Rust Testing Guide: cargo test, Unit Tests, and Integration Tests

Rust Testing Guide: cargo test, Unit Tests, and Integration Tests

Rust ships with a built-in test framework. No extra dependencies, no configuration files — just annotate a function with #[test] and run cargo test. That simplicity is deceptive: the Rust test model covers unit tests, integration tests, doc tests, and benchmarks within a single coherent workflow.

This guide covers everything you need to write, organize, and run tests effectively in Rust.

The Basics: #[test] and cargo test

A Rust test is any function annotated with #[test]:

#[test]
fn it_works() {
    let result = 2 + 2;
    assert_eq!(result, 4);
}

Run all tests with:

cargo test

Cargo compiles your tests and runs them in parallel by default. The output shows each test result, panics are caught per-test, and a summary is printed at the end.

Filtering Tests

Run only tests matching a pattern:

cargo test parsing          <span class="hljs-comment"># runs all tests with "parsing" in their name
cargo <span class="hljs-built_in">test -- --test-thread=1  <span class="hljs-comment"># run tests sequentially
cargo <span class="hljs-built_in">test -- --nocapture    <span class="hljs-comment"># print stdout/stderr from passing tests

Assertion Macros

Rust's standard library provides the core assertion macros:

Macro Purpose
assert!(expr) Panics if expr is false
assert_eq!(left, right) Panics if values are not equal
assert_ne!(left, right) Panics if values are equal

Add a custom message with an optional format string:

assert_eq!(result, 42, "expected 42 but got {}", result);

Organizing Unit Tests

Rust's convention is to put unit tests in the same file as the code being tested, inside a tests module with #[cfg(test)]:

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn test_add_negatives() {
        assert_eq!(add(-1, -1), -2);
    }
}

The #[cfg(test)] attribute tells Cargo to compile this module only during cargo test, not in release builds. The use super::* brings the parent module's items into scope.

Testing Private Functions

Because unit tests live in the same file, they can access private functions directly:

fn internal_helper(x: u32) -> u32 {
    x * 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_internal_helper() {
        assert_eq!(internal_helper(5), 10);
    }
}

This is one of the ergonomic advantages of Rust's test model compared to languages where private-function testing requires workarounds.

Integration Tests

Integration tests live in a tests/ directory at the crate root, alongside src/:

my_crate/
├── src/
│   └── lib.rs
└── tests/
    ├── api_test.rs
    └── user_flow_test.rs

Each file in tests/ is compiled as a separate crate that can only access your library's public API:

// tests/api_test.rs
use my_crate::Calculator;

#[test]
fn test_calculator_add() {
    let calc = Calculator::new();
    assert_eq!(calc.add(10, 5), 15);
}

Run only integration tests:

cargo test --<span class="hljs-built_in">test api_test

Shared Test Utilities

If multiple integration test files need shared helpers, put them in a module under tests/:

tests/
├── common/
│   └── mod.rs      # shared setup code
├── api_test.rs
└── user_flow_test.rs
// tests/common/mod.rs
pub fn setup() -> String {
    String::from("test-db-url")
}
// tests/api_test.rs
mod common;

#[test]
fn test_with_setup() {
    let db_url = common::setup();
    // use db_url...
}

Cargo won't compile common/mod.rs as a test file itself (it has no #[test] functions), so it acts purely as shared infrastructure.

Testing Expected Failures

Use #[should_panic] to assert that a test panics:

#[test]
#[should_panic(expected = "divide by zero")]
fn test_divide_by_zero() {
    divide(10, 0);
}

The expected parameter checks that the panic message contains the given string. Omit it to match any panic.

Tests That Return Result

Instead of panicking, tests can return Result<(), E>:

#[test]
fn test_parse_number() -> Result<(), std::num::ParseIntError> {
    let n: i32 = "42".parse()?;
    assert_eq!(n, 42);
    Ok(())
}

This allows using the ? operator inside tests, which is cleaner for error-heavy code paths.

Ignoring Tests

Mark a test with #[ignore] to skip it by default:

#[test]
#[ignore = "requires external database"]
fn test_database_connection() {
    // ...
}

Run ignored tests explicitly:

cargo test -- --ignored          <span class="hljs-comment"># only ignored
cargo <span class="hljs-built_in">test -- --include-ignored  <span class="hljs-comment"># all tests including ignored

Benchmark Tests

Basic benchmarks use the test crate (nightly only):

#![feature(test)]
extern crate test;

#[cfg(test)]
mod benchmarks {
    use test::Bencher;

    #[bench]
    fn bench_sort_vec(b: &mut Bencher) {
        b.iter(|| {
            let mut v: Vec<i32> = (0..1000).rev().collect();
            v.sort();
        });
    }
}

For stable Rust benchmarking, use the Criterion crate instead (covered in a separate guide).

Doc Tests

Rust compiles and runs examples in doc comments as tests:

/// Adds two numbers.
///
/// # Examples
///
/// ```
/// let result = my_crate::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

Run only doc tests:

cargo test --doc

Doc tests guarantee that your examples actually compile and produce the expected output, keeping documentation in sync with the code.

Test Configuration

Control parallelism and output via environment variables and flags:

# Limit to 4 parallel test threads
cargo <span class="hljs-built_in">test -- --test-threads=4

<span class="hljs-comment"># Show output for passing tests
cargo <span class="hljs-built_in">test -- --nocapture

<span class="hljs-comment"># Show test execution time
cargo <span class="hljs-built_in">test -- --report-time

CI Setup

A minimal GitHub Actions workflow for Rust tests:

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test --all-features

Add --all-features to test with every feature flag enabled. For workspace projects, use cargo test --workspace.

Structuring a Testable Rust Codebase

A few patterns that make Rust code easier to test:

Dependency injection via traits. Define interfaces as traits, pass them as generics or Box<dyn Trait>. Tests substitute a mock implementation.

Separate I/O from logic. Functions that only operate on data are trivial to test. Push file reads, network calls, and time-of-day lookups to the edges of your call graph.

Use #[cfg(test)] for test-only code. Constructors, factory methods, or Default impls that only make sense in tests belong inside #[cfg(test)] blocks to avoid bloating production binaries.

Connecting Tests to Monitoring

Unit and integration tests validate correctness at build time. For production behavior, HelpMeTest runs your test scenarios 24/7 against live endpoints — no source code access required, no infrastructure to manage.

Free plan: 10 tests, 5-minute monitoring intervals. Pro: $100/month for unlimited tests and parallel execution.

Summary

  • #[test] marks test functions; cargo test runs them all
  • Unit tests live in #[cfg(test)] mod tests alongside the code they test
  • Integration tests live in tests/ and only access the public API
  • #[should_panic], Result-returning tests, and #[ignore] cover edge cases
  • Doc tests keep examples in sync with the implementation
  • Structure code with trait-based DI and pure functions to maximize testability

Read more