Skip to content

10 - Tests

What this session is

About 45 minutes. You'll learn how to write tests in Rust. Like nearly all Rust tooling, the test framework is built into the toolchain - cargo test and you're going. No frameworks to install.

The simplest test

In any module, mark a function #[test]:

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

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

Run:

cargo test

You should see:

running 1 test
test test_add ... ok

test result: ok. 1 passed; 0 failed; 0 ignored

#[test] marks the function as a test. cargo test auto-discovers and runs them. assert_eq!(actual, expected) checks for equality and panics with a useful message on failure.

Convention: tests in a #[cfg(test)] module

Convention: put unit tests at the bottom of the same file as the code, wrapped in a tests module:

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

pub fn is_even(n: i32) -> bool {
    n % 2 == 0
}

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

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

    #[test]
    fn detects_even() {
        assert!(is_even(4));
        assert!(!is_even(7));
    }
}

What's new: - #[cfg(test)] - only compile this module when running tests. Keeps test code out of release builds. - mod tests { ... } - a module named tests. - use super::*; - bring all of the parent module's items into the test module's scope. - assert!(condition) - passes if condition is true; panics otherwise.

This is the idiomatic shape. Real Rust libraries put unit tests right next to the code.

Useful assertions

  • assert!(cond) - true.
  • assert_eq!(a, b) - equal.
  • assert_ne!(a, b) - not equal.

With custom messages:

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

For tests on Result-returning functions:

#[test]
fn parse_works() -> Result<(), Box<dyn std::error::Error>> {
    let n: i32 = "42".parse()?;
    assert_eq!(n, 42);
    Ok(())
}

If you return Result, you can use ? inside the test. Convenient.

Watching a test fail (do this)

In your project, change add to a - b. Run cargo test. You should see:

test tests::adds_two_numbers ... FAILED

failures:

---- tests::adds_two_numbers stdout ----
thread 'tests::adds_two_numbers' panicked at src/lib.rs:13:9:
assertion `left == right` failed
  left: -1
 right: 5

The failure tells you: what was actual (-1), what was expected (5), the line. Restore add; tests pass again.

Testing panics: #[should_panic]

For tests that the code should panic in:

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

expected = "..." requires the panic message to contain that substring. Useful for testing invariant checks.

Testing Results

For functions returning Result, two patterns:

#[test]
fn parse_succeeds() {
    let r = "42".parse::<i32>();
    assert!(r.is_ok());
    assert_eq!(r.unwrap(), 42);
}

#[test]
fn parse_fails_on_garbage() {
    assert!("hello".parse::<i32>().is_err());
}

Or the Result-returning test from above (cleaner when you want to chain ?s).

Parameterized tests: not built in

Unlike Java's @ParameterizedTest, Rust doesn't have built-in parameterization. Two common workarounds:

Manual loop:

#[test]
fn is_even_cases() {
    let cases = [(0, true), (1, false), (2, true), (-4, true), (-7, false)];
    for (n, expected) in cases {
        assert_eq!(is_even(n), expected, "is_even({n}) was wrong");
    }
}

Simple and clear. Common in real Rust.

The rstest crate provides #[rstest] and #[case(...)] if you want first-class parameterized tests. We'll meet crates in page 11.

Integration tests

Unit tests (above) live in the same file as the code they test. Integration tests live in a top-level tests/ directory and test the public API as an external user would:

my_project/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    └── integration_test.rs

tests/integration_test.rs:

use my_project::add;

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

cargo test runs both unit and integration tests.

Documentation tests (free, easy, wonderful)

Code samples in doc comments are also tests:

/// Returns the sum of two integers.
///
/// # Examples
///
/// ```
/// use my_project::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

cargo test compiles and runs every code block in /// comments. Documentation stays accurate because it's tested. One of Rust's killer features.

Useful cargo test flags

Flag What it does
cargo test Run all tests.
cargo test -- --nocapture Show println! output from passing tests.
cargo test foo Run only tests whose name contains "foo".
cargo test -- --test-threads=1 Run sequentially (default is parallel).
cargo test --release Run with optimizations on (slower compile, runtime matters for some tests).

Cargo bench (briefly)

For performance benchmarks, the stable approach is the criterion crate (we'll mention crates in page 11). Don't try to use cargo bench on stable Rust - it's nightly-only currently.

Exercise

In your project:

  1. Make src/lib.rs (instead of src/main.rs, or in addition - Cargo supports both):

    pub fn word_count(s: &str) -> usize {
        s.split_whitespace().count()
    }
    
    pub fn is_palindrome(s: &str) -> bool {
        let cleaned: String = s.to_lowercase();
        cleaned == cleaned.chars().rev().collect::<String>()
    }
    

  2. Update Cargo.toml if needed - change the [package] to have a library, or leave the binary structure. (Cargo auto-detects lib.rs.)

  3. Add a #[cfg(test)] mod tests at the bottom of lib.rs with:

  4. Parameterized test for word_count (the manual-loop pattern): "" → 0, "hello" → 1, "hello world" → 2, " many spaces here " → 3.
  5. Parameterized test for is_palindrome: "" → true, "a" → true, "racecar" → true, "hello" → false, "Racecar" → true.

  6. Run cargo test.

  7. Break each function. Watch the test fail. Fix. Watch it pass.

Stretch: add a doctest above word_count showing how to use it. Run cargo test and confirm the doctest runs.

What you might wonder

"Why are tests in the same file as the code?" Convention. Unit tests live in #[cfg(test)] mod tests at the bottom; integration tests go in tests/. This lets unit tests access private functions (they're in the same module). Integration tests can only see the public API.

"How do I mock external dependencies?" Rust mocking isn't as automated as in Java/Python. The idioms: design your code with traits at boundaries (so you can substitute a fake impl), or use crates like mockall. Out of scope for beginner; recognize when you encounter it.

"What about coverage?" cargo install cargo-tarpaulin then cargo tarpaulin. Reports line/branch coverage. Useful but don't chase 100% - chase confidence.

Done

  • Write tests in #[cfg(test)] mod tests modules.
  • Use assert!, assert_eq!, assert_ne!.
  • Use #[should_panic] for panic tests.
  • Write doc tests in /// comments.
  • Distinguish unit tests from integration tests.

Next: Crates and Cargo →

Comments