Testing the explosion
You're building a configuration parser for a server. The parser refuses to work if the host field is missing. It panics with a clear message so the developer knows exactly what went wrong. You want to write a test that proves this panic happens. If a future refactor changes the message to something vague like "Error", or worse, removes the panic entirely and returns a broken config, your test should fail.
You don't want to manually catch the panic and inspect the string. You want the test harness to verify the crash for you. That's what #[should_panic] does. It flips the test logic: the test passes if the code panics, and fails if the code runs to completion.
Inverting the test logic
Normal tests verify that code produces the right output. You call a function, check the return value, and assert it matches expectations. #[should_panic] inverts this. You call a function that you know is invalid, and you expect the function to abort execution.
Think of it like a stress test for a pressure valve. You pump pressure into the system until the valve blows. If the valve holds, the test failed. You want it to break. The attribute tells the test runner: "This test passes if the code explodes. If it runs cleanly, something is wrong."
The attribute can also check the panic message. Without the check, the test passes if any panic occurs. That's dangerous. If you introduce a bug that causes a panic in a different branch, the test might pass for the wrong reason. Adding expected = "..." requires the panic message to contain a specific substring. This ensures the test verifies the right panic, not just any crash.
Minimal example
#[test]
#[should_panic(expected = "missing field: host")]
fn test_missing_host_panics() {
// The parser panics when the host is absent.
// #[should_panic] marks this test as expecting a panic.
// expected = "..." requires the panic message to contain this substring.
// If the code panics with a different message, the test fails.
Config::parse("port=8080");
}
The expected argument is a substring match, not an exact match. If the panic message is "Error: missing field: host", the test passes. If the message is "missing field: host", it also passes. This flexibility helps when the panic message includes extra context, like a stack trace or a prefix.
What happens under the hood
When the test runner executes a function marked with #[should_panic], it wraps the function call in a panic-catching mechanism. Rust provides std::panic::catch_unwind for this purpose. The runner calls your test function inside a closure. If the function panics, the panic unwinds the stack, and catch_unwind intercepts it.
The runner extracts the panic payload, converts it to a string, and checks the substring. If the code panics and the message matches, the runner marks the test as passed. If the code panics but the message doesn't match, the runner marks the test as failed and reports the mismatch. If the code runs to completion without panicking, the runner marks the test as failed because the expected panic never occurred.
The test function itself doesn't return. The panic aborts the function early. The harness handles the cleanup and reports the result. This means you don't need to write any boilerplate to catch the panic. The attribute handles everything.
Realistic example
In real code, you often panic when a public API receives invalid input that violates an invariant. The panic signals a logic error in the caller. Testing these panics ensures the invariant holds and the error message is helpful.
pub struct Config {
pub host: String,
pub port: u16,
}
impl Config {
/// Parses a configuration string.
/// Panics if required fields are missing.
pub fn parse(input: &str) -> Self {
let mut host = None;
let mut port = None;
for part in input.split(',') {
let (key, value) = part.split_once('=').unwrap_or_else(|| {
panic!("invalid format: expected key=value, got {}", part);
});
match key {
"host" => host = Some(value.to_string()),
"port" => port = Some(value.parse().unwrap_or_else(|_| {
panic!("invalid port: {}", value);
})),
_ => {}
}
}
if host.is_none() {
panic!("missing field: host");
}
if port.is_none() {
panic!("missing field: port");
}
Self {
host: host.unwrap(),
port: port.unwrap(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "missing field: host")]
fn test_parse_requires_host() {
// Real-world parsing often panics on invalid input.
// The test guarantees the error message is helpful.
Config::parse("port=8080");
}
#[test]
#[should_panic(expected = "invalid format")]
fn test_parse_rejects_bad_format() {
// Testing a different panic path ensures all validation branches are covered.
Config::parse("host=localhost");
}
}
The tests verify both the missing field panic and the format panic. Each test targets a specific error condition. The expected substring ensures the test fails if the message changes. This is crucial for user-facing errors. If the message becomes less helpful, the test catches it.
Always add the expected substring. A test that passes on any panic is a test that lies.
The trap of standard library panics
Rust's standard library panic messages are not stable. The format can change between minor versions. For example, indexing out of bounds used to say "index out of bounds" and now says "index out of range". If you write a test like #[should_panic(expected = "index out of bounds")] for a vector access, your test breaks when you update Rust.
Only use expected for panics in your own code. You control the message, so you can keep it stable. For standard library panics, use #[should_panic] without expected, or better, avoid testing the panic message entirely. Test the behavior instead. If you need to verify that a vector access fails, test that the code panics, but don't rely on the exact wording.
The community considers testing standard library panic messages brittle. Use expected for your invariants, not library internals. If you must test a standard library panic, use #[should_panic] alone and accept that the test might need updates when Rust changes.
Pitfalls and compiler errors
The expected argument must be a string literal. You cannot pass a variable or a constant. If you try #[should_panic(expected = msg)] where msg is a variable, the compiler rejects it. The attribute syntax requires a literal string.
If you try to use a variable, the compiler rejects this with an error about expecting a string literal. You must hardcode the substring in the attribute. This restriction exists because attributes are processed at compile time, and the test harness needs the string at build time.
Another pitfall is using #[should_panic] to test functions that return Result. If a function returns Result, it doesn't panic on error. It returns Err. Using #[should_panic] on such a function is wrong. The test will fail because no panic occurs. Use assert! macros to check the Err variant instead.
Don't use #[should_panic] to test Result errors. Use assert! to check the Err variant. Panics and Result are different error models.
Decision matrix
Use #[should_panic] when you need to verify that a function panics on invalid input and the function signature does not return a Result. Use #[should_panic(expected = "...")] when you must ensure the panic message contains specific diagnostic information, not just that a panic occurred. Reach for Result instead of panics when the caller can handle the error gracefully; panics should be reserved for unrecoverable logic errors or violated invariants. Pick assert! macros when testing normal execution paths where you verify values rather than crashes. Avoid #[should_panic(expected = "...")] for standard library panics because message formats can change between Rust versions and break your tests.