Testing Rust Error Handling: anyhow, thiserror, and Error Propagation

Testing Rust Error Handling: anyhow, thiserror, and Error Propagation

Rust's error handling is excellent but undertested. Most Rust codebases test the happy path thoroughly and treat error paths as "it compiles, must be right." But error handling bugs — wrong error type returned, missing context in error messages, error chains that lose important information — cause real production incidents.

This guide covers how to test thiserror custom error types, anyhow error chains, and error propagation patterns.

What to Test in Error Handling

  • Correct error type — does parse_config return ConfigError::MissingField when a field is absent, not a generic IoError?
  • Error context — does the anyhow::Context chain include the file path, not just "parse error"?
  • Error propagation — does ? on an inner error produce the right outer error?
  • Display messages — do error messages say something useful to the user?
  • Error conversion — do From implementations convert correctly?

Testing thiserror Error Types

// src/errors.rs
use thiserror::Error;

#[derive(Error, Debug, PartialEq)]
pub enum ConfigError {
    #[error("missing required field: {field}")]
    MissingField { field: String },
    
    #[error("invalid value for field '{field}': expected {expected}, got '{got}'")]
    InvalidValue {
        field: String,
        expected: String,
        got: String,
    },
    
    #[error("config file not found at path: {path}")]
    FileNotFound { path: String },
    
    #[error("permission denied reading config: {path}")]
    PermissionDenied { path: String },
    
    #[error(transparent)]
    ParseError(#[from] serde_json::Error),
}
// tests/error_tests.rs
use myapp::errors::ConfigError;
use myapp::config::parse_config;

#[test]
fn test_missing_field_error_type() {
    let json = r#"{"port": 8080}"#;  // Missing required "host" field
    
    let result = parse_config(json);
    
    assert!(result.is_err());
    let err = result.unwrap_err();
    
    // Verify the specific error variant
    assert!(
        matches!(err, ConfigError::MissingField { field } if field == "host"),
        "Expected MissingField error for 'host', got: {:?}",
        err
    );
}

#[test]
fn test_invalid_value_error() {
    let json = r#"{"host": "localhost", "port": "not-a-number"}"#;
    
    let result = parse_config(json);
    
    let err = result.expect_err("Should fail on invalid port");
    assert!(
        matches!(err, ConfigError::InvalidValue { field, .. } if field == "port"),
        "Expected InvalidValue for 'port', got: {:?}",
        err
    );
}

#[test]
fn test_error_display_message() {
    let err = ConfigError::MissingField { field: "api_key".to_string() };
    
    assert_eq!(err.to_string(), "missing required field: api_key");
}

#[test]
fn test_error_is_send_sync() {
    fn assert_send_sync<T: Send + Sync>() {}
    assert_send_sync::<ConfigError>();
}

Testing Error Equality

thiserror errors are Debug but not automatically PartialEq. Derive it when needed for tests:

#[derive(Error, Debug, PartialEq)]
pub enum ValidationError {
    #[error("value out of range: {0} not in [{1}, {2}]")]
    OutOfRange(f64, f64, f64),
    
    #[error("required field missing")]
    RequiredFieldMissing,
}

#[test]
fn test_error_equality() {
    let err1 = ValidationError::OutOfRange(150.0, 0.0, 100.0);
    let err2 = ValidationError::OutOfRange(150.0, 0.0, 100.0);
    let err3 = ValidationError::OutOfRange(200.0, 0.0, 100.0);
    
    assert_eq!(err1, err2);
    assert_ne!(err1, err3);
}

Testing anyhow Error Chains

anyhow wraps errors with context. Test that context is preserved and meaningful:

// src/loader.rs
use anyhow::{Context, Result};
use std::path::Path;

pub fn load_config(path: &Path) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config file: {}", path.display()))?;
    
    serde_json::from_str(&content)
        .with_context(|| format!("Failed to parse config from {}", path.display()))
}
#[test]
fn test_anyhow_context_includes_path() {
    let result = load_config(Path::new("/nonexistent/config.json"));
    
    let err = result.expect_err("Should fail on missing file");
    
    // anyhow's error chain includes context messages
    let err_string = format!("{:#}", err);  // {:?} shows chain, {:#} shows pretty chain
    
    assert!(
        err_string.contains("/nonexistent/config.json"),
        "Error should include the file path: {}",
        err_string
    );
}

#[test]
fn test_anyhow_error_chain_has_source() {
    let result = load_config(Path::new("/nonexistent/config.json"));
    
    let err = result.expect_err("Should fail");
    
    // Verify there's a source error in the chain
    assert!(
        err.chain().count() >= 1,
        "Error chain should have at least one cause"
    );
}

#[test]
fn test_anyhow_downcasting() {
    let result: anyhow::Result<()> = Err(
        anyhow::Error::new(ConfigError::MissingField { field: "host".to_string() })
            .context("loading application config")
    );
    
    let err = result.unwrap_err();
    
    // Downcast to the original error type
    let config_err = err.downcast_ref::<ConfigError>();
    assert!(config_err.is_some());
    assert!(matches!(config_err.unwrap(), ConfigError::MissingField { field } if field == "host"));
}

Testing Error Propagation with ?

Test that the ? operator propagates errors correctly through layers:

// src/service.rs
use anyhow::Result;

pub fn initialize_service(config_path: &str) -> Result<Service> {
    let config = load_config(std::path::Path::new(config_path))  // Returns anyhow::Error
        .context("initializing service")?;
    
    let db = connect_database(&config.database_url)
        .context("connecting to database")?;
    
    Ok(Service { config, db })
}
#[test]
fn test_service_error_includes_step_name() {
    let result = initialize_service("/nonexistent/config.json");
    
    let err = result.expect_err("Should fail");
    let err_str = format!("{:#}", err);
    
    assert!(err_str.contains("initializing service"),
        "Error chain should include the step name: {}", err_str);
}

Testing From Conversions

thiserror's #[from] generates From implementations. Test them:

#[derive(Error, Debug)]
pub enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),
    
    #[error("config error: {0}")]
    Config(#[from] ConfigError),
}

#[test]
fn test_io_error_converts_to_app_error() {
    let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
    let app_err: AppError = io_err.into();
    
    assert!(matches!(app_err, AppError::Io(_)));
    assert!(app_err.to_string().contains("IO error"));
}

#[test]
fn test_config_error_converts_to_app_error() {
    let config_err = ConfigError::MissingField { field: "host".to_string() };
    let app_err: AppError = config_err.into();
    
    assert!(matches!(app_err, AppError::Config(ConfigError::MissingField { .. })));
}

Testing Error Handling in Async Code

use tokio::test;

#[tokio::test]
async fn test_async_error_propagation() {
    let result = async_load_config("/nonexistent").await;
    
    assert!(result.is_err());
    
    let err = result.unwrap_err();
    assert!(format!("{}", err).contains("nonexistent"));
}

#[tokio::test]
async fn test_async_retry_on_transient_error() {
    let client = MockClient::new()
        .with_failures(2)  // Fails first 2 times
        .with_success();   // Succeeds on 3rd
    
    let result = fetch_with_retry(&client, 3).await;
    
    assert!(result.is_ok());
    assert_eq!(client.call_count(), 3);
}

Testing Error Display vs. Debug

Rust errors have two representations: Display for end users, Debug for developers. Test both:

#[test]
fn test_error_display_is_user_friendly() {
    let err = ConfigError::MissingField { field: "api_key".to_string() };
    
    // Display should be readable
    let display = format!("{}", err);
    assert_eq!(display, "missing required field: api_key");
    assert!(!display.contains("ConfigError"));  // No Rust type names for users
    assert!(!display.contains("MissingField"));
}

#[test]
fn test_error_debug_includes_type_info() {
    let err = ConfigError::MissingField { field: "api_key".to_string() };
    
    // Debug should include type information
    let debug = format!("{:?}", err);
    assert!(debug.contains("MissingField"));
    assert!(debug.contains("api_key"));
}

Testing Panics vs. Errors

Some operations should panic on programmer error (invariant violation), not return Err:

#[test]
#[should_panic(expected = "index out of bounds")]
fn test_access_empty_vec_panics() {
    let v: Vec<i32> = vec![];
    let _x = v[0];  // Should panic, not return Result
}

#[test]
fn test_user_error_returns_result_not_panic() {
    // Bad user input should return Err, not panic
    let result = parse_user_age("-5");
    assert!(result.is_err());
    // Must not panic even on obviously bad input
}

CI Configuration

- name: Run Rust tests including error tests
  run: cargo test -- --nocapture 2>&1 | head -100
  # --nocapture shows println! output for debugging failures

Conclusion

Testing Rust error handling means testing the contract of each error path: the correct variant is returned, the error message is useful, context is preserved through the chain, and From conversions work correctly. Test Display output for user-facing messages and Debug for developer diagnostics. The investment pays off when you need to debug a production error and find a well-described error chain instead of "an error occurred."

Read more

Start now free