
Advanced Unit Testing and Debugging in Rust
Writing Custom Test Runners
Rust allows you to override the default test runner using the RUST_TEST_THREADS environment variable or by specifying a custom test runner in the Cargo.toml file. This is particularly useful for parallel test execution or integrating with external tools like pytest or cargo-watch.
To write a custom test runner, you can use the --test flag with cargo to execute a test binary directly, or create a custom binary that imports and runs test functions. Here's an example of a custom test runner that prints test names and execution times:
#[cfg(test)]
mod tests {
use std::time::{Instant, Duration};
#[test]
fn test_one() {
assert_eq!(1 + 1, 2);
}
#[test]
fn test_two() {
assert_eq!(2 * 2, 4);
}
#[test]
fn slow_test() {
std::thread::sleep(Duration::from_millis(500));
assert_eq!(true, true);
}
}
#[cfg(test)]
fn run_tests() {
let tests = &["test_one", "test_two", "slow_test"];
for name in tests {
let start = Instant::now();
let test_result = std::panic::catch_unwind(|| {
let test = tests::tests::get_test(name);
test();
});
let duration = start.elapsed();
match test_result {
Ok(_) => println!("✅ {} passed in {:?}", name, duration),
Err(_) => println!("❌ {} failed in {:?}", name, duration),
}
}
}
#[cfg(test)]
fn get_test(name: &str) -> fn() {
match name {
"test_one" => tests::test_one,
"test_two" => tests::test_two,
"slow_test" => tests::slow_test,
_ => panic!("Unknown test"),
}
}This approach gives you full control over test execution and reporting, which is useful in complex test environments or CI pipelines.
Using Mocks in Unit Tests
In Rust, mocking is often achieved using crates like mockall, mox, or fake. These crates allow you to create mock implementations of traits or structs to isolate units under test.
Here's an example using mockall to mock a dependency:
use mockall::predicate::*;
use mockall::*;
trait Database {
fn get_user(&self, id: u32) -> String;
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct User {
name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
#[test]
fn test_user_retrieval() {
let mut mock_db = MockDatabase::new();
mock_db.expect_get_user()
.with(eq(1))
.returning(|id| format!("Alice {}", id));
let result = mock_db.get_user(1);
assert_eq!(result, "Alice 1");
}
}
mockall::mock! {
pub struct MockDatabase {
}
impl Database for MockDatabase {
fn get_user(&self, id: u32) -> String;
}
}This pattern is particularly useful when testing code that depends on external systems like databases or APIs.
Conditional Testing with cfg and feature Flags
Rust's conditional compilation features allow you to write tests that only run under certain conditions. This is helpful for testing platform-specific behavior or optional features.
You can use the #[cfg] attribute or #[cfg_attr] to include or exclude tests based on environment or feature flags.
#[cfg(all(test, target_os = "linux"))]
#[test]
fn test_linux_specific_behavior() {
let os = std::env::consts::OS;
assert_eq!(os, "linux");
}
#[cfg(feature = "nightly")]
#[test]
fn test_with_nightly_features() {
#[cfg(nightly)]
{
// Use nightly-specific APIs here
}
}You can also use cargo test --features to enable specific feature flags during test runs.
Debugging with dbg! and trace
Rust provides the dbg! macro for quick debugging. It prints the file and line number along with the value of the expression. For more verbose logging, the tracing crate is recommended.
Example using dbg!:
fn calculate(x: i32, y: i32) -> i32 {
let result = x * y;
dbg!(result);
result
}
fn main() {
calculate(3, 4);
}For more structured debugging and performance analysis, use the tracing crate with a subscriber like tracing-subscriber:
[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"use tracing::{info, Level};
fn main() {
tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
.init();
info!("Starting application");
let result = 3 + 4;
tracing::debug!("Result is: {}", result);
}This allows you to trace application behavior in detail without cluttering your codebase.
Debugging with gdb and lldb
For low-level debugging of Rust programs, tools like gdb and lldb are invaluable. Rust provides support for these debuggers out of the box.
To debug a Rust binary with gdb, compile with debug symbols:
cargo build --release --features debugThen run the debugger:
gdb target/release/your_binaryYou can set breakpoints, inspect variables, and step through code. For more complex scenarios, such as debugging panics or memory issues, tools like valgrind and rust-gdb can be used.
