Rust Should Panic
When developing robust applications, ensuring your code fails correctly is just as important as making sure it succeeds correctly. In Rust, the panic!
mechanism provides a way for your program to terminate when it encounters an unrecoverable error. But how do you test that your code panics when it should? This is where the should_panic
attribute comes in.
Introduction to should_panic
In Rust's testing framework, the should_panic
attribute allows you to verify that your code panics under specific conditions. This is particularly useful when you want to ensure that your error handling works correctly.
For example, if you're writing a function that divides two numbers, you might want to ensure it panics when the denominator is zero. The should_panic
attribute allows you to write tests that verify this behavior.
Basic Usage of should_panic
Let's start with a simple example. Imagine we have a function that verifies a user's age:
pub fn verify_age(age: u8) {
if age < 18 {
panic!("Age must be 18 or older");
}
println!("Age verification successful!");
}
To test that this function panics when the age is less than 18, we can write a test using the should_panic
attribute:
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn test_verify_age_too_young() {
verify_age(16); // This should panic
}
#[test]
fn test_verify_age_valid() {
verify_age(18); // This should not panic
}
}
In this example:
#[test]
marks a function as a test#[should_panic]
indicates that we expect the test to panic- The test passes if the code panics, and fails if it doesn't
When you run this test with cargo test
, you'll see output like:
running 2 tests
test tests::test_verify_age_valid ... ok
test tests::test_verify_age_too_young ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Both tests pass: test_verify_age_valid
because it doesn't panic, and test_verify_age_too_young
because it panics as expected.
Checking Panic Messages with expected
The basic should_panic
attribute verifies that the code panics, but it doesn't check the panic message. To ensure your code is panicking for the right reason, you can use the expected
parameter:
#[test]
#[should_panic(expected = "Age must be 18 or older")]
fn test_verify_age_too_young() {
verify_age(16);
}
This test will pass only if:
- The code panics
- The panic message contains the specified text
If the code panics with a different message, the test will fail.
Let's see what happens if we change our function to use a different panic message:
pub fn verify_age(age: u8) {
if age < 18 {
panic!("User is underage!");
}
println!("Age verification successful!");
}
Now our test will fail because the panic message doesn't match the expected string:
running 2 tests
test tests::test_verify_age_valid ... ok
test tests::test_verify_age_too_young ... FAILED
failures:
---- tests::test_verify_age_too_young stdout ----
thread 'tests::test_verify_age_too_young' panicked at 'User is underage!', src/lib.rs:3:9
note: panic did not contain expected string
expected: 'Age must be 18 or older'
actual: 'User is underage!'
This helps ensure that your code is panicking for the exact reason you expect it to.
Working with Complex Panic Conditions
Let's explore a more complex example. Imagine we're building a simple bank account application:
pub struct BankAccount {
balance: i32,
}
impl BankAccount {
pub fn new(initial_balance: i32) -> Result<Self, String> {
if initial_balance < 0 {
return Err("Initial balance cannot be negative".to_string());
}
Ok(Self { balance: initial_balance })
}
pub fn withdraw(&mut self, amount: i32) {
if amount <= 0 {
panic!("Withdrawal amount must be positive");
}
if amount > self.balance {
panic!("Insufficient funds: tried to withdraw {} with balance of {}", amount, self.balance);
}
self.balance -= amount;
}
}
We can test the panic conditions for our withdraw
method:
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "Withdrawal amount must be positive")]
fn test_negative_withdrawal() {
let mut account = BankAccount::new(100).unwrap();
account.withdraw(-50);
}
#[test]
#[should_panic(expected = "Insufficient funds")]
fn test_excessive_withdrawal() {
let mut account = BankAccount::new(100).unwrap();
account.withdraw(150);
}
}
Here we're testing two different panic conditions:
- That attempting to withdraw a negative amount causes a panic
- That attempting to withdraw more than the account balance causes a panic
Note that for the second test, we're only checking for the prefix "Insufficient funds" rather than the entire message, since the message includes variable values.
When to Use should_panic
Use should_panic
when:
- Validating error handling: Ensure your code correctly panics for invalid input
- Testing boundary conditions: Verify edge cases are handled properly
- Testing invariant violations: Make sure your code maintains its integrity
It's particularly useful when:
- You're designing APIs and want to ensure they fail gracefully
- You're implementing critical functions where invalid states must be caught immediately
- You want to document expected failure behavior
Real-World Example: Vector Access
Let's look at a real-world example involving vector access in Rust:
pub fn get_element(vec: &Vec<i32>, index: usize) -> i32 {
// Using [] access which will panic if index is out of bounds
vec[index]
}
pub fn get_element_safe(vec: &Vec<i32>, index: usize) -> Option<i32> {
// Using get() which returns None if index is out of bounds
vec.get(index).copied()
}
We can test both functions:
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_get_element_out_of_bounds() {
let vec = vec![1, 2, 3];
get_element(&vec, 5);
}
#[test]
fn test_get_element_safe_out_of_bounds() {
let vec = vec![1, 2, 3];
assert_eq!(get_element_safe(&vec, 5), None);
}
}
This demonstrates two approaches to the same problem:
get_element
uses direct indexing, which panics for invalid indicesget_element_safe
returns anOption
, which is a more "Rustic" way to handle potential errors
Diagnosing Test Failures
If a should_panic
test fails, it could be for two reasons:
- The code didn't panic when it should have
- The code panicked with a different message than expected
Let's visualize the test execution flow:
Best Practices for should_panic
Tests
- Be specific with expected messages: Use the
expected
parameter to ensure your code is panicking for the right reason - Keep panic messages consistent: Don't change panic messages without updating tests
- Test one panic condition per test: Each test should focus on one specific panic scenario
- Consider alternatives to panic: In many cases, using
Result
orOption
types is more appropriate than panicking - Name your tests clearly: The test name should describe the failing condition
Alternative Approaches
While should_panic
is useful, Rust offers other approaches for testing error conditions:
Using Result
instead of panic
pub fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("Division by zero".to_string());
}
Ok(a / b)
}
#[test]
fn test_divide_by_zero() {
let result = divide(10, 0);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Division by zero");
}
Testing with catch_unwind
For more control, you can use std::panic::catch_unwind
:
use std::panic::{catch_unwind, AssertUnwindSafe};
#[test]
fn test_panic_with_catch_unwind() {
let result = catch_unwind(|| {
let v = vec![1, 2, 3];
v[5] // This should panic
});
assert!(result.is_err());
}
Summary
The should_panic
attribute is a powerful tool in Rust's testing arsenal that allows you to verify that your code panics when it should. By using this attribute, you can:
- Ensure your error handling works correctly
- Test boundary conditions and edge cases
- Verify that invariants are maintained
- Document expected failure behavior
Remember that while panicking is sometimes necessary, Rust's type system generally encourages using Result
and Option
types for error handling. The should_panic
attribute is most useful for testing scenarios where panic is the appropriate response to an unrecoverable error.
Additional Resources
- Rust Official Documentation on Testing
- Rust By Example: Testing
- The Rust Programming Language (Book): Error Handling
Exercises
-
Write a function
create_array
that takes a size parameter and returns an array of that size. The function should panic if the size is greater than 100. Write tests to verify this behavior. -
Implement a
Stack
struct withpush
andpop
methods. Thepop
method should panic when the stack is empty. Write tests usingshould_panic
to verify this behavior. -
Modify the
BankAccount
example to add atransfer
method that moves money between accounts. Ensure it panics appropriately for invalid operations, and write tests to verify this. -
Create a function that processes a string containing JSON. It should panic if the JSON is invalid. Use
should_panic
to test various invalid JSON inputs. -
Compare the use of
panic!
versus returning aResult
for error handling in a small project. Write tests for both approaches and evaluate the pros and cons.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)