Rust: Testing

Home · Blog

24 January 2023

Like other modern programming languages, Rust has support for automated tests built-in. In fact, when you set up a library with cargo new --lib, it’ll generate a sample function with a sample unit test.

Unit tests

Unit tests are usually included in the same source file as the domain code, in a nested module called test. Here’s an example testing a factorial function:

fn factorial(n: usize) -> usize {
    if n <= 1 { 1 } else { n * factorial(n - 1) }
}

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

    #[test]
    fn factorial_works() {
        assert!(factorial(1) == 1);
    }
}

The tests module is annotated with #[cfg(test)] so that it will only be compiled when running tests. If we run the tests with cargo test, cargo builds an executable that runs each function annotated with #[test] as a test. Other functions are not run, so you can have helper functions. The line

use super::*;

imports all definitions in the outer module, including private ones.

If a test function panics, the test counts as failed; otherwise it has succeeded. Most tests use one of the three assertion macros:

#[test]
fn factorial_works() {
    assert!(factorial(1) == 1);    // assert true
    assert_eq!(factorial(1), 1);   // assert equal
    assert_ne!(factorial(1), 0);   // assert not equal
}

You can set a custom message for assertions with optional parameters that are passed to the format! macro. We can use that for a table-driven test following the Go convention:

#[test]
fn table_driven() {
    let cases = vec![(0, 1), (1, 1), (2, 2), (3, 6), (4, 24), (5, 120)];
    for (n, want) in cases {
        let got = factorial(n);
        assert_eq!(got, want,
            "factorial({}) == {}, want {}", n, got, want);
    }
}

If you have code that’s supposed to panic, use the should_panic attribute to test it:

#[test]
#[should_panic]
fn test_vector_panic() {
    vec![][0]
}

The test will count as failed if it doesn’t panic.

Test functions can also return Result, with Ok indicating a successful test run and Err or a panic a failed one. This lets you use the question mark operator in tests:

#[test]
fn read_to_string_works() -> std::io::Result<()> {
    let text = fs::read_to_string("sample-file.txt")?;
    assert_eq!(text, "Sample text\n");
    Ok(())
}

Ignored tests

If you have a test that shouldn’t always run, for example because it takes a long time, annotate it with #[ignore]:

#[test]
#[ignore]
fn slow_test() {
    assert_eq!(process(big_dataset()), 42);
}

This test will be skipped by default; to run it, use

cargo test -- --ignored

We need the -- because we’re passing an argument to the test binary, not to cargo. To run all tests:

cargo test -- --include-ignored

Doc-tests

cargo test will also run code in documentation comments; if the code panics, the test fails. This helps to catch errors in the documentation early. Here’s the factorial function again:

/// Compute the factorial of n.
///
/// ```
/// let result = mycrate::factorial(5);
/// assert_eq!(result, 120);
/// ```
pub fn factorial(n: usize) -> usize {
    if n <= 1 { 1 } else { n * factorial(n - 1) }
}

Integration tests

There’s a third category of tests run by cargo test after unit and doc tests: integration tests. To set up integration tests, create a directory tests at the same level as src. Each file there will be compiled as a separate crate; functions marked with #[test] will be considered tests just as for unit tests. Here’s an integration test for the factorial function:

use mycrate;

#[test]
fn factorial_of_six() {
    assert_eq!(mycrate::factorial(6), 720);
}

Integration tests can only use the public interface of the module. Their purpose is usually to test that various parts of your library work together -- hence the name integration test.