RustWriting Tests

Writing Tests in Rust

Testing is a first-class citizen in Rust. The language and its toolchain ship with everything you need to write unit tests, integration tests, and documentation tests — no external framework required. The cargo test command discovers and runs them all, prints a clear pass/fail report, and integrates naturally with CI pipelines.

The #[test] Attribute

Mark any function with #[test] to tell the compiler it is a test. Test functions take no arguments and return either () or Result<(), E>. Cargo compiles them only when running tests — they never ship in your release binary.

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

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

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured
Running Tests with cargo test

cargo test builds a special test binary, runs every function marked #[test], and prints a summary. By default, tests run in parallel across multiple threads.

Bash
cargo test                      # run all tests
cargo test test_add              # run tests whose name contains "test_add"
cargo test math::                # run tests inside the math module
cargo test -- --test-threads=1  # force serial execution
Tip
Filtering by name is a substring match — cargo test add runs every test whose name contains the string "add".
assert!, assert_eq!, and assert_ne!

Rust provides three assertion macros for tests. All panic (and therefore fail the test) when the condition is not met, printing a helpful diagnostic automatically.

Macro

Passes when

Shows on failure

assert!(expr)

expr is true

The expression that was false

assert_eq!(a, b)

a == b

Both values side by side

assert_ne!(a, b)

a != b

Both values side by side

RUST
#[test]
fn assertion_examples() {
    let x = 4;
    assert!(x > 0);
    assert_eq!(x, 4);
    assert_ne!(x, 99);

    // Custom failure message (format-string style)
    assert!(x < 10, "expected x < 10, got x = {}", x);
    assert_eq!(x, 4, "x should still be 4, got {}", x);
}
Note
Values compared with assert_eq! and assert_ne!must implement the PartialEq and Debug traits. Derive both on your own types with #[derive(Debug, PartialEq)].
Testing for Panics with #[should_panic]

Some functions are supposed to panic under invalid input. Add #[should_panic] to verify that the panic actually occurs. Use the expected parameter to check that the panic message contains a specific string — this prevents the test from silently passing when a different panic fires.

RUST
fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("division by zero");
    }
    a / b
}

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

#[test]
#[should_panic]   // any panic passes — less precise
fn test_divide_by_zero_loose() {
    divide(5, 0);
}
Warning
Avoid bare #[should_panic] without expected in serious test suites. Without it, an unrelated panic caused by a bug elsewhere will still make the test pass.
Returning Result from Tests

Tests may return Result&lt;(), E&gt; instead of panicking. This lets you use the ? operator inside tests, which makes working with fallible operations (file I/O, parsing, network calls) much more ergonomic.

RUST
use std::num::ParseIntError;

fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
    let n: i32 = s.parse()?;
    Ok(n * 2)
}

#[test]
fn test_parse_and_double() -> Result<(), ParseIntError> {
    let result = parse_and_double("21")?;
    assert_eq!(result, 42);
    Ok(())
}
Tip
When a Result-returning test returns Err, Cargo marks it as failed and prints the error value — no custom panic message needed.
Unit Tests: #[cfg(test)] mod tests

Unit tests live right next to the code they test, inside a dedicated module annotated with #[cfg(test)]. Cargo compiles this module only during cargo test, so it adds zero overhead to your release binary.

The key advantage: because the test module is a child of the module under test, it can access private functions and types directly.

RUST
// src/math.rs
pub fn square(n: i32) -> i32 {
    n * n
}

fn cube(n: i32) -> i32 {  // private helper
    n * n * n
}

#[cfg(test)]
mod tests {
    use super::*;  // brings everything, including private items, into scope

    #[test]
    fn test_square() {
        assert_eq!(square(4), 16);
    }

    #[test]
    fn test_cube() {   // can test the private function
        assert_eq!(cube(3), 27);
    }
}
Integration Tests: the tests/ Directory

Integration tests live in a top-level tests/ directory. Each file in that directory is compiled as its own separate crate that links against your library — so they can only call public API, exactly as an external user would.

Bash
my_project/
├── src/
│   └── lib.rs
└── tests/
    ├── integration_test.rs   # one test crate
    └── another_test.rs       # another test crate

RUST
// tests/integration_test.rs
use my_project::square;  // only public API is accessible here

#[test]
fn test_square_public() {
    assert_eq!(square(5), 25);
}
Note
Shared helpers for integration tests go in tests/common/mod.rs(not tests/common.rs). Using a subdirectory prevents Cargo from treating the helper file as its own test crate.
Doc Tests

Any code block inside a /// doc comment is compiled and run as a test by cargo test. Doc tests ensure your examples stay correct as the code evolves — broken examples fail the test suite immediately.

RUST
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// let result = my_crate::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}
running 1 test
test src/lib.rs - add (line 5) ... ok
Tip
Prefix a line with # (hash + space) inside a doc-test code block to hide it from rendered documentation while still running it. This is useful for setup boilerplate like use statements.
Controlling Test Output

By default cargo test captures all stdout from tests and only shows it when a test fails. The flags below change that behaviour.

Bash
# show println! output even for passing tests
cargo test -- --show-output

# run tests one at a time (useful when tests share global state)
cargo test -- --test-threads=1

# do not capture stdout at all (output appears immediately)
cargo test -- --nocapture
Ignoring Slow Tests with #[ignore]

Tag a test with #[ignore] to skip it during the normal cargo test run. This is useful for tests that hit a network, write to disk, or simply take a long time. You can still run ignored tests explicitly when needed.

RUST
#[test]
#[ignore = "slow: downloads large dataset"]
fn test_large_download() {
    // ...
}

// Run ONLY the ignored tests:
// cargo test -- --ignored

// Run ALL tests including ignored:
// cargo test -- --include-ignored
Test Helpers and Fixtures

For tests that share setup logic, extract a plain setup() function and call it at the start of each test. When tests need temporary files, use the tempfile crate.

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

    fn setup() -> Vec<i32> {
        vec![1, 2, 3, 4, 5]
    }

    #[test]
    fn test_sum() {
        let data = setup();
        assert_eq!(data.iter().sum::<i32>(), 15);
    }

    #[test]
    fn test_len() {
        let data = setup();
        assert_eq!(data.len(), 5);
    }
}
Mocking with the mockall Crate

Rust does not have built-in mocking, but the mockall crate provides a powerful macro-based approach. Add it as a dev dependency — compiled only during tests — and annotate your trait with #[automock].

TOML
[dev-dependencies]
mockall = "0.13"

RUST
use mockall::automock;

#[automock]
trait Database {
    fn fetch_user(&self, id: u32) -> Option<String>;
}

#[test]
fn test_with_mock() {
    let mut mock = MockDatabase::new();
    mock.expect_fetch_user()
        .with(mockall::predicate::eq(1))
        .returning(|_| Some("Alice".to_string()));

    assert_eq!(mock.fetch_user(1), Some("Alice".to_string()));
}
Success
Rust's built-in test framework handles the vast majority of testing needs. Reach for third-party crates like mockall, tempfile, or proptest only when you need capabilities beyond basic assertions — and even then, a single cargo add --dev is all it takes.