Unit Testing

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:

  1. Verify multiple panicking calls sequentially in a loop.
  2. Inspect the state of a data structure after a panic to verify exception safety (ensuring no corrupted invariants were left behind).
  3. Inspect the panic payload directly via downcast_ref::<&str>() or downcast_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.