How to test error conditions

Use the #[should_panic] attribute on a test function to verify that error conditions cause the expected crash.

The error path is the real test

You wrote a function that parses a configuration file. It works perfectly when the file is valid. You run the test, see the green checkmark, and celebrate. Then you deploy. A user uploads a file with a typo. The server crashes. The problem isn't that your code works. The problem is that you never tested what happens when it breaks.

Testing error conditions is just as important as testing the happy path. In many languages, errors are signals that crash the program or jump to a catch block. Rust treats errors as data. Most functions return a Result<T, E>, which is a value containing either success or failure. You can inspect that value, compare it, and assert its contents just like any other variable. This changes how you write tests. You don't just hope errors don't happen. You verify their shape and message.

Errors are values, not crashes

Rust's Result type is an enum with two variants: Ok(T) and Err(E). When a function returns Result, it hands you a sealed box. If the operation succeeded, the box contains the value. If it failed, the box contains an error description. Testing errors means opening the box and checking what's inside.

Think of Result like a delivery package. If the item arrived, the box contains the product. If something went wrong, the box contains a return slip explaining the issue. Testing the happy path checks that the product is correct. Testing the error path checks that the return slip exists and that the message on the slip makes sense. You verify the slip isn't blank. You verify it mentions the specific problem.

Panics are different. A panic is like the delivery truck exploding. The thread stops immediately. You test panics by expecting the explosion. Most application logic should return Result, not panic. Panics are for bugs, like accessing an array out of bounds. Errors are for recoverable failures, like a missing file or invalid input. Your tests should reflect this distinction.

Minimal example: asserting an error

Start with a simple function that returns Result. The test checks that the function returns an error when given bad input. Use assert! to verify the variant, then extract the error payload to check the details.

/// Parses a string into a positive integer.
/// Returns an error if the string is not a number or is negative.
fn parse_positive(s: &str) -> Result<i32, String> {
    // Attempt to parse the string as an integer.
    let n = s.parse::<i32>().map_err(|e| format!("parse failed: {}", e))?;
    
    // Check if the number is negative.
    if n < 0 {
        Err(format!("number must be positive, got {}", n))
    } else {
        Ok(n)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Tests that parsing a negative number returns an error.
    #[test]
    fn test_parse_negative_returns_error() {
        let result = parse_positive("-5");
        
        // Assert the result is an error variant.
        assert!(result.is_err());
        
        // Extract the error value for inspection.
        // unwrap_err panics if the result is Ok, which fails the test.
        let err_msg = result.unwrap_err();
        
        // Verify the error message contains the expected text.
        assert!(err_msg.contains("positive"));
    }
}

The is_err() method returns true if the result is Err. The unwrap_err() method extracts the error value. If the result is Ok, unwrap_err() panics. This is useful in tests because it turns a logic error into a test failure. If you expected an error but got success, the test crashes and reports the failure.

Walkthrough: checking the payload

Checking that an error exists is rarely enough. You need to verify the error contains the right information. The unwrap_err() method gives you the error value so you can inspect it. If the error is a String, you can check substrings. If the error is a custom type, you can check fields.

Use expect_err() instead of unwrap_err() in tests. The expect_err() method takes a message argument. If the result is Ok, the panic includes your message. This helps when a test fails for the wrong reason. The message tells you which assertion fired.

#[cfg(test)]
mod tests {
    use super::*;

    /// Tests that parsing garbage returns a parse error.
    #[test]
    fn test_parse_garbage_returns_parse_error() {
        let result = parse_positive("abc");
        
        // Assert error and extract with a descriptive message.
        let err_msg = result.expect_err("expected parse failure for non-numeric input");
        
        // Check that the error mentions the parse failure.
        assert!(err_msg.contains("parse failed"));
    }
}

Convention aside: the Rust community prefers expect over unwrap everywhere, including tests. The extra message costs nothing and pays off when a test fails unexpectedly. If you use unwrap and the test panics, the output just says "called Result::unwrap() on an Err value". It doesn't tell you which line failed or what you expected. expect fixes that.

Realistic example: custom error types

Real code uses custom error types, not just String. These types carry structured data. Tests should verify the structure. To use assert_eq! with Err, the error type must implement PartialEq. Derive the trait on your error enum or struct.

#[derive(Debug, PartialEq)]
enum ValidationError {
    TooShort,
    InvalidChar(char),
    Duplicate,
}

/// Validates a username string.
/// Returns an error if the name is invalid.
fn validate_username(name: &str) -> Result<(), ValidationError> {
    if name.len() < 3 {
        return Err(ValidationError::TooShort);
    }
    
    for c in name.chars() {
        if !c.is_alphanumeric() && c != '_' {
            return Err(ValidationError::InvalidChar(c));
        }
    }
    
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Tests that a short username returns TooShort.
    #[test]
    fn test_username_too_short() {
        let result = validate_username("ab");
        // Compare the entire Result, including the error variant.
        assert_eq!(result, Err(ValidationError::TooShort));
    }

    /// Tests that an invalid character returns InvalidChar with the char.
    #[test]
    fn test_username_invalid_char() {
        let result = validate_username("user@name");
        // Verify the error captures the specific bad character.
        assert_eq!(result, Err(ValidationError::InvalidChar('@')));
    }
}

The assert_eq! macro compares the Result values. Since ValidationError derives PartialEq, the comparison works. The test verifies both the variant and the data inside the variant. If the function returns Err(ValidationError::InvalidChar('#')), the test fails with a clear diff.

If you forget to derive PartialEq, the compiler rejects the comparison with E0277 (trait bound not satisfied). The error message tells you that ValidationError doesn't implement PartialEq. Add the derive attribute to fix it.

Pitfalls: unwraps, panics, and trait bounds

Testing errors introduces specific traps. Avoid them by following a few patterns.

The first trap is using unwrap() on a result that should be an error. If you write let val = func().unwrap(); and func returns Err, the test panics. The panic message is unhelpful. It doesn't tell you that you expected an error. Use unwrap_err() or expect_err() when you expect failure.

The second trap is #[should_panic] matching too broadly. This attribute marks a test as expecting a panic. If any line in the test panics, the test passes. If you test a function that panics, but the panic comes from an unrelated assertion, the test still passes. Use #[should_panic(expected = "message")] to restrict the match. The panic message must contain the substring. This prevents false positives.

#[test]
#[should_panic(expected = "index out of bounds")]
fn test_array_panic() {
    let arr = [1, 2, 3];
    // This panics with the expected message.
    let _ = arr[10];
}

The third trap is type mismatches. If you try to compare Err(value) but the types don't match, the compiler rejects the code with E0308 (mismatched types). This happens when the error type in the test differs from the function's error type. Check the function signature. Ensure the error type in the test matches exactly.

The fourth trap is ignoring the error message. Testing is_err() confirms an error occurred. It doesn't confirm the error is correct. A function might return Err("unknown") for every failure. The test passes, but the error is useless. Always inspect the error payload. Check the message or the variant. If the error changes, the test should fail.

Derive PartialEq on your error types. If you can't compare them, you can't test them precisely.

Decision matrix: choosing the right assertion

Pick the assertion style based on what you need to verify.

Use assert!(result.is_err()) when you only care that an error occurred and don't need to inspect the payload. This is rare. Most tests need more detail.

Use assert_eq!(result, Err(expected)) when your error type implements PartialEq and you want to verify the exact error variant and data. This is the cleanest approach for custom error types.

Use match or if let with assertions when the error contains complex data that requires multiple checks or custom logic. This gives you full control over the inspection.

Use #[should_panic] when testing code that explicitly panics on invalid invariants, like array index out of bounds or debug assertions. Reserve this for bugs, not recoverable errors.

Use #[should_panic(expected = "...")] when you need to ensure the panic message matches a specific string, preventing false positives from unrelated panics. Always include the expected message.

Use expect_err("description") instead of unwrap_err() in tests to provide context when the assertion fails. The message helps diagnose why a test failed if the result was Ok instead of Err.

Test the error message. If the error changes, the test should scream.

Where to go next