Collapsing every failure into a String is convenient until a caller needs to tell a missing file from a malformed port. The text may still include the underlying message, but the concrete error identity and traversable source chain are gone. Calling panic! everywhere has the opposite problem: a recoverable input error now terminates the process. Error handling starts with boundaries, not wording.
The example in this guide reads and parses a configuration file. It implements Display, std::error::Error, and source() with only the standard library, then traces where ? propagates or converts an error. Unit tests, integration tests, negative cases, and file fixtures exercise the same code. This is not an argument against thiserror. Learn what its macros generate, then add the crate when the repetition is real.
1. Recoverable Failures vs. Bugs
Errors fall into recoverable and unrecoverable categories. A missing configuration file, invalid port, or temporary network failure can be reported, retried, or handled by the caller, so Result<T, E> fits. If an array index violates an internal invariant or execution reaches a branch that should be impossible, panic!, assert!, or unreachable! can expose a programming bug.
Rarity is not the distinction. Bad user input may be unusual, but it can occur during normal operation. The example therefore returns every file and parse failure as a Result. Tests use expect to stop a failed test with a useful message; that does not justify replacing recoverable library paths with expect.
2. Error Types for Machines and People
ConfigError retains data for each kind of failure. Read stores a path and an io::Error. InvalidNumber stores the line, key, original value, and ParseIntError.
#[derive(Debug)]
pub enum ConfigError {
Read {
path: PathBuf,
source: io::Error,
},
InvalidLine {
line: usize,
text: String,
},
InvalidNumber {
line: usize,
key: &'static str,
value: String,
source: ParseIntError,
},
MissingKey(&'static str),
}
A caller can match on the variants. Display supplies the message for a CLI or log. Keep that job distinct from Debug: Debug exposes developer-oriented structure, while Display describes one layer concisely.
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read { path, .. } => {
write!(formatter, "failed to read configuration {}", path.display())
}
Self::InvalidLine { line, text } => {
write!(formatter, "invalid configuration at line {line}: {text:?}")
}
Self::InvalidNumber {
line, key, value, ..
} => write!(formatter, "invalid {key} value {value:?} at line {line}"),
Self::MissingKey(key) => write!(formatter, "missing required key {key:?}"),
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::InvalidNumber { source, .. } => Some(source),
Self::InvalidLine { .. } | Self::MissingKey(_) => None,
}
}
}
source() does not concatenate messages. It exposes the lower-level error that caused the current one. ConfigError::Read owns the file-path context, while io::Error keeps the operating-system error kind and message. Parsing follows the same pattern. A CLI can walk the chain, and a test can inspect a stable value such as io::ErrorKind::NotFound.
InvalidLine and MissingKey have no source because the example discovers those conditions itself. There is no reason to manufacture a cause, and error variants do not need chains of identical depth.
3. ? and Error Context
When ? sees Err, it returns early from the current function. If the surrounding function has another error type, it also attempts a conversion through From. The developer still decides what context must survive.
pub fn load_settings(path: impl AsRef<Path>) -> Result<Settings, ConfigError> {
let path = path.as_ref();
let text = fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
parse_settings(&text)
}
The example deliberately omits a blanket From<io::Error> for ConfigError. Automatic conversion alone would have nowhere to put the path that failed. At the file I/O boundary, map_err adds that path and ? returns the enriched error. Explicit conversion is the better choice when a boundary must add context.
At the application boundary, wrapping every configuration error can be lossless. AppError retains the complete ConfigError as its source, so From and ? fit cleanly.
impl From<ConfigError> for AppError {
fn from(source: ConfigError) -> Self {
Self { source }
}
}
pub fn load_for_app(path: impl AsRef<Path>) -> Result<Settings, AppError> {
Ok(load_settings(path)?)
}
Ask whether a conversion loses information. If the path, line number, original value, and nested cause remain available, an outer error type can group the failure without weakening diagnosis. A .to_string() value may retain the nested error's wording, but it discards the variant identity and structured access to the source chain too early.
4. When to Add an Error Crate
The manual implementation exposes the mechanism, but more variants mean more repetitive Display, Error::source, and From code. A crate such as thiserror is useful because derive macros and attributes generate that plumbing, identify source fields, and create selected From implementations. Once a codebase has several stable error enums, it can cut maintenance work.
The choice is not about manual code being purer. Define the public error structure first, then weigh repetition, macro dependencies, compile time, and team familiarity. A dynamic reporting crate with context helpers may suit the top of an application. A library boundary may benefit more from a concrete error type that callers can match. Either way, avoid erasing the original cause into a string.
5. Unit and Integration Tests
Unit tests live in a #[cfg(test)] module inside src/lib.rs. They can call the private parse_settings function, which makes them a good fit for small rules such as line counting, unknown keys, and missing required fields.
#[test]
fn reports_the_line_and_value_for_an_invalid_port() {
let error = parse_settings("host=localhost\nport=nope\nretries=2\n")
.expect_err("invalid port should fail");
assert_eq!(error.to_string(), "invalid port value \"nope\" at line 2");
assert!(error.source().is_some());
}
Integration tests under tests/ use only the public API, as an external crate would. They check that file I/O and parsing work together and that public errors retain the promised data. Because they do not call implementation details, internal refactors disturb them less often.
#[test]
fn invalid_fixture_preserves_parse_error_as_its_source() {
let error = load_settings(fixture("invalid-port.conf"))
.expect_err("invalid fixture should return an error");
assert!(matches!(
error,
ConfigError::InvalidNumber {
line: 2,
key: "port",
ref value,
..
} if value == "not-a-number"
));
assert!(error.source().is_some());
}
The two layers need not repeat every input. Unit tests cover parser branches closely. Integration tests focus on the file boundary and the public contract.
6. Durable Negative Tests and Fixtures
A negative test that checks only is_err() says little. The example verifies that an invalid port is reported on line 2, retains its input value, and exposes the nested parse error. The missing-file test inspects the stored path and io::ErrorKind::NotFound rather than matching the complete OS message. That asserts more of the contract while avoiding platform-specific wording.
The files are split into fixtures/valid.conf, fixtures/invalid-port.conf, and fixtures/missing-host.conf. Integration tests build paths from env!("CARGO_MANIFEST_DIR") instead of assuming a current working directory. The same fixtures are found whether cargo test starts at the project root or in the crate directory.
Use #[should_panic] when panic behavior is itself the contract. For invalid configuration whose contract is Result::Err, expect_err, variant matching, and source checks are more precise. A lesson about rejected APIs could add compile-fail fixtures or doctests. This example makes no compiler-diagnostic claim, so its negative cases are executed runtime error tests.
7. Standalone Example
The complete Rust 2024 project is at examples/article-17-errors-testing from the project directory. It has no external dependencies.
cd examples/article-17-errors-testing
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
All five commands should exit with status 0 on Rust 1.98.1 and Cargo 1.98.1. The suite contains four unit tests and three integration tests. The integration-test section is:
running 3 tests
test loads_the_valid_fixture_through_the_public_api ... ok
test invalid_fixture_preserves_parse_error_as_its_source ... ok
test missing_file_keeps_the_path_and_io_error ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The binary prints one line:
api.example.com:443 (retries=3)
Test count matters less than assigning a job to each layer. Unit tests own parser rules. Integration tests own the public API and real file boundary. Negative tests pin down the error variant, context, and source chain. With those contracts in place, the team can decide whether to keep the manual implementation or let a crate remove the repetition.
Full source code
The complete runnable source for this article is available in the Chapter 17 project on GitHub.
Sources
- The Rust Programming Language: Error Handling
- The Rust Programming Language: Recoverable Errors with Result
- The Rust Programming Language: To panic! or Not to panic!
- Rust standard library:
std::error::Error - Rust standard library:
Display - Rust standard library:
From - The Rust Programming Language: How to Write Tests
- The Rust Programming Language: Test Organization
- The Cargo Book: Tests
Leave a Reply