When the happy path isn't enough
You just finished a function that parses a configuration file. The happy path works. You feed it a valid JSON blob, and it returns the settings struct. You feel good. Then you run the app in production, and it hits a malformed file. The program doesn't return a nice error message. It panics. Or worse, it returns garbage data because you assumed the input was always valid.
Testing the happy path proves your code works when everything goes right. Testing error cases proves your code survives when things go wrong. In Rust, error handling is explicit. Functions return Result, Option, or they panic. Your tests need to verify exactly which behavior happens and what the error looks like.
Panics, Results, and Options
Rust treats errors in three distinct ways. Your tests must match the contract.
A panic means the program cannot continue. This happens when an invariant is violated, like accessing an index out of bounds. The thread stops, and the program crashes. You test panics by asserting that the function explodes.
A Result<T, E> means the operation might fail, but the caller can handle the failure. The function returns Ok(value) on success or Err(error) on failure. You test this by checking the variant and inspecting the error value.
An Option<T> means the value might be absent. This is common for lookups. The function returns Some(value) or None. You test this by checking for None when the input is invalid.
Confusing these three leads to brittle tests. If a function returns Result, testing for a panic is wrong. If a function panics on invalid input, testing for Err is impossible. Read the return type first.
Testing panics with #[should_panic]
When a function is documented to panic on invalid input, use the #[should_panic] attribute. This tells the test harness to expect a thread explosion. If the function returns normally, the test fails. If it panics, the test passes.
/// Returns the element at the given index or panics.
fn get_element(items: &[i32], index: usize) -> i32 {
// This panics if index is out of bounds.
items[index]
}
#[cfg(test)]
mod tests {
use super::*;
/// Verifies that out-of-bounds access triggers a panic.
#[test]
#[should_panic]
fn test_get_element_out_of_bounds() {
let items = vec![10, 20, 30];
// Accessing index 5 panics because the slice has length 3.
// The test harness catches the panic and marks this test as passed.
let _value = get_element(&items, 5);
}
}
The test runner wraps your test function in a guard. If the guard detects a panic, it records the test as successful. If the guard reaches the end of the function without a panic, it records the test as failed.
The expected substring
A blind #[should_panic] is dangerous. It passes if the function panics for any reason. You might fix the bug you were testing, but introduce a null pointer dereference elsewhere. The test still passes, even though the behavior changed.
Always use expected. This adds a substring check to the panic message. The test passes only if the panic message contains the specified string.
/// Verifies the panic message matches the expected text.
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_get_element_message() {
let items = vec![10, 20, 30];
// The panic message contains "index out of bounds".
// If the panic message changes, this test fails.
let _value = get_element(&items, 5);
}
The expected string is a substring match, not a regex. It checks if the panic message contains the text anywhere. This is robust against minor formatting changes in the standard library.
Convention aside: The community treats #[should_panic] without expected as a code smell. If you can't predict the panic message, you probably don't understand the failure mode well enough. Add the expected string to lock in the behavior.
Don't trust a blind panic test. If you didn't check the message, you didn't test the behavior.
Testing Result error paths
Most Rust functions return Result<T, E>. You test these by calling the function, asserting the result is Err, and inspecting the error value.
/// Divides two numbers and returns an error if the divisor is zero.
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
// Return the error variant with a descriptive message.
Err("Cannot divide by zero".to_string())
} else {
Ok(a / b)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Checks that division by zero returns the correct error.
#[test]
fn test_divide_by_zero() {
// Call the function with invalid input.
let result = divide(10.0, 0.0);
// Assert the result is the Err variant.
assert!(result.is_err(), "Expected an error for zero divisor");
// Extract the error value and check the message.
// expect_err fails fast if the result is Ok instead of Err.
let error = result.expect_err("Expected Err variant");
assert_eq!(error, "Cannot divide by zero");
}
}
The expect_err method is safer than unwrap_err. If the function returns Ok when you expect Err, unwrap_err panics with a generic message. expect_err panics with the message you provide, making it clear that the test assumption was wrong.
Convention aside: Use expect_err in tests, not unwrap_err. It signals intent. A reader sees expect_err and knows you are testing the error path. If the code changes and returns Ok, the test fails with a helpful message instead of a confusing unwrap panic.
Testing custom error enums
Real projects use custom error types. Define an enum for your errors and derive PartialEq so you can compare them in assertions.
/// Custom error type for the parser.
#[derive(Debug, PartialEq)]
enum ParseError {
/// The input was empty.
EmptyInput,
/// The format was invalid.
InvalidFormat(String),
}
/// Parses a string and returns a value or a ParseError.
fn parse_input(input: &str) -> Result<i32, ParseError> {
if input.is_empty() {
Err(ParseError::EmptyInput)
} else if input == "bad" {
Err(ParseError::InvalidFormat("bad token".to_string()))
} else {
Ok(42)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Verifies that empty input returns the EmptyInput error.
#[test]
fn test_parse_empty() {
let result = parse_input("");
// Compare the error variant directly using PartialEq.
assert_eq!(result.unwrap_err(), ParseError::EmptyInput);
}
/// Verifies that invalid format returns the correct error with data.
#[test]
fn test_parse_invalid() {
let result = parse_input("bad");
// Check the variant and the inner string.
assert_eq!(
result.unwrap_err(),
ParseError::InvalidFormat("bad token".to_string())
);
}
}
Deriving PartialEq on your error enum is standard practice. It lets you write clean assertions. If your error type holds complex data, you might need to implement PartialEq manually or use a helper crate, but for most cases, the derive macro works.
Testing Option absence
Functions that return Option<T> use None to indicate failure or absence. Test these by asserting is_none().
/// Finds a user by ID. Returns None if not found.
fn find_user(id: u32) -> Option<String> {
if id == 0 {
None
} else {
Some(format!("User{}", id))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Checks that ID 0 returns None.
#[test]
fn test_find_user_missing() {
let result = find_user(0);
// Assert the option is None.
assert!(result.is_none(), "Expected None for ID 0");
}
}
If you need to check the value inside Some, use expect or unwrap. expect is preferred because it provides a context message if the assumption fails.
Convention aside: Name your tests to describe the scenario. test_find_user_missing is better than test_find_user_0. The name should tell you what condition triggers the error, not just the input value.
Pitfalls and compiler errors
Testing error cases introduces specific traps. Watch for these.
Comparing Result to a value
If you try to assert on a Result without unwrapping, the compiler rejects you with E0308 (mismatched types). You cannot compare a Result<T, E> to a T or an E. You must extract the inner value first.
#[test]
fn test_bad_comparison() {
let result = divide(10.0, 0.0);
// This fails to compile with E0308.
// assert_eq!(result, "Cannot divide by zero");
// You must unwrap the error first.
assert_eq!(result.unwrap_err(), "Cannot divide by zero");
}
Using unwrap on Err
If you call unwrap on a Result that is Err, the test panics. This looks like a test failure, but the panic message might be confusing. Use expect_err to make the intent clear. If the function returns Ok when you expect Err, expect_err fails with your message.
Testing private implementation details
Avoid testing private helper functions that only exist to structure the code. Test the public interface. If a private function returns an error, test it through the public function. If you need to test the private function directly, use #[cfg(test)] to expose it, but prefer testing the observable behavior.
Convention aside: The community prefers testing the public contract. If you change the internal implementation but the public behavior stays the same, your tests should still pass. Testing private details couples your tests to the code structure, making refactoring painful.
Treat your tests as a contract. If the contract holds, the implementation can change.
When to use each approach
Choose the testing strategy based on the function's return type and error semantics.
Use #[should_panic] when the function is documented to panic on invalid input, such as index out of bounds or debug assertions. Always add expected = "message" to verify the specific panic reason.
Use assert!(result.is_err()) when the function returns Result<T, E> and you only need to verify that an error occurred, without inspecting the error value.
Use result.expect_err("reason") when you need to inspect the error value inside a Result. This fails the test immediately if the function returns Ok instead of Err.
Use assert_matches!(result, Err(_)) when you want to check the variant structure of a complex error enum without unwrapping the value. This is useful when the error contains data you don't care about.
Use assert!(option.is_none()) when the function returns Option<T> and the error case is represented by None.
Use assert_eq!(result.unwrap_err(), expected_error) when you have a custom error type with PartialEq and need to verify the exact error variant and data.
Match your test to the contract. If the function returns an error, test the error. If it panics, test the panic. If it returns None, test the absence.