cargo test Deep Dive: Options, Filtering, and Organization

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:

  1. Unit test binary — one per library crate, compiled with #[cfg(test)] enabled. Contains all #[test] functions from src/.
  2. Integration test binaries — one per file in tests/. Each is a separate binary with access only to your crate's public API.
  3. 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 match

The 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 myapp

Controlling 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=4

The --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=1

This 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.rs

Unit 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 = 2

Run with a custom profile:

cargo test --profile ci-test

For faster compile times during development, enable incremental compilation:

[profile.test]
incremental = true

Running 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 --release

Custom 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 harness

The 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 list

Nextest 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 Html

cargo-llvm-cov (Cross-platform)

cargo install cargo-llvm-cov
cargo llvm-cov --html

See 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 ci

Nextest 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 postgres

Continuous 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 --exact for precise matching
  • --test-threads=1 when tests share global state or resources
  • #[ignore] for slow or environment-dependent tests; run explicitly when needed
  • Return Result from tests to use ? and get better error output
  • cargo-nextest is significantly faster for large test suites
  • Custom test runners with harness = false for special requirements

Read more

Start now free