Skip to content
rusthacks.dev
Testing

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.

3 min read
Summarize in AI
Flat illustration for the post “Testing Panics in Rust With Should Panic and Catch Unwind”, composed around errors.

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

rust
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:

Text
note: panic did not contain expected string
      panic message: "attempt to divide by zero"
 expected substring: "division by zero"

The hole

rust
#[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

rust
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:

rust
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.

Also inRust
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.