Testing Panics in Rust With Should Panic and Catch Unwind
Assert that code panics with should_panic and a message, or with catch_unwind for finer control. Know the traps: unrelated panics passing, Result tests and panic=abort.

A test that expects a panic is a test with a hole in it: it passes for the wrong panic. #[should_panic] without a message is true of any failure in the body, including a typo in the setup. This post shows the attribute with a message, catch_unwind for multi-case checks and when to avoid testing panics at all. It belongs to the testing in Rust guide.
Rust 1.90, edition 2024.
The attribute
pub fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("division by zero");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "division by zero")]
fn rejects_zero_divisor() {
divide(1, 0);
}
}The test passes only if the panic message contains division by zero. If someone changes the check to b < 0, divide(1, 0) panics with the built-in attempt to divide by zero message, which does not contain the expected text, and the test fails: that is the point of expected. The failure output reads:
note: panic did not contain expected string
panic message: "attempt to divide by zero"
expected substring: "division by zero"The hole
#[test]
#[should_panic]
fn loose() {
let v: Vec<i32> = Vec::new();
v[0]; // oops, a different bug
divide(1, 0);
}This passes because indexing panicked first, and divide never ran. A bare #[should_panic] is almost always too loose.
Several cases with catch_unwind
use std::panic::{catch_unwind, AssertUnwindSafe};
#[test]
fn panics_for_each_bad_input() {
for (a, b) in [(1, 0), (0, 0), (-5, 0)] {
let result = catch_unwind(AssertUnwindSafe(|| divide(a, b)));
assert!(result.is_err(), "divide({a}, {b}) should have panicked");
}
}Each case runs, the panic is caught, and the assert reports which input did not panic. The default panic hook still prints a message for every caught panic, which clutters the output; install std::panic::set_hook(Box::new(|_| {})) in the test if it bothers you, and restore it after, bearing in mind that hooks are process-global and tests run in parallel.
Prefer Result
For errors callers can reasonably expect, return Result and assert on it:
pub fn checked_divide(a: i32, b: i32) -> Result<i32, &'static str> {
a.checked_div(b).ok_or("division by zero")
}
#[test]
fn reports_zero_divisor() {
assert_eq!(checked_divide(1, 0), Err("division by zero"));
}A Result is testable without hooks and without should_panic. Reserve panics for violated invariants, and document them in a # Panics section. More in error handling in Rust.
Limits
With panic = "abort" in the test profile (not the default) panics end the process, so should_panic cannot work. Panics across FFI boundaries are not testable this way. A panic inside a spawned thread is returned by join(), which you can assert on. The attribute is documented in the Rust book section on should_panic.


