Skip to content

← Modules, Visibility, Testing and Docs step 20 of 22

Hard Framework

Build your own #[test]: catching panics deterministically

#[test] cannot be graded on this site. cfg(test) is set only by rustc --test, and submissions are compiled with plain rustc -O, so a #[cfg(test)] mod tests { … } block is deleted before type-checking: it compiles clean, never runs, and clippy never even looks inside it. You could put assert_eq!(1, 2) in there and pass.

So instead of writing #[test] twenty times, you are going to rebuild it. That turns out to be the better lesson anyway: by the end you will understand unwinding, panic payloads and the recoverable/unrecoverable split far more concretely than any amount of annotating would have taught you.

pub struct Case {
    pub name: String,
    pub op: String,
    pub args: Vec<i64>,
    pub should_panic: Option<String>,
    pub ignore: bool,
}

pub struct Outcome {
    pub name: String,
    pub status: String,
}

pub fn run_suite(cases: Vec<Case>) -> Vec<Outcome>

Output order is input order. Nothing here is timing-dependent, and that is deliberate.

The statuses

status when
ignored ignore is set. The body is not executed.
ok ran, did not panic, and no panic was expected
failed panicked when none was expected, or did not panic when one was
panicked_as_expected panicked, and the payload contains the expected substring
wrong_panic_message panicked, but the payload does not contain it

should_panic: Some(expected) models #[should_panic(expected = "…")], and the comparison really is substring containment, not equality — that is what the real attribute does.

Some("") models bare #[should_panic] with no expected, and notice what falls out: the empty string is contained in everything, so any panic passes. Including one thrown by a completely unrelated line earlier in the test. That is a real source of false confidence, and it is why clippy::should_panic_without_expect exists.

The ops

op selects one of six bodies. They are uninteresting except that they fail in different ways:

op behaviour
ok sums args, never panics
div args[0] / args[1]
idx indexes a three-element array by args[0]
boom panic!("boom")
boom_fmt panic!("boom {}", args[0])
halt calls std::process::exit(97)

halt is how “ignored means not executed” gets graded

#[ignore] does not mean “run it and throw the result away”. The body never happens. That is normally impossible to observe from the outside — so one test case marks a halt case as ignored. If your runner executes it, the process disappears mid-suite and every case fails at once. Not subtle, and not catchable by catch_unwind either: exit does not unwind.

The payload downcast, which must try both types

A panic payload is a Box<dyn Any + Send>, and the concrete type depends on how the panic was written:

panic!("boom")            // payload is &'static str
panic!("boom {}", n)      // payload is String   (n is a runtime value)

Handling only one of the two silently misreads half of all panics. div and boom give you &'static str; idx and boom_fmt give you String. The starter handles only String, which is why it gets wrong_panic_message on things that panicked exactly as asked.

Unwinding, and what AssertUnwindSafe actually asserts

catch_unwind requires its closure to be UnwindSafe. That is not a safety property in the unsafe sense — it is a logical one. A panic can leave data half-updated: a &mut you were partway through mutating, an invariant restored on the next line that never ran. UnwindSafe marks the types where observing that half-state after catching cannot cause a logic bug.

Most useful closures are rejected, and AssertUnwindSafe(f) is the normal answer. What you are asserting is “I have thought about the state this closure touches, and looking at it after a panic is fine.” Do not treat it as boilerplate — that is the one habit that turns a compiler question into a real bug later.

Two more things worth carrying:

  • Suppress the hook, then put it back. catch_unwind catches the unwind, but the default hook still prints a backtrace on the way past. take_hook() returns the old one; set_hook(Box::new(|_| {})) installs silence; set_hook(previous) restores it. The hook is process-global, so restoring it is not politeness.
  • Do not build error handling out of this. catch_unwind is a test-harness and FFI-boundary tool. Recoverable failure is Result; panics are for bugs. Also, dropping a panic payload can itself panic, and a panic during a panic aborts the process with no unwinding at all.