Testing Panics and Returning Result from Tests
20 min read
Not every test is a simple “given input, assert output.” Sometimes the correct behavior is to fail loudly, and sometimes the test body itself is full of fallible steps. Rust gives you two dedicated tools for these cases: the #[should_panic] attribute and tests that return Result<(), E> so you can use the ? operator.
Quick Overview
Section titled “Quick Overview”In Jest or Vitest you assert that code throws with expect(fn).toThrow(...), and you write fallible test bodies as async functions that await and let rejections bubble up. Rust splits these into two distinct mechanisms: a test marked #[should_panic(expected = "...")] passes only if its body panics with a message containing the expected substring, and a test whose signature is -> Result<(), E> lets you use the ? operator so any Err cleanly fails the test instead of forcing a sea of .unwrap() calls. Knowing which to reach for is the difference between a test that documents a real invariant and one that quietly passes for the wrong reason.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In a Jest/Vitest suite, asserting that something throws and writing a fallible test body look like this:
export function withdraw(balance: number, amount: number): number { if (amount > balance) { throw new Error( `insufficient funds: balance is ${balance}, tried to withdraw ${amount}`, ); } return balance - amount;}
// banking.test.tsimport { describe, it, expect } from "vitest";import { withdraw } from "./banking";
describe("withdraw", () => { // Asserting a throw. The matcher wraps the call in a try/catch for you. it("rejects overdrafts", () => { expect(() => withdraw(100, 150)).toThrow("insufficient funds"); });
it("succeeds within balance", () => { expect(withdraw(100, 30)).toBe(70); });
// A fallible test body: the function is `async`, and any rejected promise // (or thrown error) automatically fails the test. it("loads and parses config", async () => { const raw = await readFile("config.json", "utf8"); // may reject const config = JSON.parse(raw); // may throw expect(config.port).toBe(8080); });});Two things are happening here. expect(() => ...).toThrow(...) passes the function (not its result) so the matcher can catch the throw itself, and it matches the error message as a substring. Separately, the async test body lets a rejected await or a thrown JSON.parse fail the test without any explicit error plumbing — the test runner treats a rejected promise as a failure.
Rust Equivalent
Section titled “Rust Equivalent”Rust expresses the first case with the #[should_panic] attribute and the second with a Result-returning test signature:
use std::num::ParseIntError;
/// Withdraw `amount` from `balance`, panicking if it would overdraw.pub fn withdraw(balance: u32, amount: u32) -> u32 { if amount > balance { panic!("insufficient funds: balance is {balance}, tried to withdraw {amount}"); } balance - amount}
#[derive(Debug, PartialEq)]pub struct Rgb { pub r: u8, pub g: u8, pub b: u8,}
/// Parse a `#rrggbb` hex color string.pub fn parse_hex_color(s: &str) -> Result<Rgb, ParseIntError> { let s = s.trim_start_matches('#'); let r = u8::from_str_radix(&s[0..2], 16)?; let g = u8::from_str_radix(&s[2..4], 16)?; let b = u8::from_str_radix(&s[4..6], 16)?; Ok(Rgb { r, g, b })}
#[cfg(test)]mod tests { use super::*;
// Asserting a panic, matching part of the message. #[test] #[should_panic(expected = "insufficient funds")] fn overdraw_panics() { withdraw(100, 150); }
#[test] fn withdraw_succeeds() { assert_eq!(withdraw(100, 30), 70); }
// A fallible test body: `?` propagates any `Err`, failing the test. #[test] fn parses_white() -> Result<(), ParseIntError> { let color = parse_hex_color("#ffffff")?; assert_eq!(color, Rgb { r: 255, g: 255, b: 255 }); Ok(()) }}Running cargo test produces:
running 3 teststest tests::parses_white ... oktest tests::withdraw_succeeds ... oktest tests::overdraw_panics - should panic ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe - should panic annotation in the runner output tells you the test was expected to panic and did. The ? in parses_white is the direct analog of await in an async Jest test: if parse_hex_color returns an Err, the ? returns it from the test function, and the runner marks the test failed.
Detailed Explanation
Section titled “Detailed Explanation”#[should_panic] inverts the pass/fail condition
Section titled “#[should_panic] inverts the pass/fail condition”A normal #[test] passes when its body runs to completion without panicking. Adding #[should_panic] inverts that contract: the test passes only if the body panics, and fails if it returns normally. The attribute is stacked below #[test]:
#[test]#[should_panic]fn overdraw_panics() { withdraw(100, 150); // must panic, or the test fails}This is the analog of Jest’s expect(fn).toThrow() with no argument — “I don’t care about the message, just that it throws.”
expected matches a substring, not the whole message
Section titled “expected matches a substring, not the whole message”The bare #[should_panic] is blunt: it passes if the body panics for any reason at all, which is dangerous (see Common Pitfalls). Add expected = "..." to require that the panic message contains that substring:
#[test]#[should_panic(expected = "insufficient funds")]fn overdraw_panics() { withdraw(100, 150);}The check is a substring match (via str::contains), not an exact-equality or regex match — exactly like Jest’s toThrow("...") with a string argument. If the actual panic message does not contain the expected substring, the test fails and the runner prints both strings so you can see the mismatch. With our withdraw panicking on a "account frozen" expectation, the failure looks like this:
thread 'tests::wrong_expected_substring' panicked at src/lib.rs:3:9:insufficient funds: balance is 100, tried to withdraw 150note: run with `RUST_BACKTRACE=1` environment variable to display a backtracenote: panic did not contain expected string panic message: "insufficient funds: balance is 100, tried to withdraw 150" expected substring: "account frozen"Tip: Always prefer
#[should_panic(expected = "...")]over the bare form. The expected string nails down why the code panicked, so the test cannot pass because of an unrelated panic (a typo’d array index, a differentunwrapfailing, and so on).
#[should_panic] catches any panic, including assert! and overflow
Section titled “#[should_panic] catches any panic, including assert! and overflow”The body just needs to panic — it does not matter whether the panic came from an explicit panic!, from a failed assert!/assert_eq!, from an .unwrap() on None, or from an arithmetic overflow in a debug build. For example, a panic raised by assert! is caught the same way:
pub fn checked_div(a: i32, b: i32) -> i32 { assert!(b != 0, "division by zero is undefined"); a / b}
#[cfg(test)]mod tests { use super::*;
#[test] #[should_panic(expected = "division by zero")] fn divide_by_zero_panics() { checked_div(10, 0); }}running 1 testtest tests::divide_by_zero_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sTests can return Result<(), E> to use ?
Section titled “Tests can return Result<(), E> to use ?”A test function may return Result<(), E> for any error type E that implements Debug. The runner treats Ok(()) as a pass and any Err(e) as a failure, printing the error with its Debug representation. This unlocks the ? operator inside the test body:
#[test]fn parses_white() -> Result<(), ParseIntError> { let color = parse_hex_color("#ffffff")?; // `?` returns Err on failure assert_eq!(color, Rgb { r: 255, g: 255, b: 255 }); Ok(()) // explicit success value is required}Without this feature you would have to write parse_hex_color("#ffffff").unwrap() on every fallible line. The Result return type lets you write the same straight-line “happy path” code you would write in the library itself — see The ? Operator for the full mechanics of ?. The error type can be anything Debug: a concrete error like ParseIntError, a custom enum, or the catch-all Box<dyn std::error::Error> when several different errors flow through one test.
When such a test does fail, the Debug of the returned error is shown under the test’s name. A test that propagates a ParseIntError from invalid hex prints:
running 1 testtest tests::rejects_garbage ... FAILED
failures:
---- tests::rejects_garbage stdout ----Error: ParseIntError { kind: InvalidDigit }
failures: tests::rejects_garbage
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sNote:
Result-returning tests cannot be combined with#[should_panic]. The two model opposite outcomes:#[should_panic]says “this must panic,” while aResulttest says “this must finish and returnOk.” A test that needs to assert anErrvalue should returnResultand check the error explicitly (shown below), not use#[should_panic].
Key Differences
Section titled “Key Differences”| Concern | TypeScript (Jest/Vitest) | Rust |
|---|---|---|
| Assert that code throws/panics | expect(() => fn()).toThrow("msg") | #[should_panic(expected = "msg")] on the test |
| Pass a callback vs. run inline | Must wrap in () => ... so the matcher catches the throw | Body runs inline; the attribute inverts pass/fail |
| Message matching | Substring (string arg) or regex (/.../ ) | Substring only (str::contains); no regex |
| Fallible test body | async test; rejected await/throw fails it | -> Result<(), E> lets ? propagate Err |
| Explicit success value | Implicit (test returns undefined) | Must end with Ok(()) |
| Assert on the error value | try { ... } catch (e) { expect(e).toEqual(...) } | Return Result and check .unwrap_err(), or match |
| What “panic” / “throw” means | Any thrown value unwinds the stack | A panic! is an unrecoverable bug signal, not control flow |
The single most important conceptual difference: in TypeScript, throw is an ordinary, expected control-flow mechanism — you throw to signal validation failures and catch to recover. In Rust, a panic signals an unrecoverable bug and is not how you report expected failures; recoverable failures return Result. So #[should_panic] is for testing genuine “this should be impossible” invariants (a precondition violation, an index out of bounds), whereas testing an expected failure — bad user input, a missing file — means asserting on a returned Err, not on a panic. See Panicking for the panic-vs-Result decision.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: bare #[should_panic] passing for the wrong reason
Section titled “Pitfall 1: bare #[should_panic] passing for the wrong reason”A test marked with the bare attribute passes if the body panics for any reason. If you later introduce a bug — say, an out-of-bounds index before the line you meant to test — the test still passes, hiding the regression.
#[test]#[should_panic] // passes if ANY panic occurs, even an unrelated onefn overdraw_panics() { let accounts = vec![100u32]; let _ = accounts[5]; // this panics first — test "passes" for the wrong reason! withdraw(accounts[0], 150);}Fix: always pin the message with expected, so an unrelated panic (here, the slice-index panic index out of bounds) fails the substring check instead of silently satisfying it.
Pitfall 2: a #[should_panic] test that does not panic
Section titled “Pitfall 2: a #[should_panic] test that does not panic”If the body completes normally, the test fails. This is the failure mode you want (it tells you the panic you expected never happened), but the message surprises newcomers:
#[test]#[should_panic]fn does_not_actually_panic() { let _ = 2 + 2; // no panic — so the test fails}running 1 testtest tests::does_not_actually_panic - should panic ... FAILED
failures:
---- tests::does_not_actually_panic stdout ----note: test did not panic as expected at src/lib.rs:9:8
failures: tests::does_not_actually_panic
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sPitfall 3: using ? in a test that does not return Result
Section titled “Pitfall 3: using ? in a test that does not return Result”The ? operator only works in a function whose return type can carry the error. A default test returns (), so ? will not compile:
#[test]fn parses_ff() { // returns () — no place for `?` to send the Err let value = parse_hex_byte("ff")?; // does not compile (E0277) assert_eq!(value, 255);}The real compiler error:
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`) --> src/lib.rs:14:41 |13 | fn parses_ff() { | -------------- this function should return `Result` or `Option` to accept `?`14 | let value = parse_hex_byte("ff")?; | ^ cannot use the `?` operator in a function that returns `()` |help: consider adding return type |13 ~ fn parses_ff() -> Result<(), Box<dyn std::error::Error>> {14 | let value = parse_hex_byte("ff")?;15 | assert_eq!(value, 255);16 ~ Ok(())17 ~ }Fix: add the return type the compiler suggests (-> Result<(), Box<dyn std::error::Error>> or a concrete error type) and end the body with Ok(()).
Pitfall 4: trying to assert an Err value with #[should_panic]
Section titled “Pitfall 4: trying to assert an Err value with #[should_panic]”A returned Err is not a panic, so #[should_panic] will never trigger on it — the test would fail with “did not panic.” To assert that a function returns a specific error, return Result from the test and inspect the error directly:
#[test]fn missing_key_is_reported() { // `unwrap_err` extracts the Err; it panics only if the call unexpectedly succeeds. let err = parse_hex_color("nothex").unwrap_err(); assert_eq!(err.to_string(), "invalid digit found in string");}This is the idiomatic way to test the “expected failure” cases that you would write as expect(...).toThrow(...) in Jest but which in Rust are recoverable Result errors, not panics.
Pitfall 5: forgetting Ok(()) at the end
Section titled “Pitfall 5: forgetting Ok(()) at the end”A Result-returning test must yield a value on the success path. Ending the body with the last assert! is not enough, because assert! evaluates to (), not Result. Add an explicit Ok(()) as the final expression.
Best Practices
Section titled “Best Practices”- Always use
expected = "..."with#[should_panic]. The bare form is a foot-gun that passes on any panic. Pick a substring stable enough to survive small wording changes but specific enough to pin the cause. - Reserve
#[should_panic]for genuine invariants — preconditions, “unreachable” branches, index bounds — i.e. the things that should panic in production because they represent a bug. Do not use it to test ordinary input validation; that belongs in aResultreturn and anassert_eq!on the error. - Return
Result<(), E>whenever a test body has more than one fallible step, so?keeps the happy path readable. Use a concrete error type (ParseIntError, your domain enum) when one error flows through, andBox<dyn std::error::Error>when several different errors do. - Assert error values by returning
Resultand inspecting.unwrap_err()(ormatch/matches!), not by catching a panic. - Keep
Ok(())at the very end of aResulttest; treat it as the test’s “I reached the end successfully” marker. - A
Resulttest that itself fails on the success path can still use.unwrap()for the negative check — e.g.unwrap_err()is fine because a precondition guarantees it. Reserve unconditionalunwrap()for tests; in library code, prefer?(seeunwrapandexpect).
Real-World Example
Section titled “Real-World Example”A small configuration store that reads typed settings. The error type uses thiserror (add it with cargo add thiserror; this pulls in thiserror = "2"). The test module mixes all three styles: a Result-returning happy-path test that uses ?, a negative test that asserts on the returned Err value, and a test that exercises the Display impl.
use std::collections::HashMap;use thiserror::Error;
/// Errors that can occur while reading a typed setting from the config.#[derive(Debug, Error, PartialEq)]pub enum ConfigError { #[error("missing required key: {0}")] Missing(String), #[error("key `{key}` is not a valid integer: {value:?}")] NotAnInt { key: String, value: String },}
/// A tiny string-keyed configuration store.pub struct Config { values: HashMap<String, String>,}
impl Config { pub fn from_pairs(pairs: &[(&str, &str)]) -> Self { let values = pairs .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); Config { values } }
/// Read a required `u16` setting, failing if it is missing or malformed. pub fn require_u16(&self, key: &str) -> Result<u16, ConfigError> { let raw = self .values .get(key) .ok_or_else(|| ConfigError::Missing(key.to_string()))?; raw.parse::<u16>().map_err(|_| ConfigError::NotAnInt { key: key.to_string(), value: raw.clone(), }) }}
#[cfg(test)]mod tests { use super::*;
// A Result-returning test: every `?` either gives us the value or fails // the test by returning the Err. No `.unwrap()` noise on the happy path. #[test] fn reads_valid_port() -> Result<(), ConfigError> { let config = Config::from_pairs(&[("port", "8080"), ("host", "localhost")]); let port = config.require_u16("port")?; assert_eq!(port, 8080); Ok(()) }
// For the *failure* paths we assert on the returned Err directly, // rather than using `?` (which would abort the test). #[test] fn missing_key_is_reported() { let config = Config::from_pairs(&[("host", "localhost")]); let err = config.require_u16("port").unwrap_err(); assert_eq!(err, ConfigError::Missing("port".to_string())); }
#[test] fn malformed_int_is_reported() -> Result<(), Box<dyn std::error::Error>> { let config = Config::from_pairs(&[("port", "not-a-number")]); let err = config.require_u16("port").unwrap_err(); // `to_string()` exercises the `#[error(...)]` Display impl. assert!(err.to_string().contains("not a valid integer")); Ok(()) }}Running cargo test for this module:
running 3 teststest tests::malformed_int_is_reported ... oktest tests::missing_key_is_reported ... oktest tests::reads_valid_port ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sNotice the division of labor: the success path uses ? for clean propagation, the failure paths use unwrap_err() and assert on the ConfigError value, and none of them uses #[should_panic] — because none of these are bugs, they are recoverable errors. That is the idiomatic Rust split.
Further Reading
Section titled “Further Reading”- The Rust Programming Language — How to Write Tests (Checking for Panics with
should_panic) - The Rust Programming Language — Using
Result<T, E>in Tests - Rust By Example — Testing
- Related sections in this guide:
- Unit Tests — the
#[test]and#[cfg(test)] mod testsbasics these tests build on - Assertions —
assert!/assert_eq!/assert_ne!and custom messages, which trigger the panics#[should_panic]catches - Integration Tests —
Result-returning tests also work in thetests/directory - Doc Tests — doc tests support a
should_panicattribute and can also returnResult - Test Organization — where these tests live and the
testssubmodule convention - The
?Operator — the mechanics of?thatResult-returning tests rely on - Panicking — when a panic is the right signal versus returning a
Result unwrapandexpect— acceptable in tests, including theunwrap_err()pattern above- Section 14: Macros —
#[test],#[should_panic], andassert!are all built on Rust’s attribute and macro system
- Unit Tests — the
Exercises
Section titled “Exercises”Exercise 1: assert a precondition panic
Section titled “Exercise 1: assert a precondition panic”Difficulty: Easy
Objective: Use #[should_panic(expected = "...")] to verify that a method panics when called on an invalid state.
Instructions:
- Given the
Stack<T>below, whosepoppanics on an empty stack, write a test that confirms callingpopon a fresh stack panics with a message containing"empty stack". - Add a second, ordinary test that pushes two values and asserts
popreturns them in last-in-first-out order.
pub struct Stack<T> { items: Vec<T>,}
impl<T> Stack<T> { pub fn new() -> Self { Stack { items: Vec::new() } } pub fn push(&mut self, item: T) { self.items.push(item); } /// Remove and return the top item, panicking if the stack is empty. pub fn pop(&mut self) -> T { self.items.pop().expect("pop called on an empty stack") }}
#[cfg(test)]mod tests { use super::*; // TODO: write the two tests}Solution
pub struct Stack<T> { items: Vec<T>,}
impl<T> Stack<T> { pub fn new() -> Self { Stack { items: Vec::new() } } pub fn push(&mut self, item: T) { self.items.push(item); } pub fn pop(&mut self) -> T { self.items.pop().expect("pop called on an empty stack") }}
#[cfg(test)]mod tests { use super::*;
#[test] #[should_panic(expected = "empty stack")] fn pop_on_empty_panics() { let mut stack: Stack<i32> = Stack::new(); stack.pop(); }
#[test] fn push_then_pop() { let mut stack = Stack::new(); stack.push(1); stack.push(2); assert_eq!(stack.pop(), 2); assert_eq!(stack.pop(), 1); }}The expected substring matches the message inside .expect(...). Running cargo test reports pop_on_empty_panics - should panic ... ok and push_then_pop ... ok.
Exercise 2: convert an unwrap-heavy test to a Result test
Section titled “Exercise 2: convert an unwrap-heavy test to a Result test”Difficulty: Medium
Objective: Replace .unwrap() calls in a test body with the ? operator by giving the test a Result return type.
Instructions:
- The parser below turns a
"x,y"row into aPoint. Write a testparses_a_pointthat parses"3, 4"and asserts it equalsPoint { x: 3, y: 4 }. - Use
?rather than.unwrap(), which means the test must returnResult<(), ParseError>and end withOk(()).
use std::num::ParseIntError;
#[derive(Debug, PartialEq)]pub struct Point { pub x: i32, pub y: i32,}
#[derive(Debug, PartialEq)]pub enum ParseError { WrongFieldCount(usize), BadInt(ParseIntError),}
impl From<ParseIntError> for ParseError { fn from(e: ParseIntError) -> Self { ParseError::BadInt(e) }}
pub fn parse_point(row: &str) -> Result<Point, ParseError> { let fields: Vec<&str> = row.split(',').collect(); if fields.len() != 2 { return Err(ParseError::WrongFieldCount(fields.len())); } let x = fields[0].trim().parse::<i32>()?; let y = fields[1].trim().parse::<i32>()?; Ok(Point { x, y })}
#[cfg(test)]mod tests { use super::*; // TODO: write `parses_a_point` using `?`}Solution
use std::num::ParseIntError;
#[derive(Debug, PartialEq)]pub struct Point { pub x: i32, pub y: i32,}
#[derive(Debug, PartialEq)]pub enum ParseError { WrongFieldCount(usize), BadInt(ParseIntError),}
impl From<ParseIntError> for ParseError { fn from(e: ParseIntError) -> Self { ParseError::BadInt(e) }}
pub fn parse_point(row: &str) -> Result<Point, ParseError> { let fields: Vec<&str> = row.split(',').collect(); if fields.len() != 2 { return Err(ParseError::WrongFieldCount(fields.len())); } let x = fields[0].trim().parse::<i32>()?; let y = fields[1].trim().parse::<i32>()?; Ok(Point { x, y })}
#[cfg(test)]mod tests { use super::*;
#[test] fn parses_a_point() -> Result<(), ParseError> { let point = parse_point("3, 4")?; assert_eq!(point, Point { x: 3, y: 4 }); Ok(()) }}Because the test returns Result<(), ParseError>, the ? on parse_point propagates any Err as a test failure, and the happy path stays free of .unwrap(). The test reports parses_a_point ... ok.
Exercise 3: test both the success and the two failure modes
Section titled “Exercise 3: test both the success and the two failure modes”Difficulty: Medium-Hard
Objective: Cover one function with a Result test for success and Err-value assertions for each failure path — without #[should_panic], because these are recoverable errors, not bugs.
Instructions:
Using the same parse_point from Exercise 2, write three tests:
wrong_field_count_is_an_error: parsing"1,2,3"returnsErr(ParseError::WrongFieldCount(3)).bad_int_is_an_error: parsing a valid"10,-7"succeeds via?, and parsing"x,2"returns aParseError::BadInt(_).- Decide for each whether the test should return
Resultand explain (in a comment) why you did not use#[should_panic].
Solution
use std::num::ParseIntError;
#[derive(Debug, PartialEq)]pub struct Point { pub x: i32, pub y: i32,}
#[derive(Debug, PartialEq)]pub enum ParseError { WrongFieldCount(usize), BadInt(ParseIntError),}
impl From<ParseIntError> for ParseError { fn from(e: ParseIntError) -> Self { ParseError::BadInt(e) }}
pub fn parse_point(row: &str) -> Result<Point, ParseError> { let fields: Vec<&str> = row.split(',').collect(); if fields.len() != 2 { return Err(ParseError::WrongFieldCount(fields.len())); } let x = fields[0].trim().parse::<i32>()?; let y = fields[1].trim().parse::<i32>()?; Ok(Point { x, y })}
#[cfg(test)]mod tests { use super::*;
// A wrong field count is a recoverable, *expected* failure — not a bug — // so we assert on the returned Err value rather than expecting a panic. #[test] fn wrong_field_count_is_an_error() { let err = parse_point("1,2,3").unwrap_err(); assert_eq!(err, ParseError::WrongFieldCount(3)); }
// Same reasoning: a malformed integer returns Err, it does not panic. // This test returns Result so the *valid* row can use `?` on the happy path. #[test] fn bad_int_is_an_error() -> Result<(), ParseError> { let ok = parse_point("10,-7")?; assert_eq!(ok, Point { x: 10, y: -7 }); assert!(matches!(parse_point("x,2"), Err(ParseError::BadInt(_)))); Ok(()) }}Both failures are returned as Err values, so #[should_panic] would be wrong here — it would only catch a panic, and parse_point never panics. matches! is a concise way to assert the variant of an error without comparing the inner ParseIntError, which does not have a convenient literal. The suite reports wrong_field_count_is_an_error ... ok and bad_int_is_an_error ... ok.