The Rust Lang Book: Testing in Rust (Part 1)
While Rust's compiler guarantees type safety, memory safety, and the absence of data races, it cannot verify that your program's business logic is correct. A program that compiles perfectly can still produce incorrect outputs if the underlying algorithms or logical conditions are flawed. To ensure logical correctness and prevent regressions, Rust provides a first-class, built-in testing framework.
This guide covers the fundamentals of writing tests in Rust, exploring the anatomy of a test, assertion macros, custom failure messages, panic verification, and alternative test return types.
1. Why Do We Need Tests in Rust?
Rust is famous for its strict compiler. The borrow checker and type system act as a powerful safety net, catching a massive class of bugs at compile time:
- Type Safety: Ensures you do not pass a string where an integer is expected.
- Memory Safety: Prevents null pointer dereferences, double frees, and use-after-free bugs.
- Concurrency Safety: Eliminates data races at compile time.
However, the compiler is blind to business logic intent.
Real-Life Analogy
Think of the Rust compiler as a building inspector checking a house's structural integrity. The inspector can guarantee that the walls won't collapse, the plumbing doesn't leak, and the electrical wiring is up to code. However, the inspector cannot tell you if the layout of the rooms makes sense for your daily life. You need to walk through the house (run tests) to verify that the design matches your actual living requirements.
To bridge this gap, you must write automated tests to verify that your code behaves exactly as intended.
2. Setting Up a Test Suite with Cargo
Cargo makes setting up a test suite incredibly simple. When creating a new library project, Cargo automatically generates a default test template.
To create a new library project named adder, run the following command in your terminal:
cargo new adder --lib
Navigating into the newly created directory reveals the following project structure:
Opening src/lib.rs reveals the default template generated by Cargo:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
This file contains a nested module named tests containing a single test function, it_works, which asserts that .
3. Anatomy of a Rust Test
A test in Rust is simply a function annotated with the #[test] attribute. Attributes are metadata about Rust code; the #[test] attribute tells the compiler to treat the annotated function as a test runner entry point.
#[cfg(test)]
mod tests {
// This is a test function because of the #[test] attribute
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
// This is a helper function, NOT a test, because it lacks the attribute
fn helper_util() {
// Setup code here...
}
}
Key Components of the Test Structure:
#[cfg(test)]: This attribute tells Rust to compile and run the test code only when executingcargo test, preventing test code from being included in the final production build.mod tests: A nested module used to group your test functions.#[test]: An attribute placed directly above a function to mark it as an executable test.- Helper Functions: You can write standard helper functions inside the test module to assist with setup or common assertions. As long as they lack the
#[test]attribute, the test runner will not attempt to execute them as independent tests.
4. How Rust Runs Tests
To execute your test suite, run the cargo test command in your terminal:
cargo test
Understanding the Test Output
When you run cargo test, Rust compiles your library and runs all functions marked with the #[test] attribute. The terminal output is structured into distinct sections:
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The output is split into two primary suites:
- Unit/Integration Tests: The tests defined in your source code (e.g.,
tests::it_works). - Doc-tests: Rust compiles and runs code examples written inside your documentation comments. This ensures your documentation examples never go out of date.
How Rust Determines Test Failure
In Rust, a test fails if the thread executing it panics. Each test is executed in its own isolated thread. If the main test runner thread detects that a test thread has died due to a panic, that specific test is marked as failed.
To demonstrate a failing test, we can write a function that explicitly calls the panic! macro:
#[cfg(test)]
mod tests {
#[test]
fn failing_test() {
panic!("Make this test fail");
}
}
Running cargo test with this failing test produces detailed diagnostic information:
The failure output tells you:
- Which test failed (
tests::failing_test). - The exact line number where the failure occurred (
src/lib.rs:6:9). - The panic message provided (
Make this test fail).
5. Testing Business Logic: The assert! Macro
The assert! macro is the simplest assertion tool in Rust. It evaluates a boolean expression. If the expression evaluates to true, the test passes. If it evaluates to false, the macro calls panic!, causing the test to fail.
To see how this works with real code, let's test a Rectangle struct and its can_hold method:
#[derive(Debug)]
pub struct Rectangle {
pub width: u32,
pub height: u32,
}
impl Rectangle {
pub fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
Bringing Code into Scope
Because our tests reside in a nested module (tests), we must bring the code from the outer (parent) module into the scope of the test module. We do this using a relative path with the super keyword:
#[cfg(test)]
mod tests {
use super::*; // Imports everything from the parent module
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle { width: 8, height: 7 };
let smaller = Rectangle { width: 5, height: 1 };
// assert! expects a boolean value
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle { width: 8, height: 7 };
let smaller = Rectangle { width: 5, height: 1 };
// We negate the result because smaller.can_hold(&larger) should return false
assert!(!smaller.can_hold(&larger));
}
}
Catching a Bug
If we introduce a bug into our business logic by changing the > operator to < in the can_hold method:
impl Rectangle {
pub fn can_hold(&self, other: &Rectangle) -> bool {
// Bug introduced: using < instead of >
self.width < other.width && self.height > other.height
}
}
Running cargo test immediately flags the failure:
6. Comparing Values: assert_eq! and assert_ne!
While you can use assert!(left == right) to compare values, Rust provides dedicated macros for equality assertions: assert_eq! and assert_ne!. These macros provide much better error messages because they print both the left and right values when an assertion fails.
Consider a simple function that adds two to an integer:
pub fn add_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
assert_eq!(4, add_two(2));
}
}
Left vs. Right Arguments
In some programming languages and testing frameworks, the parameters to an equality assertion are strictly designated as expected and actual.
Note: In Rust, there is no semantic distinction between the left and right arguments of
assert_eq!orassert_ne!. You can place the expected value on the left and the actual value on the right, or vice versa.
// Both are perfectly valid and equivalent in Rust:
assert_eq!(4, add_two(2)); // expected, actual
assert_eq!(add_two(2), 4); // actual, expected
Analyzing a Failure
If we introduce a bug in add_two so that it adds 3 instead of 2:
pub fn add_two(a: i32) -> i32 {
a + 3
}
Running cargo test shows exactly what went wrong:
Trait Requirements for assert_eq! and assert_ne!
Under the hood, the assert_eq! and assert_ne! macros use the == and != operators, and format the values using debug formatting ({:?}).
Therefore, any values passed to these macros must implement the following two traits:
PartialEq: Required to perform the equality comparison.Debug: Required to print the values to the console if the assertion fails.
Because all primitive types and most standard library types implement these traits, you can use them immediately. However, for custom structs or enums that you define, you must derive these traits:
#[derive(PartialEq, Debug)] // Required for assert_eq! / assert_ne!
struct CustomStruct {
value: i32,
}
7. Custom Failure Messages for Richer Debugging
To make failing tests easier to debug, you can pass custom failure messages as optional arguments to the assert!, assert_eq!, and assert_ne! macros. Any arguments specified after the required parameters are passed directly to Rust's formatting engine (the same engine used by format! and println!).
Consider a greeting function that is supposed to include a person's name in its output:
pub fn greeting(name: &str) -> String {
format!("Hello!") // Bug: missing the name
}
A standard test using assert! only tells us that the assertion failed:
#[test]
fn greeting_contains_name_naive() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
This produces a generic, unhelpful failure message: assertion failed: result.contains("Carol").
We can write a much more informative test by adding a custom failure message:
#[test]
fn greeting_contains_name_custom() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, the returned value was: '{}'",
result
);
}
When this test fails, it prints our custom formatted message, making it immediately obvious what the function actually returned:
8. Verifying Failures: The #[should_panic] Attribute
Sometimes, correct behavior means verifying that a function fails under invalid conditions. For example, a constructor should panic if it receives an out-of-bounds input.
We can test this behavior using the #[should_panic] attribute. This attribute tells the test runner that the test passes if the code inside the function panics, and fails if the code runs to completion without panicking.
Let's look at a Guess struct that enforces a value range between 1 and 100:
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess { value }
}
}
We can write a test to verify that passing a value greater than 100 causes a panic:
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic] // This test passes if the code below panics
fn greater_than_100() {
Guess::new(200);
}
}
If we run cargo test, the test passes because Guess::new(200) successfully panics.
When a #[should_panic] Test Fails
If we break our validation logic so that it fails to panic on out-of-bounds values:
impl Guess {
pub fn new(value: i32) -> Guess {
// Bug: removed the out-of-bounds check
Guess { value }
}
}
Running cargo test results in a failure, noting that the test did not panic as expected:
9. Precise Panic Testing with expected Messages
Using #[should_panic] on its own can be risky. A test will pass if the code panics for any reason, even if it's a completely different panic than the one we expected (such as an out-of-bounds index error or an unrelated assertion failure).
To make panic tests more precise, we can add an expected parameter to the #[should_panic] attribute. The test runner will then verify that the panic message contains the specified substring.
Let's update our Guess struct to panic with different messages depending on whether the value is too low or too high:
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {}.",
value
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {}.",
value
);
}
Guess { value }
}
}
We can now target the specific panic message in our test:
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
How the Match Works
The expected parameter does not need to match the entire panic message exactly; it only needs to be a substring of the panic message.
If we pass an input that triggers the wrong panic (for example, if we pass -2 but expect the "less than or equal to 100" panic), the test will fail because the panic message does not match:
10. Writing Tests that Return a Result Type
So far, all the tests we've written are functions that return nothing (the unit type ()) and signal failure by panicking.
Alternatively, you can write tests that return a Result<T, E> type. This allows you to use the question mark (?) operator inside your tests, which is highly convenient when testing operations that can return errors.
#[cfg(test)]
mod tests {
#[test]
fn it_works() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
Why Use Result in Tests?
Using Result in tests makes it easy to propagate errors up to the test runner without having to write explicit unwrap() or expect() calls:
#[test]
fn test_with_question_mark() -> Result<(), ParseIntError> {
// If parse() returns an Err, the test fails immediately
let number: i32 = "42".parse()?;
assert_eq!(number, 42);
Ok(())
}
Warning: You cannot use the
#[should_panic]attribute on a test function that returns aResult<T, E>. If you need to assert that an operation returns anErrvariant, do not use#[should_panic]. Instead, assert on the result directly usingassert!(result.is_err()).
Summary of Assertion Tools
Summary and Key Takeaways
- Compiler Limits: Rust's compiler guarantees type and memory safety, but automated tests are required to verify business logic and program correctness.
- Cargo Integration: Cargo has built-in testing support. Running
cargo testautomatically compiles and runs unit tests, integration tests, and documentation tests. - Test Isolation: Each test runs in its own thread. If a test thread panics, the test runner marks that test as failed.
- Scope Resolution: Tests are typically written in a nested module named
tests. You must useuse super::*;to bring your parent module's code into the test scope. - Equality Assertions: Use
assert_eq!andassert_ne!for equality checks. Both macros require the compared types to implementPartialEqandDebug. - Custom Failure Messages: You can pass custom format strings and arguments to assertion macros to print helpful debugging information when a test fails.
- Panic Verification: Use
#[should_panic]to verify that a function panics under invalid conditions. Use theexpectedparameter (#[should_panic(expected = "message")]) to ensure the function panics for the correct reason. - Result Return Types: Writing tests that return a
Result<(), E>allows you to use the?operator to cleanly propagate errors, making it easier to test fallible operations.