Testing Panics
Testing panics verifies that an API enforces its preconditions—such as out-of-bounds indexing, division by zero, or invalid builder configuration—by panicking when a caller violates its documented contract. Rust provides two ways to test panicking code: declarative test attributes via #[should_panic] and programmatic panic interception via std::panic::catch_unwind.
Ways to Test Panicking Code:
1. Attribute on #[test] function:
#[test]
#[should_panic(expected = "divisor must be non-zero")]
fn test_div_zero() { ... }
--> Entire test function unwinds; harness marks test "ok".
2. Scoped interception via std::panic::catch_unwind:
let res = std::panic::catch_unwind(|| risky_call());
assert!(res.is_err());
--> Catches unwind mid-function; execution continues!
Verifying Invariants After a Panic with catch_unwind
While #[should_panic] terminates the entire test function as soon as the first panic occurs, std::panic::catch_unwind allows a single test to:
- Verify multiple panicking calls sequentially in a loop.
- Inspect the state of a data structure after a panic to verify exception safety (ensuring no corrupted invariants were left behind).
- Inspect the panic payload directly via
downcast_ref::<&str>()ordowncast_ref::<String>().
use std::panic::{self, AssertUnwindSafe};
pub struct BoundedStack<T> {
items: Vec<T>,
capacity: usize,
}
impl<T> BoundedStack<T> {
pub fn new(capacity: usize) -> Self {
assert!(capacity > 0, "BoundedStack capacity must be greater than 0");
Self {
items: Vec::with_capacity(capacity),
capacity,
}
}
pub fn push(&mut self, item: T) {
if self.items.len() == self.capacity {
panic!(
"BoundedStack overflow: capacity of {} reached",
self.capacity
);
}
self.items.push(item);
}
pub fn len(&self) -> usize {
self.items.len()
}
}
fn extract_panic_message(err: &Box<dyn std::any::Any + Send>) -> Option<&str> {
if let Some(s) = err.downcast_ref::<&'static str>() {
Some(s)
} else if let Some(s) = err.downcast_ref::<String>() {
Some(s.as_str())
} else {
None
}
}
fn main() {
// 1. Test constructor precondition panic
let zero_cap_err = panic::catch_unwind(|| BoundedStack::<i32>::new(0)).unwrap_err();
let msg = extract_panic_message(&zero_cap_err).unwrap();
assert!(msg.contains("capacity must be greater than 0"));
println!("Caught constructor panic: {msg}");
// 2. Test overflow panic AND verify stack state remains intact afterward
let mut stack = BoundedStack::new(2);
stack.push(10);
stack.push(20);
let overflow_res = panic::catch_unwind(AssertUnwindSafe(|| {
stack.push(30);
}));
assert!(overflow_res.is_err());
// Verify exception safety: existing elements are still intact!
assert_eq!(stack.len(), 2);
println!("Stack length preserved after caught overflow panic: {}", stack.len());
}
Caught constructor panic: BoundedStack capacity must be greater than 0
Stack length preserved after caught overflow panic: 2
Unwind vs Abort Requirement
Both #[should_panic] and std::panic::catch_unwind rely on stack unwinding (panic = "unwind", the default in dev, test, and release profiles). If a project configures panic = "abort" in Cargo.toml for the test profile, panics terminate the test process immediately and #[should_panic] tests cannot be run in-process.