# 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.

Github

Goals for Part 4

  • Handle errors ergonomically with Result and the ? operator
  • Know when to use anyhow (apps/CLIs) vs thiserror (libraries)
  • Structure modules for clarity and minimal pub surface
  • 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 anyhow internally; libraries expose concrete errors with thiserror.


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 logic

src/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 API

src/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)

src/domain/mod.rs
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:

Terminal window
cargo test

Integration tests (/tests directory)

Anything under tests/ is compiled as a separate crate and links your library externally—great for testing public API.

tests/
└─ smoke.rs
tests/smoke.rs
use 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).

src/config/loader.rs
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 deep match nesting
  • In apps: use anyhow::{Result, Context} for quick wins
  • In libraries: define a small Error enum with thiserror
  • Attach human context at boundaries (file paths, URLs, user input)

Modules

  • Keep main.rs thin; push logic into lib.rs
  • Re‑export with pub use for 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)

  1. Context ladder: Add path + line number context to a CSV reader using with_context at each boundary (open → read → parse).
  2. Typed config: Replace the HashMap<String, String> with a Config { host: String, port: u16 } that returns a thiserror‑backed error on invalid/missing fields.
  3. Integration test: Create tests/config_end_to_end.rs that writes a temp file and asserts that load_config returns a typed Config.
  4. Doc test: Add /// examples to parse_kv and ensure cargo test runs them.

What stuck today

  • anyhow accelerates application code by hiding heterogenous errors and letting me stack context.
  • thiserror keeps 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.

Next: Learning Rust #5 — Shipping a Real CLI (Args, Files, HTTP, Concurrency)
My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


Learning Rust Series

Comments