Question

How to Test for Panics in Rust Unit Tests

rustunit-testing

Question

I have a Rust function that panics under certain conditions, and I want to write a unit test to verify that the panic actually happens. I found assert! and assert_eq!, but I did not see an obvious way to test for panics.

Is there a built-in mechanism for this in Rust, or should I spawn a separate task and check whether that task panics?

Returning a Result<T, E> is not suitable in my case.

For example, I want to implement the Add trait for a Matrix type. I want matrix addition to support syntax like this:

let m = m1 + m2 + m3;

where m1, m2, and m3 are matrices.

That means the result type of add should be Matrix. A style like this would be too awkward:

let m = ((m1 + m2).unwrap() + m3).unwrap();

However, matrix addition must validate that both matrices have the same dimensions. If the dimensions do not match, add() should panic. In that case, how can I write a Rust unit test that confirms the panic occurs?

Short Answer

By the end of this page, you will understand how Rust tests can check for panics, when to use #[should_panic], when std::panic::catch_unwind is helpful, and how this applies to APIs like Add implementations that panic on invalid input.

Concept

In Rust, a panic signals that the program has reached an unrecoverable state. In tests, you sometimes want to verify that invalid input correctly triggers that panic.

Rust provides a built-in way to do this with the #[should_panic] test attribute. If a test marked with #[should_panic] panics, the test passes. If it does not panic, the test fails.

This matters because some APIs are intentionally designed to panic when misused. Common examples include:

  • indexing past the end of a collection
  • calling methods that require a valid invariant
  • operator overloads such as Add when invalid operands should be considered a programmer error

In your matrix example, implementing Add for Matrix usually means the method must return Matrix, not Result<Matrix, E>, because the trait signature is fixed around an output type. If mismatched dimensions are considered invalid usage, panicking can be a reasonable design choice.

When that is your design, your tests should explicitly confirm both behaviors:

  • valid matrices add correctly
  • invalid matrices panic

Rust testing supports this directly, so you do not need to spawn a new task just to detect a panic in most cases.

Mental Model

Think of a panic like a built-in emergency stop.

  • Normal return value: the function finished its work normally.
  • Result: the function expected something might go wrong and lets the caller handle it.
  • Panic: the function detected a broken rule and stops immediately.

A panic test is like checking that a machine's emergency shutdown triggers when safety conditions are violated.

For matrix addition:

  • same dimensions -> normal operation
  • different dimensions -> emergency stop

Your test is not asking, "Did the math work?" It is asking, "Did the safety rule activate when it should?"

Take Quiz

Syntax and Examples

The simplest way to test for a panic in Rust is #[should_panic].

Basic syntax

#[test]
#[should_panic]
fn test_that_panics() {
    panic!("something went wrong");
}

This test passes because the function panics.

Example with matrix validation

use std::ops::Add;

#[derive(Debug, PartialEq, Clone)]
struct Matrix {
    rows: usize,
    cols: usize,
    data: Vec<i32>,
}

impl Add for Matrix {
    type Output = Matrix;

    fn add(self, rhs: Matrix) -> Matrix {
        if self.rows != rhs.rows || self.cols != rhs.cols {
            panic!("matrix dimensions must match");
        }

        let data = self
            .data
            .into_iter()
            .zip(rhs.data.into_iter())
            .map(|(a, b)| a + b)
            .collect();

        Matrix {
            rows: self.rows,
            cols: self.cols,
            data,
        }
    }
}

#[test]
#[should_panic]
fn add_panics_for_different_dimensions() {
    let m1 = Matrix {
        rows: 2,
        cols: 2,
        data: vec![1, 2, 3, 4],
    };

    let m2 = Matrix {
        rows: 3,
        cols: 2,
        data: vec![5, 6, 7, 8, 9, 10],
    };

    let _ = m1 + m2;
}

Checking the panic message

If you want to verify part of the panic message too, use expected:

#[test]
#[should_panic(expected = "matrix dimensions must match")]
fn add_panics_with_clear_message() {
    let m1 = Matrix {
        rows: 1,
        cols: 2,
        data: vec![1, 2],
    };

    let m2 = Matrix {
        rows: 2,
        cols: 1,
        data: vec![3, 4],
    };

    let _ = m1 + m2;
}

This is often better than only checking that some panic happened.

Step by Step Execution

Consider this test:

#[test]
#[should_panic(expected = "matrix dimensions must match")]
fn add_panics_with_clear_message() {
    let m1 = Matrix {
        rows: 1,
        cols: 2,
        data: vec![1, 2],
    };

    let m2 = Matrix {
        rows: 2,
        cols: 1,
        data: vec![3, 4],
    };

    let _ = m1 + m2;
}

Here is what happens step by step:

  1. The test runner starts add_panics_with_clear_message.
  2. Because of #[should_panic(...)], Rust expects this test to panic.
  3. m1 and m2 are created.
  4. The expression m1 + m2 calls the add method from the Add trait implementation.
  5. Inside add, the code compares rows and cols.
  6. The dimensions do not match.
  7. The code executes panic!("matrix dimensions must match").
  8. The test runner catches the panic.
  9. Rust checks whether the panic message contains matrix dimensions must match.
  10. It matches, so the test passes.

If the code did not panic, the test would fail.

If it panicked with a different message, the test would also fail.

Real World Use Cases

Testing panics is useful in real Rust code when your API treats invalid usage as a programmer error.

Common scenarios

  • Operator overloading: Add, Sub, or indexing operations may panic when invariants are violated.
  • Input validation for internal APIs: helper functions may panic if they are only meant to be called with already-validated data.
  • Collection access: code that intentionally uses indexing may rely on panic behavior for invalid indexes.
  • Debug-only invariant checks: libraries may panic when internal assumptions are broken.
  • Constructor validation: some constructors panic if impossible states are requested.

Matrix example

A matrix library might choose:

  • checked_add(&self, &other) -> Result<Matrix, MatrixError> for recoverable user-facing code
  • impl Add for Matrix that panics on invalid dimensions for ergonomic operator syntax

This gives users two styles:

  • safe, explicit error handling
  • concise operator syntax when inputs are trusted
Take Quiz

Real Codebase Usage

In real projects, developers often combine panic-based APIs with safer alternatives.

Common patterns

1. Ergonomic operator + checked method

impl Matrix {
    fn checked_add(self, rhs: Matrix) -> Result<Matrix, &'static str> {
        if self.rows != rhs.rows || self.cols != rhs.cols {
            return Err("matrix dimensions must match");
        }

        let data = self
            .data
            .into_iter()
            .zip(rhs.data.into_iter())
            .map(|(a, b)| a + b)
            .collect();

        Ok(Matrix {
            rows: self.rows,
            cols: self.cols,
            data,
        })
    }
}

Then Add can delegate to it and panic if needed:

impl Add for Matrix {
    type Output = Matrix;

    fn add(self, rhs: Matrix) -> Matrix {
        self.checked_add(rhs)
            .expect("matrix dimensions must match")
    }
}

This is common because it keeps operator syntax clean while still offering a non-panicking API.

2. Guard clauses

Developers usually check invalid conditions early:

if self.rows != rhs.rows || self.cols != rhs.cols {
    panic!("matrix dimensions must match");
}

This keeps the happy path simple.

3. Specific panic tests

Instead of only using #[should_panic], teams often prefer:

#[should_panic(expected = "matrix dimensions must match")]

This avoids false positives where the code panics for the wrong reason.

4. catch_unwind for finer control

In advanced test scenarios, developers may use std::panic::catch_unwind if they need to inspect whether a closure panicked without marking the whole test with #[should_panic].

use std::panic;

#[test]
fn panic_can_be_caught() {
    let result = panic::catch_unwind(|| {
        panic!("boom");
    });

    assert!(result.is_err());
}

This is useful when one test needs to verify multiple panic and non-panic cases.

Common Mistakes

1. Using #[should_panic] without narrowing the cause

This test passes on any panic:

#[test]
#[should_panic]
fn test_matrix_add() {
    let _ = buggy_code();
}

Problem:

  • the panic might come from unrelated code

Better:

#[test]
#[should_panic(expected = "matrix dimensions must match")]
fn test_matrix_add() {
    let _ = buggy_code();
}

2. Forgetting to trigger the panic

Broken example:

#[test]
#[should_panic]
fn test_add_should_panic() {
    let m1 = make_matrix_2x2();
    let m2 = make_matrix_3x2();
    // forgot to actually add them
}

This fails because no panic occurred.

3. Testing panic behavior when Result would be better

Sometimes beginners panic for normal user errors. That is usually not ideal.

Use panic when:

  • invalid usage breaks API assumptions
  • the operation is defined as infallible in its interface

Use Result when:

  • failure is expected and should be handled by the caller

4. Spawning threads unnecessarily

You usually do not need a new thread or task to test a panic. Rust test attributes already handle this.

5. Writing brittle exact-message tests

This can be too strict if wording changes often:

#[should_panic(expected = "matrix dimensions must match: left=2x2 right=3x2")]

Prefer checking a stable, meaningful substring.

6. Panicking for the wrong reason

Example:

impl Add for Matrix {
    type Output = Matrix;

    fn add(self, rhs: Matrix) -> Matrix {
        let data = self
            .data
            .into_iter()
            .zip(rhs.data.into_iter())
            .map(|(a, b)| a + b)
            .collect();

        Matrix {
            rows: self.rows,
            cols: self.cols,
            data,
        }
    }
}

This code silently truncates to the shorter iterator instead of validating dimensions. That is not the same as a controlled panic. Always validate dimensions explicitly first.

Comparisons

ApproachWhen to useProsCons
#[should_panic]A whole test should panicSimple and built-inLess flexible
#[should_panic(expected = "...")]You want to verify the reasonCatches wrong panic causes betterStill tied to panic text
std::panic::catch_unwindYou need programmatic panic detection inside a testMore controlMore verbose
Returning Result<T, E>Failure is expected and recoverableExplicit and flexibleNot always compatible with trait ergonomics

#[should_panic] vs catch_unwind

#[test]
#[should_panic]
fn simple_panic_test() {
    panic!("boom");
}

Best for straightforward cases.

use std::panic;

#[test]
fn catch_panic_manually() {
    let result = panic::catch_unwind(|| {
        panic!("boom");
    });

    assert!(result.is_err());
}

Best when you need to keep control inside the test.

Panic vs Result

Design choiceMeaning
PanicCaller violated an important rule or invariant
ResultFailure is normal enough that the caller should handle it

For Matrix + Matrix, panicking can be acceptable for invalid dimensions if you want operator ergonomics, but many libraries also provide a checked alternative.

Take Quiz

Cheat Sheet

#[test]
#[should_panic]
fn test_name() {
    some_code_that_should_panic();
}
#[test]
#[should_panic(expected = "message text")]
fn test_name() {
    some_code_that_should_panic();
}
use std::panic;

#[test]
fn test_with_catch_unwind() {
    let result = panic::catch_unwind(|| {
        some_code_that_should_panic();
    });

    assert!(result.is_err());
}

Rules

  • #[should_panic] makes the test pass only if a panic occurs.
  • expected = "..." checks that the panic message contains the given text.
  • You usually do not need a separate thread to test panics.
  • Use Result for recoverable errors.
  • Use panic for broken assumptions or invariant violations.

Good practice

  • Test both success and panic cases.
  • Prefer expected = "..." when possible.
  • Consider offering both a checked API and an ergonomic panicking API.

Matrix pattern

impl Add for Matrix {
    type Output = Matrix;

    fn add(self, rhs: Matrix) -> Matrix {
        self.checked_add(rhs)
            .expect("matrix dimensions must match")
    }
}

FAQ

How do I assert that a function panics in Rust?

Use the #[should_panic] attribute on a test function.

Can I check the panic message too?

Yes. Use #[should_panic(expected = "some text")] to match part of the message.

Do I need to spawn a thread or task to test a panic?

No. In normal unit tests, #[should_panic] is the standard solution.

When should I use catch_unwind instead of #[should_panic]?

Use catch_unwind when you need to inspect panic behavior inside a larger test or keep executing more assertions afterward.

Is panicking in Add a bad idea?

Not necessarily. If mismatched matrix dimensions represent invalid usage and you want ergonomic + syntax, panicking can be reasonable.

Should matrix addition return Result instead?

That depends on your API design. Result is better for recoverable errors, but trait-based operator syntax often favors returning the concrete output type.

Can one Rust test verify both panic and non-panic behavior?

Yes. catch_unwind is useful when one test needs to check both cases programmatically.

What happens if a #[should_panic] test does not panic?

The test fails.

Take Quiz

Mini Project

Description

Build a small Matrix type in Rust that supports addition with the + operator and panics when the dimensions do not match. This project demonstrates both normal unit testing and panic testing using Rust's built-in test tools.

Goal

Create a Matrix type with Add support, write one test for successful addition, and one test that confirms invalid dimensions cause a panic.

Requirements

  • Define a Matrix struct with row count, column count, and data storage.
  • Implement the Add trait for Matrix.
  • Panic if the dimensions of the two matrices do not match.
  • Write at least one passing addition test.
  • Write at least one panic test using #[should_panic].
Take Quiz

Keep learning

Related questions

View more