Modules, Visibility and Testing · about 45 min

Unit Tests with #[test]

Why this matters

So far you have checked programs by running them and looking at the output. That stops scaling quickly: every change means re-checking everything by hand, and the edge case you checked last week is forgotten. Automated tests are small functions that call your code and assert what the result must be. They run in seconds, catch regressions the moment they happen, and document the intended behavior better than comments do. Rust has testing built into the compiler and cargo, with no framework to install.

A first test

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

fn main() {
    println!("{}", add(2, 2));
}

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

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

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

Running it: with plain rustc, compile in test mode and run the result. (With cargo, cargo test does both.)

$ rustc --edition 2021 --test -o t main.rs
$ ./t
running 2 tests
test tests::adds_negatives ... ok
test tests::adds_small_numbers ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

--test builds a different program: instead of your main, a generated test runner that calls every #[test] function (in parallel threads), catches panics, and reports. A test passes if it returns without panicking and fails if it panics.

Assertions

assert!(condition);                    // panics if false
assert!(condition, "message {}", x);   // with a custom message
assert_eq!(left, right);               // panics if left != right, printing both
assert_ne!(left, right);               // panics if they are equal

assert_eq! needs the values to implement PartialEq (to compare) and Debug (to print them on failure). When a test fails, the output shows exactly what went wrong:

fn average(v: &[i32]) -> i32 {
    v.iter().sum::<i32>() / v.len() as i32
}

fn main() {}

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

    #[test]
    fn average_of_three() {
        assert_eq!(average(&[1, 2, 4]), 2);
    }

    #[test]
    fn average_rounds() {
        assert_eq!(average(&[1, 2]), 2, "1 and 2 should round up to 2");
    }
}
$ ./t
running 2 tests
test tests::average_of_three ... ok
test tests::average_rounds ... FAILED

failures:

---- tests::average_rounds stdout ----

thread 'tests::average_rounds' panicked at main.rs:18:9:
assertion `left == right` failed: 1 and 2 should round up to 2
  left: 1
 right: 2
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::average_rounds

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The runner exits with a non-zero status when any test fails, which is what CI systems check. (Here the test documents a requirement the implementation does not meet: integer division truncates. Whether the test or the code is wrong is a design question; the test made it visible.)

Running a subset

The test binary accepts a filter: ./t average runs only tests whose names contain "average"; cargo test average does the same. ./t --nocapture shows println! output from passing tests (normally captured and shown only for failures). #[ignore] marks slow tests to skip unless asked with --ignored.

Testing private functions

Because tests is a child module, use super::* gives it access to private items. Rust's convention is that unit tests sit next to the code and may test internals; integration tests (in a separate tests/ directory of a cargo project) use only the public API, like an outside user would.

What a good unit test looks like

Common mistakes

Recap

Going further: in the sandbox shell, create a cargo project, put a function and tests in src/lib.rs, and run cargo test. Read the Rust Book chapter 11.1, "How to Write Tests".

Exercises

Exercise 1 · write code

Write the tests

is_leap is already written. Add a #[cfg(test)] mod tests with at least four #[test] functions that pin down the leap-year rule: an ordinary year divisible by 4 (2024), a non-multiple of 4 (2023), a century that is not a leap year (1900) and a century that is (2000). Use assert! / assert!(!...) or assert_eq!.

The checker compiles your file with rustc --test, runs your tests, and then runs them again against several buggy versions of is_leap: your tests must catch every bug. Keep the lines // BEGIN IMPL and // END IMPL around the function. main must still print:

2000 is a leap year

File: main.rs

Starting point

// BEGIN IMPL
fn is_leap(year: u32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
// END IMPL

fn main() {
    let y = 2000;
    if is_leap(y) {
        println!("{y} is a leap year");
    }
}

Example

expected output

2000 is a leap year
Hint 1

Start with #[cfg(test)] mod tests { use super::*; #[test] fn some_name() { assert!(is_leap(2024)); } }.

Hint 2

One test per rule. assert!(!is_leap(1900)); checks that something is false.

Reference solution
// BEGIN IMPL
fn is_leap(year: u32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
// END IMPL

fn main() {
    let y = 2000;
    if is_leap(y) {
        println!("{y} is a leap year");
    }
}

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

    #[test]
    fn divisible_by_four() {
        assert!(is_leap(2024));
    }

    #[test]
    fn not_divisible_by_four() {
        assert!(!is_leap(2023));
    }

    #[test]
    fn century_is_not_leap() {
        assert!(!is_leap(1900));
    }

    #[test]
    fn four_hundred_is_leap() {
        assert!(is_leap(2000));
    }
}

Exercise 2 · write code

Make the tests pass

The tests at the bottom of this file describe fn clamp_percent(x: i32) -> u8: values below 0 become 0, above 100 become 100, everything else is unchanged. The implementation is wrong. Fix the function (do not change the tests) so that all tests pass; the checker runs them with rustc --test. main prints:

0 42 100

File: main.rs

Starting point

fn clamp_percent(x: i32) -> u8 {
    if x > 100 {
        100
    } else {
        x as u8
    }
}

fn main() {
    println!("{} {} {}", clamp_percent(-5), clamp_percent(42), clamp_percent(300));
}

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

    #[test]
    fn in_range() {
        assert_eq!(clamp_percent(0), 0);
        assert_eq!(clamp_percent(55), 55);
        assert_eq!(clamp_percent(100), 100);
    }

    #[test]
    fn above() {
        assert_eq!(clamp_percent(101), 100);
        assert_eq!(clamp_percent(i32::MAX), 100);
    }

    #[test]
    fn below() {
        assert_eq!(clamp_percent(-1), 0, "negative values must clamp to 0");
        assert_eq!(clamp_percent(i32::MIN), 0);
    }
}

Example

expected output

0 42 100
Hint 1

Run the tests and read the failure: -1 as u8 is 255, not 0.

Hint 2

Add a branch else if x < 0 { 0 }.

Reference solution
fn clamp_percent(x: i32) -> u8 {
    if x > 100 {
        100
    } else if x < 0 {
        0
    } else {
        x as u8
    }
}

fn main() {
    println!("{} {} {}", clamp_percent(-5), clamp_percent(42), clamp_percent(300));
}

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

    #[test]
    fn in_range() {
        assert_eq!(clamp_percent(0), 0);
        assert_eq!(clamp_percent(55), 55);
        assert_eq!(clamp_percent(100), 100);
    }

    #[test]
    fn above() {
        assert_eq!(clamp_percent(101), 100);
        assert_eq!(clamp_percent(i32::MAX), 100);
    }

    #[test]
    fn below() {
        assert_eq!(clamp_percent(-1), 0, "negative values must clamp to 0");
        assert_eq!(clamp_percent(i32::MIN), 0);
    }
}

Exercise 3 · quiz

What does #[cfg(test)] do?

What is the effect of putting #[cfg(test)] on mod tests?

Answer
  • The module is compiled only when building tests (rustc --test or cargo test), so it adds nothing to the normal program

cfg is conditional compilation: the item exists only when the configuration flag is set. rustc --test (and cargo test) set the test flag and also build a test runner as main. In a normal build, the test module and any test-only helpers or imports are left out entirely, so they cost nothing and cause no unused warnings.