# Learning Rust #4 — Errors, Modules, and Testing
Table of Contents
As an experienced dev, I want errors that carry context, modules that don’t sprawl, and tests that I’ll actually keep running. Rust gives you all three with low ceremony once you embrace a few idioms.
Goals for Part 4
- Handle errors ergonomically with
Resultand the?operator - Know when to use
anyhow(apps/CLIs) vsthiserror(libraries) - Structure modules for clarity and minimal
pubsurface - Write unit, integration, and doc tests without friction
Start simple: Result<T, E> and ?
use std::fs;use std::io;
fn read_whole(path: &str) -> Result<String, io::Error> { let data = fs::read_to_string(path)?; // bubbles up io::Error Ok(data)}? expands to “if this is Err, return early; otherwise unwrap the value”. It keeps the happy path visible.
Mapping errors:
fn read_first_line(path: &str) -> Result<String, String> { let txt = std::fs::read_to_string(path).map_err(|e| e.to_string())?; Ok(txt.lines().next().unwrap_or("").to_string())}anyhow for binaries (apps/CLIs)
When you’re building an application with many error types, reach for anyhow. It erases concrete error types into a single anyhow::Error and lets you layer context.
use anyhow::{Context, Result};
fn load_cfg(path: &str) -> Result<String> { let txt = std::fs::read_to_string(path) .with_context(|| format!("failed to read config at {path}"))?; Ok(txt)}
fn parse_port(s: &str) -> Result<u16> { let p: u16 = s.trim().parse() .with_context(|| format!("invalid port: {s}"))?; Ok(p)}Signature stays clean (Result<T>), and your top-level main can just print a pretty chain:
fn main() -> Result<()> { let cfg = load_cfg("app.toml")?; let _port = parse_port("8080")?; println!("ok"); Ok(())}thiserror for libraries (reusable crates)
When publishing a library, you usually want a stable error type that downstream users can match on. thiserror derives that boilerplate.
use thiserror::Error;
#[derive(Debug, Error)]pub enum AppError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("invalid port: {0}")] InvalidPort(u16),}
pub type Result<T> = std::result::Result<T, AppError>;
pub fn parse_port(s: &str) -> Result<u16> { let p: u16 = s.trim().parse().map_err(|_| AppError::InvalidPort(0))?; Ok(p)}Consumers get exhaustiveness and pattern matching, you keep control over your error surface.
Rule of thumb: Apps use
anyhowinternally; libraries expose concrete errors withthiserror.
Organizing modules that scale
A little structure goes a long way.
src/├─ main.rs # thin binary entrypoint├─ lib.rs # reusable core (testable without I/O)├─ config/│ ├─ mod.rs # re-exports│ └─ loader.rs # reading/parsing config└─ domain/ └─ mod.rs # pure logicsrc/lib.rs (make the binary thin and your core reusable):
pub mod config;pub mod domain;
pub use config::load_config; // re-export for a tidy APIsrc/config/mod.rs (re-export submodules):
pub mod loader;pub use loader::load_config;src/config/loader.rs (I/O + context):
use anyhow::{Context, Result};
pub fn load_config(path: &str) -> Result<String> { let txt = std::fs::read_to_string(path) .with_context(|| format!("reading {path}"))?; Ok(txt)}src/main.rs stays tiny:
use anyhow::Result;use learning_core::load_config; // assume crate name learning_core
fn main() -> Result<()> { let cfg = load_config("app.toml")?; println!("{} bytes", cfg.len()); Ok(())}Keep I/O at the edges (config, HTTP, DB) and keep domain logic pure so it’s trivial to unit‑test.
Tests you’ll actually run
Unit tests (inside modules)
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]mod tests { use super::*;
#[test] fn adds_numbers() { assert_eq!(add(2, 2), 4); }}Run all:
cargo testIntegration tests (/tests directory)
Anything under tests/ is compiled as a separate crate and links your library externally—great for testing public API.
tests/ └─ smoke.rsuse learning_core::add;
#[test]fn smoke_add() { assert_eq!(add(1, 2), 3);}Doctests (examples in docs)
Examples in /// comments are compiled and run:
/// Add two numbers.////// ```/// use learning_core::add;/// assert_eq!(add(2, 2), 4);/// ```pub fn add(a: i32, b: i32) -> i32 { a + b }Testing errors cleanly
When functions return anyhow::Result, your test can use ? too:
#[test]fn loads_config() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let p = dir.path().join("cfg.toml"); std::fs::write(&p, "key=value\n")?; let txt = crate::config::load_config(p.to_str().unwrap())?; assert!(txt.contains("key")); Ok(())}For library errors, assert exact variants:
#[test]fn invalid_port_is_error() { let err = crate::parse_port("not a number").unwrap_err(); match err { crate::AppError::InvalidPort(_) => (), _ => panic!("wrong error") }}Mini-project: A tiny config crate you can test
We’ll parse a file with lines like host = example.com and port = 8080 into a struct. I/O and parsing are separate; parsing is pure (easy to test).
use anyhow::{Context, Result};use std::collections::HashMap;
pub fn load_config(path: &str) -> Result<HashMap<String, String>> { let txt = std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?; Ok(parse_kv(&txt))}
fn parse_kv(input: &str) -> HashMap<String, String> { input .lines() .filter_map(|line| line.split_once('=')) .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) .collect()}
#[cfg(test)]mod tests { use super::*;
#[test] fn parses_pairs() { let m = parse_kv("host = example.com\nport=8080\n"); assert_eq!(m.get("host").unwrap(), "example.com"); assert_eq!(m.get("port").unwrap(), "8080"); }}This splits I/O (load_config) from pure logic (parse_kv). In larger systems, extend this with a typed Config struct and validation errors via thiserror.
Practical checklists
Error handling
- Prefer
Result<T, E>+?to deepmatchnesting - In apps: use
anyhow::{Result, Context}for quick wins - In libraries: define a small
Errorenum withthiserror - Attach human context at boundaries (file paths, URLs, user input)
Modules
- Keep
main.rsthin; push logic intolib.rs - Re‑export with
pub usefor a tidy public API - Minimize
pub; start private, expose as needed
Testing
- Unit tests for small, pure helpers
- Integration tests for public behavior
- Doctests for copy‑paste‑ready examples
Exercises (15–25 minutes)
- Context ladder: Add path + line number context to a CSV reader using
with_contextat each boundary (open → read → parse). - Typed config: Replace the
HashMap<String, String>with aConfig { host: String, port: u16 }that returns athiserror‑backed error on invalid/missing fields. - Integration test: Create
tests/config_end_to_end.rsthat writes a temp file and asserts thatload_configreturns a typedConfig. - Doc test: Add
///examples toparse_kvand ensurecargo testruns them.
What stuck today
anyhowaccelerates application code by hiding heterogenous errors and letting me stack context.thiserrorkeeps library surfaces precise and matchable.- Thin binaries + pure cores = easier tests and reuse.
Next up (Part 5): shipping a real CLI end‑to‑end with args, files, HTTP, concurrency, logging, and a neat release build.