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_configreturnConfigError::MissingFieldwhen a field is absent, not a genericIoError? - Error context — does the
anyhow::Contextchain 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
Fromimplementations 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 failuresConclusion
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."