The silent failure trap
You write a function that parses a configuration file. It returns Result<Config, Error>. You write a test to verify the parsing logic. You call the function, assign the result to a variable, and write an assertion on the config value. You forget to handle the Result. The test passes. You deploy. The production code crashes because the config was invalid, but your test suite gave you a green checkmark.
Or consider the opposite. You use unwrap() everywhere. A test fails. The output shows a stack trace pointing to line 42 of std::result, with the message "called Result::unwrap() on an Err value". You have twelve unwrap() calls in that test. You spend ten minutes hunting down which one failed.
Tests need to fail loudly and tell you exactly what broke. Rust gives you precise tools to turn errors into test failures, but you have to choose the right tool for the context. The goal is fast failure with maximum signal.
Turning results into failures
In Rust, functions that can fail return Result<T, E> or Option<T>. These types wrap the success value or the error. In production code, you handle these gracefully. You return errors, you log them, you provide fallbacks.
In tests, you usually want the opposite. You want the test to crash immediately if something goes wrong. A crashing test is a failed test. The test runner catches the crash and reports failure. unwrap() and expect() are the tools that turn a wrapped result into a hard crash on error. They are the smoke alarms of your test suite. If the result is an error, the alarm screams, and the test fails.
This approach enforces a strict invariant: the test setup must succeed. If the setup fails, the test cannot proceed, and the failure is visible. This prevents the silent failure trap where a test passes because it ignored an error.
Minimal examples: unwrap versus expect
The most common way to handle a Result in a test is to extract the value and assert on it. unwrap() does this. If the Result is Ok, you get the value. If it is Err, the test panics.
expect() works identically but accepts a message. That message appears in the panic output. This is crucial for debugging. The message acts as a comment that the compiler enforces. If the test fails, the message tells you what went wrong without forcing you to grep the source code.
/// Parses a config string into a Config struct.
fn parse_config(input: &str) -> Result<Config, &'static str> {
if input.is_empty() {
Err("Empty config")
} else {
Ok(Config { port: 8080 })
}
}
struct Config { port: u16 }
#[test]
fn test_parse_success() {
// unwrap() extracts the value if Ok.
// If the Result is Err, the test panics immediately.
// This is the standard way to assert success in simple tests.
let config = parse_config("valid.conf").unwrap();
assert_eq!(config.port, 8080);
}
#[test]
fn test_parse_with_context() {
// expect() works like unwrap() but includes a custom message.
// Use this when the failure reason isn't obvious from the function name.
// The message helps identify the failure point in complex tests.
let config = parse_config("valid.conf")
.expect("Parsing 'valid.conf' should always succeed");
assert_eq!(config.port, 8080);
}
Community convention favors expect() over unwrap() in most test code. The message documents the invariant. It tells future readers why this call should succeed. If the test fails, the message saves debugging time. Treat expect() as a contract. The message is the reason the contract holds.
What happens at runtime
When the test runs, parse_config executes. If it returns Ok(Config), unwrap() sees the Ok variant and returns the Config. The assertion runs. If the assertion passes, the test passes.
If parse_config returns Err, unwrap() calls panic!. The panic bubbles up the call stack. The test harness catches the panic. The test is marked as failed. The output shows the panic message. With expect(), the message you provided is included in the panic output.
This flow ensures fast failure. The test stops at the first error. You don't get cascading failures from subsequent assertions that depend on the failed setup. This keeps test output clean and actionable.
Tests that return Result
Rust tests can return a Result. This is a powerful feature that many beginners miss. If a test function returns Result<(), E>, the test passes if the function returns Ok(()). The test fails if the function returns Err.
This allows you to use the ? operator inside tests. The ? operator propagates errors. If a call fails, the ? returns the error from the test function. The test runner sees the error and fails the test. This keeps the error flow clean. You avoid nested unwrap() calls. You avoid deep indentation from match statements.
use std::error::Error;
#[test]
fn test_with_result_return() -> Result<(), Box<dyn Error>> {
// The ? operator propagates errors.
// If parse_config returns Err, the test fails immediately.
// The error message is included in the test output.
let config = parse_config("valid.conf")?;
// Additional setup can also use ?.
let data = load_data(&config)?;
assert_eq!(data.items.len(), 5);
Ok(())
}
This pattern shines in tests with multiple fallible steps. Each step can fail cleanly. The test output shows the error from the first failure. You don't need expect() messages for every call. The error type provides the context. This reduces boilerplate and keeps the test logic focused on assertions.
Convention aside: returning Result from tests is idiomatic in larger codebases. It scales better than unwrap() chains. Use it when the test has more than two or three fallible operations.
Asserting on the Result directly
Sometimes you don't want to unwrap. You want to verify the Result itself. assert_eq! compares values. You can compare a Result against an expected Ok or Err.
This approach has a distinct advantage. If the assertion fails, you get a diff. The diff shows the actual Result versus the expected Result. This is helpful when the error type is complex. You can see exactly which variant failed or which field differed.
#[test]
fn test_assert_result() {
let result = parse_config("valid.conf");
// assert_eq! compares the whole Result.
// If it fails, you get a diff showing Ok vs Err or value mismatch.
// This is useful for inspecting the exact failure mode.
assert_eq!(result, Ok(Config { port: 8080 }));
}
This pattern is also useful for testing error cases. You can assert that a function returns a specific error. You don't need unwrap_err(). You just compare against the expected error.
#[test]
fn test_parse_empty_fails() {
let result = parse_config("");
// Asserting on the error directly verifies the failure mode.
// This is clearer than checking is_err() and then unwrapping.
assert_eq!(result, Err("Empty config"));
}
Use this when you need to inspect the error details or when the error type derives PartialEq. It keeps the test declarative. You state what you expect, and the assertion framework checks it.
Testing panics
Not all failures are Result errors. Some code panics on invalid input. Index out of bounds, arithmetic overflow, or explicit panic! calls. You need to verify that panics occur when expected.
The #[should_panic] attribute marks a test as expected to panic. The test passes if the code panics. The test fails if the code completes without panicking.
You can add an expected string to #[should_panic]. The test passes only if the panic message contains that string. This prevents false positives. A different panic in the code won't accidentally pass the test.
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_vector_panic() {
let v = vec![1, 2, 3];
// This access triggers a panic.
// The test passes only if the panic message contains the expected string.
// Without the expected string, any panic would pass the test.
let _x = v[10];
}
Always use the expected string. It makes the test precise. It documents the expected panic message. It prevents the test from passing due to unrelated panics.
Pitfalls and compiler errors
The silent killer is ignoring the result. If you write let result = parse_config("bad"); and do nothing with result, the test passes. The compiler might warn about unused variables, but it won't fail the test. Always assert or unwrap. If you intentionally ignore a result, use let _ = result; to signal the decision. But in tests, ignoring results is almost always a bug.
Another pitfall is unwrap() noise. If you have many unwrap() calls, a failure gives you little context. The stack trace points to the panic, but you have to search the code to find the call. expect() solves this. Use expect() when the call isn't self-documenting.
Compiler errors can help you catch mistakes. If you forget to unwrap and pass a Result to a function expecting Config, you get E0308 (mismatched types). The compiler tells you Result<Config, Error> is not Config. This is good. It forces you to handle the error. Don't suppress this error with type coercion or ignored variables. Let the compiler enforce correctness.
Be careful with #[should_panic] without an expected string. It passes on any panic. A typo in the test code could cause a panic, and the test would pass. This hides bugs. Always specify the expected message.
Decision matrix
Choose the error handling strategy based on the test complexity and the need for context.
Use unwrap() when the error case is impossible in the test setup and the function name makes the failure obvious.
Use expect() when the test setup is complex or the function is generic, so a custom message helps identify the failure point.
Use assert_eq!(result, Ok(...)) when you need to verify the exact result value and benefit from the diff output on failure.
Use -> Result in the test signature when the test has multiple fallible steps and you want to use the ? operator for clean error propagation.
Use #[should_panic(expected = "...")] when you are testing code that panics on invalid input, like index out of bounds or arithmetic overflow.
Use unwrap_err() when you expect an error and want to extract it for detailed assertions on the error fields.
Use match on the result when the error type has multiple variants and you need to verify specific variant details.
Don't fight the compiler here. Reach for expect() when in doubt. The message pays for itself in debugging time.