cargo test Deep Dive: Options, Filtering, and Organization
cargo test is deceptively simple on the surface. You type it, tests run. But there's a rich set of options that most Rust developers never fully explore — options that can make your test suite faster, more precise, and easier to debug.
This is a deep dive into cargo test: how it works, what you can control, and how to structure tests in larger Rust projects.
How cargo test Works
When you run cargo test, Cargo compiles two kinds of test binaries:
- Unit test binary — one per library crate, compiled with
#[cfg(test)]enabled. Contains all#[test]functions fromsrc/. - Integration test binaries — one per file in
tests/. Each is a separate binary with access only to your crate's public API. - Doc test binary — extracts and runs code examples from documentation.
These binaries are linked against your library as if they were external crates (for integration tests) or internal modules (for unit tests). Understanding this distinction is key to knowing where to put your tests.
Basic Filtering
Run a subset of tests by passing a filter string:
cargo test auth # Run all tests whose name contains "auth"
cargo test auth::login # More specific — tests in the auth::login module
cargo test -- --exact test_user_login # Exact name matchThe filter is a substring match against the full test name, which includes the module path. For a test fn test_login() inside mod auth { mod login { ... } }, the full name is auth::login::test_login.
# Run only tests in a specific file
cargo test --test integration_tests
# Run only doc tests
cargo test --doc
# Run only unit tests (not integration tests)
cargo test --lib
# Run only binary tests
cargo test --bin myappControlling Parallelism
By default, cargo test runs tests in parallel using one thread per logical CPU. This is usually what you want, but some tests are sensitive to parallelism:
# Run tests sequentially (one at a time)
cargo test -- --test-threads=1
# Use a specific number of threads
cargo test -- --test-threads=4The --test-threads flag is passed to the test binary (hence the -- separator). It controls how many tests run simultaneously within a single test binary, not across binaries.
For tests that touch shared resources (ports, files, environment variables), use --test-threads=1 or use serial_test crate to mark specific tests as serial:
use serial_test::serial;
#[test]
#[serial]
fn test_database_migration() {
// Only one test with #[serial] runs at a time
}Capturing and Displaying Output
By default, cargo test suppresses stdout from passing tests. When a test fails, it shows the captured output. To always show output:
# Show println! output even for passing tests
cargo test -- --nocapture
# Show output and don't run in parallel (useful for debugging)
cargo test -- --nocapture --test-threads=1This is invaluable for debugging — add println! to a test and run with --nocapture to trace execution.
The #[cfg(test)] Module Pattern
Unit tests live inside the source file they test, inside a module gated by #[cfg(test)]:
// src/parser.rs
pub fn parse_int(s: &str) -> Result<i64, ParseError> {
// implementation
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_positive_integer() {
assert_eq!(parse_int("42"), Ok(42));
}
#[test]
fn test_negative_integer() {
assert_eq!(parse_int("-17"), Ok(-17));
}
#[test]
fn test_invalid_input() {
assert!(parse_int("abc").is_err());
}
}The use super::* brings the parent module's items into scope, including private functions. This is the key advantage of unit tests inside source files — they can test private internals that integration tests can't reach.
Test Organization for Large Projects
For larger codebases, keeping all tests inside source files gets messy. A common pattern:
src/
lib.rs
auth/
mod.rs
login.rs
token.rs
users/
mod.rs
tests/
auth_flow.rs
user_management.rs
helpers/
mod.rs
fixtures.rsUnit tests (testing private internals) go in src/. Integration tests (testing public API, end-to-end flows) go in tests/.
For shared test helpers in tests/, use a helpers or common submodule:
// tests/helpers/mod.rs
pub fn create_test_db() -> Database {
// setup code
}
pub fn cleanup(db: Database) {
// teardown code
}Then in tests:
// tests/auth_flow.rs
mod helpers;
#[test]
fn test_full_login_flow() {
let db = helpers::create_test_db();
// test...
helpers::cleanup(db);
}Note: every file in tests/ is its own binary. If you have tests/helpers/mod.rs, Cargo won't try to run it as a test binary (since it's a directory module, not a top-level .rs file).
Test Attributes
Beyond #[test], several attributes control test behavior:
#[ignore]
Skip a test by default but allow it to run explicitly:
#[test]
#[ignore = "requires running database"]
fn test_with_live_db() {
// Only runs with: cargo test -- --ignored
}Run all ignored tests: cargo test -- --ignored Run both regular and ignored: cargo test -- --include-ignored
#[should_panic]
Assert a test panics:
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_out_of_bounds() {
let v = vec![1, 2, 3];
let _ = v[10]; // Should panic
}The expected string is optional but recommended — it verifies the panic message, preventing false positives from unexpected panics.
Returning Result from Tests
Tests can return Result<(), E> to use the ? operator:
#[test]
fn test_file_parsing() -> Result<(), Box<dyn std::error::Error>> {
let content = std::fs::read_to_string("fixtures/sample.txt")?;
let parsed = parse_file(&content)?;
assert_eq!(parsed.count(), 42);
Ok(())
}If the test returns Err, it fails with the error displayed. This is much cleaner than unwrap() chains in tests.
Cargo Test Profiles
Tests compile with the test profile by default. Customize it in Cargo.toml:
[profile.test]
opt-level = 1 # Some optimization (default is 0)
debug = true # Keep debug info
overflow-checks = true # Panic on integer overflow
# Fast test profile for CI
[profile.ci-test]
inherits = "test"
opt-level = 2Run with a custom profile:
cargo test --profile ci-testFor faster compile times during development, enable incremental compilation:
[profile.test]
incremental = trueRunning Tests in Release Mode
Running tests with --release uses the release profile. This matters because:
- Optimizations can expose or hide bugs (integer overflow behavior differs)
- Some bugs only appear with LLVM optimizations enabled
- It's much faster for compute-heavy tests
cargo test --releaseCustom Test Runners with libtest-mimic
The default test runner (libtest) is fine for most cases. For custom behavior (different output formats, test selection logic), use libtest-mimic:
[dev-dependencies]
libtest-mimic = "0.7"// tests/custom_runner.rs
use libtest_mimic::{Arguments, Trial};
fn main() {
let args = Arguments::from_args();
let tests = vec![
Trial::test("my_test", || {
// test logic
Ok(())
}),
];
libtest_mimic::run(&args, tests).exit();
}Then in Cargo.toml:
[[test]]
name = "custom_runner"
harness = false # Disable default test harnessThe harness = false flag tells Cargo not to inject the default test runner — your main function controls everything.
Nextest: A Faster Test Runner
cargo-nextest is a third-party test runner that's significantly faster than the default:
cargo install cargo-nextest
# Run all tests
cargo nextest run
# Run with filter
cargo nextest run auth
# List tests without running
cargo nextest listNextest runs each test in its own process, which means:
- Tests can't interfere with each other via global state
- Failures don't abort the test run
- Better parallelism on multi-core machines
- JUnit XML output for CI systems
For CI, nextest is often 2-3x faster than cargo test on large test suites.
Measuring Test Coverage
Cargo doesn't include coverage tools, but the ecosystem has options:
cargo-tarpaulin (Linux)
cargo install cargo-tarpaulin
cargo tarpaulin --out Htmlcargo-llvm-cov (Cross-platform)
cargo install cargo-llvm-cov
cargo llvm-cov --htmlSee Rust test coverage with tarpaulin and llvm-cov for a complete guide.
Test Output in CI
For CI environments, use --no-fail-fast to collect all failures before exiting:
# Run all tests even if some fail, get a complete picture
cargo test -- --no-fail-fast
# nextest with JUnit output (for CI reporting)
cargo nextest run --profile ciNextest supports JUnit XML natively, which CI systems like GitHub Actions and Jenkins can parse for test reporting.
Example nextest.toml in your repo:
[profile.ci]
fail-fast = false
test-threads = "num-cpus"
junit = { path = "target/nextest/ci/junit.xml" }Environment-Specific Tests
Use #[cfg] attributes to conditionally compile tests based on features or target:
#[test]
#[cfg(target_os = "linux")]
fn test_linux_specific_behavior() {
// Only compiles and runs on Linux
}
#[test]
#[cfg(feature = "postgres")]
fn test_postgres_integration() {
// Only runs when `postgres` feature is enabled
}Run feature-gated tests:
cargo test --features postgresContinuous Test Monitoring
Local test suites catch regressions during development. But for production systems, you need to verify behavior continuously — not just in CI.
HelpMeTest runs your tests as always-on monitors. Write a test scenario once, and it runs every few minutes against your production (or staging) environment:
Scenario: API returns valid response
When I send GET /api/health
Then status code is 200
And response body contains "ok"This catches issues that only appear under real-world conditions — configuration drift, external service changes, or environment-specific bugs your local cargo tests can't detect.
Key Takeaways
- Unit tests live in
src/inside#[cfg(test)]modules — test private internals - Integration tests live in
tests/— test public API as external consumers would - Filter tests by name substring; use
--exactfor precise matching --test-threads=1when tests share global state or resources#[ignore]for slow or environment-dependent tests; run explicitly when needed- Return
Resultfrom tests to use?and get better error output - cargo-nextest is significantly faster for large test suites
- Custom test runners with
harness = falsefor special requirements