Learn Rust with Tests
Learn Rust by writing tests, guided by the principles of TDD.
This book is inspired by Learn Go with Tests. The idea is simple: learn Rust incrementally, one small test at a time. Each chapter introduces a language concept or technique and uses Test-Driven Development to explore it.
You don't need to be an experienced programmer, but you should be comfortable with basic concepts: variables, functions, conditionals. Some experience with another language will help.
What you'll need
- A computer with an internet connection
- Rust installed
- A text editor or IDE
- A terminal
How this book works
Each chapter in the Rust Fundamentals section introduces a concept through the TDD cycle:
- Write a failing test
- Write the minimal code to make it pass
- Refactor
The Principles section describes the ideas behind this approach. You can read them first or return to them as reference as you work through the chapters.
Let's start with Hello, World.
Hello, World
You can find all the code for this chapter here
It is traditional for your first program in a new language to be Hello, World.
In Rust, we use cargo to create and manage projects. Run the following:
cargo new hello-world
cd hello-world
cargo is Rust's build tool and package manager. cargo new creates a project with a standard structure. Have a look at what it generated:
src/main.rs
Cargo.toml
Cargo.toml describes your project — its name, version, and dependencies. src/main.rs is where your code lives. Open it and you'll see:
fn main() { println!("Hello, world!"); }
Run it:
cargo run
Hello, world!
Two things worth noting before we move on.
println! is a macro, not a function. The ! is the giveaway. For now, treat it as "print this to the terminal with a newline".
fn main() is the entry point of every Rust program. Execution starts here.
How to test
How do you test this? The problem is that println! is a side effect — it prints to the terminal, which is the outside world. Side effects are hard to test.
The better approach is to separate what we want to say from the act of saying it. The greeting logic belongs in a pure function that returns a value. The printing belongs in main.
This is a pattern we'll return to throughout this book. See Separating Concerns for the deeper reasoning.
Write the test first
Add the following to the bottom of src/main.rs:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_greet() { assert_eq!(greet(), "Hello, World!"); } } }
Don't write greet yet. Run the tests:
cargo test
warning: unused import: `super::*`
error[E0425]: cannot find function `greet` in this scope
--> src/main.rs:11:20
|
11 | assert_eq!(greet(), "Hello, World!");
| ^^^^^ not found in this scope
This is the red step. The compiler is telling you exactly what's missing. There's also a warning about the unused use super::* import — once greet exists, that import will be used and the warning will disappear.
A few new concepts
#[cfg(test)] tells the compiler to only include this module when running tests. The test code doesn't end up in your production binary.
mod tests creates a module — a namespace — for our tests. The name tests is conventional.
use super::* brings everything from the parent module into scope, so the test can see greet once we define it.
#[test] marks a function as a test. cargo test finds and runs all functions marked this way.
assert_eq! checks that two values are equal and fails the test with a clear message if they aren't.
Make it pass
Add the greet function above main, and update main to use it:
fn main() { println!("{}", greet()); } fn greet() -> String { format!("Hello, World!") }
-> String declares that the function returns an owned String. format! works like println! but returns a String instead of printing — exactly the separation we wanted.
Run the tests:
cargo test
running 1 test
test tests::test_greet ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. And fast. A tight feedback loop matters — you want running tests to feel like no effort at all.
This is a good moment to commit before we add the next requirement.
Hello, YOU
Now that we have a test, we can iterate safely.
Our next requirement: the greeting should address a specific person. Let's write the test first:
#![allow(unused)] fn main() { #[test] fn greets_a_person_by_name() { assert_eq!(greet("Alice"), "Hello, Alice!"); } }
Run the tests:
error[E0061]: this function takes 0 arguments but 1 argument was supplied
--> src/main.rs:20:20
|
20 | assert_eq!(greet("Alice"), "Hello, Alice!");
| ^^^^^ ------- unexpected argument of type `&'static str`
The compiler has done our TODO list for us. greet needs to accept a name. Let's add the parameter — but do the minimum: just change the signature, don't use name yet:
#![allow(unused)] fn main() { fn greet(name: &str) -> String { format!("Hello, World!") } }
&str is a string slice — the standard type for borrowed string data in Rust. When a function just needs to read a string, &str is usually the right choice. We'll explore Rust's string types properly in a later chapter.
Run the tests:
error[E0061]: this function takes 1 argument but 0 arguments were supplied
--> src/main.rs:2:20
|
2 | println!("{}", greet());
| ^^^^^-- argument #1 of type `&str` is missing
error[E0061]: this function takes 1 argument but 0 arguments were supplied
--> src/main.rs:15:20
|
15 | assert_eq!(greet(), "Hello, World!");
| ^^^^^-- argument #1 of type `&str` is missing
warning: unused variable: `name`
Two things to notice. First: adding the parameter broke two places — main and the first test — both of which were calling greet() with no arguments. Second: Rust warns you about the unused name variable. It won't let you silently ignore things. Fix both call sites by passing "World" for now:
fn main() { println!("{}", greet("World")); }
#![allow(unused)] fn main() { #[test] fn test_greet() { assert_eq!(greet("World"), "Hello, World!"); } }
Run the tests again:
warning: unused variable: `name`
test tests::test_greet ... ok
test tests::greets_a_person_by_name ... FAILED
---- tests::greets_a_person_by_name stdout ----
assertion `left == right` failed
left: "Hello, World!"
right: "Hello, Alice!"
It compiles. The first test passes, the second tells us exactly what's wrong. Make it pass by using name:
#![allow(unused)] fn main() { fn greet(name: &str) -> String { format!("Hello, {}!", name) } }
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Both green, warning gone. But look at the first test — it's passing "World" as an argument. That works, but it feels off. We're not really testing that the function greets a person named "World" — we're using it as a stand-in for "no name given". That intent isn't expressed anywhere. Let's make it explicit.
Default behaviour
Update the first test to pass an empty string instead, and rename it to reflect the intent:
#![allow(unused)] fn main() { #[test] fn greets_world_by_default() { assert_eq!(greet(""), "Hello, World!"); } }
Run the tests:
test tests::greets_world_by_default ... FAILED
---- tests::greets_world_by_default stdout ----
assertion `left == right` failed
left: "Hello, !"
right: "Hello, World!"
Good — a clear failure. Now make it pass:
#![allow(unused)] fn main() { fn greet(name: &str) -> String { let name = if name.is_empty() { "World" } else { name }; format!("Hello, {}!", name) } }
A couple of things here:
let name = ... introduces a new binding that shadows the parameter. Rust allows — and encourages — this rather than mutating a variable.
if name.is_empty() { "World" } else { name } — in Rust, if is an expression, not just a statement. It produces a value. The whole line evaluates to either "World" or the original name.
Run the tests:
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
All green. Commit.
One more thing: Option
The empty string approach works, but it's a workaround. We're using "" to signal "no value given" — but an empty string is a valid string. We're abusing it as a sentinel. Rust has a proper way to express "this value may or may not be present": Option.
Update both tests:
#![allow(unused)] fn main() { #[test] fn greets_world_by_default() { assert_eq!(greet(None), "Hello, World!"); } #[test] fn greets_a_person_by_name() { assert_eq!(greet(Some("Alice")), "Hello, Alice!"); } }
Run the tests:
error[E0308]: mismatched types
--> src/main.rs:16:26
|
16 | assert_eq!(greet(None), "Hello, World!");
| ----- ^^^^ expected `&str`, found `Option<_>`
error[E0308]: mismatched types
--> src/main.rs:21:26
|
21 | assert_eq!(greet(Some("Alice")), "Hello, Alice!");
| ----- ^^^^^^^^^^^^^ expected `&str`, found `Option<&str>`
Both tests fail to compile — the function still expects &str. Update the signature:
#![allow(unused)] fn main() { fn greet(name: Option<&str>) -> String { let name = name.unwrap_or("World"); format!("Hello, {}!", name) } }
Option<&str> is Rust's way of saying "a string that may or may not be there". It has two variants: Some("Alice") when a value is present, and None when it isn't. There's no null, no empty string as a signal — the absence of a value is encoded in the type itself.
unwrap_or("World") extracts the value from Some, or returns "World" if it's None. This replaces the entire is_empty check with something that expresses the intent directly.
Run the tests:
error[E0308]: mismatched types
--> src/main.rs:2:26
|
2 | println!("{}", greet("World"));
| ----- ^^^^^^^ expected `Option<&str>`, found `&str`
|
help: try wrapping the expression in `Some`
|
2 | println!("{}", greet(Some("World")));
main also needs updating — and the compiler even tells us how. Since we want to show the default behaviour, pass None:
fn main() { println!("{}", greet(None)); }
Run the tests:
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
All green. The code is cleaner, the intent is clearer, and we've removed the empty string workaround entirely. Commit.
Wrapping up
We've covered a lot of ground in a small program.
Rust concepts introduced
cargo new— create a projectfn— declare a function&strandString— Rust's two main string types (more on this later)format!,println!— macros for working with stringsifas an expression- Variable shadowing with
let Option<T>— representing a value that may or may not be presentunwrap_or— extracting a value from anOptionwith a fallback
Testing concepts
#[cfg(test)]andmod tests— how Rust isolates test code#[test]— marking a function as a testassert_eq!— asserting equality- The value of watching a test fail before making it pass
The TDD process
We followed the cycle deliberately: write a failing test, write the minimum code to make it pass, refactor. Notice how the compiler errors guided each step — in Rust, the compiler is a collaborator, not an obstacle. Learning to read what it's telling you is one of the most valuable things you can do early on.
See The TDD Cycle for more on why each step matters.
Further reading
- Functions — parameters, return types, and how Rust functions differ from what you may be used to
Stringvs&str— ownership explains why Rust has two string types; this chapter makes it clickOption<T>— why Rust has nonull, and howOptionmodels the absence of a value safely
Integers
Requirement: We want an add function that adds two integers together.
A library crate
So far we've used cargo new to create a binary crate — a program with a main function. For this chapter we want a library: reusable code without a main. The flag is --lib:
cargo new --lib integers
Open integers/src/lib.rs. You'll notice Cargo has scaffolded something more opinionated than main.rs — it generates an add stub and a passing test out of the box:
#![allow(unused)] fn main() { pub fn add(left: u64, right: u64) -> u64 { left + right } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let result = add(2, 2); assert_eq!(result, 4); } } }
Convenient, but we're going to follow the journey ourselves. Replace the file contents with just the test — no implementation yet:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn add_two_integers() { assert_eq!(add(2, 2), 4); } } }
Write the test first
Run cargo test:
warning: unused import: `super::*`
--> src/lib.rs:3:9
|
3 | use super::*;
| ^^^^^^^^
error[E0425]: cannot find function `add` in this scope
--> src/lib.rs:7:20
|
7 | assert_eq!(add(2, 2), 4);
| ^^^ not found in this scope
|
help: use the `.` operator to call the method `Add::add` on `{integer}`
|
7 - assert_eq!(add(2, 2), 4);
7 + assert_eq!(2.add(2), 4);
add doesn't exist yet — exactly what we want. The warning about use super::* will disappear once there's something in the parent module to import.
Notice the compiler's suggestion: 2.add(2). That's pointing at the Add trait from the standard library. Traits are a major topic for later — ignore the hint for now and write the function ourselves.
Make it pass
Add add above the test module:
#![allow(unused)] fn main() { pub fn add(a: i32, b: i32) -> i32 { a + b } }
Run cargo test:
running 1 test
test tests::add_two_integers ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests integers
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. Two things to note here.
First, the type is i32 — a 32-bit signed integer. Rust has a full family: i8, i16, i32, i64, i128 (signed) and u8, u16, u32, u64, u128 (unsigned). i32 is the default when Rust infers an integer type and has no reason to prefer otherwise.
Unlike some languages, Rust will not silently coerce between integer types. Pass an i64 where i32 is expected and the compiler rejects it — you have to convert explicitly. This is deliberate, and the compiler will tell you exactly what to do.
Second, notice every parameter has its own type annotation: a: i32, b: i32. There's no shorthand for consecutive parameters of the same type — Rust is explicit everywhere.
Commit.
Documenting our code
Rust has a built-in documentation tool: cargo doc. It generates HTML docs from your source — the same tool used for the standard library. The interesting part is that examples in your doc comments are compiled and run as tests.
Documentation comments use /// (three slashes). Add one above add:
#![allow(unused)] fn main() { /// Adds two integers together. /// /// # Examples /// /// ``` /// assert_eq!(integers::add(2, 2), 4); /// ``` pub fn add(a: i32, b: i32) -> i32 { }
Run cargo test and look at the output:
running 1 test
test tests::add_two_integers ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests integers
running 1 test
test src/lib.rs - add (line 5) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
all doctests ran in 0.49s; merged doctests compilation took 0.24s
Doc-tests integers now shows 1 test passing — the example from the /// comment. This is one of Rust's better ideas: documentation that goes stale breaks the build. Change add and forget to update the example, and cargo test will catch it.
Run cargo doc --open to build and view the docs:
Documenting integers v0.1.0 (/path/to/integers)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
Generated /path/to/integers/target/doc/integers/index.html
You'll see the description and example rendered exactly like the standard library docs — because it's the same tool.
Commit.
A note on overflow
What happens when the result of an addition doesn't fit in i32? In debug builds — which is what cargo test and cargo run use — Rust panics. The program crashes with an explicit error rather than silently wrapping around. In release builds (cargo build --release) it wraps.
You can also opt in to explicit behaviour: .checked_add() returns None on overflow, .saturating_add() clamps to the maximum value, .wrapping_add() always wraps. For now, just know that Rust doesn't silently discard the problem.
Wrapping up
Rust concepts introduced
cargo new --lib— library crates vs binary cratesi32and the integer type family (i8–i128,u8–u128)- No silent coercion between integer types
///doc commentscargo doc— generating HTML documentation from source- Doc-tests — examples in
///comments thatcargo testcompiles and runs - Overflow behaviour: panic in debug builds, wrap in release
Testing concepts
- Doc-tests are tests — examples that go out of date will fail the build
cargo testruns both unit tests and doc-tests automatically
See The TDD Cycle for more on why each step matters.
Further reading
- Integer types — the full family of signed and unsigned types, when to use each
- Integer overflow — why debug and release builds behave differently, and what to do when you care
Strings and Iteration
Requirement: We want a repeat function that takes a string and a count, and returns the string repeated that many times. For example, repeat("na", 4) returns "nananana".
Write the test first
Create a new library crate:
cargo new --lib strings-and-iteration
Replace the contents of strings-and-iteration/src/lib.rs with just the test:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn repeat_a_string() { assert_eq!(repeat("na", 4), "nananana"); } } }
Run cargo test:
error[E0425]: cannot find function `repeat` in this scope
--> src/lib.rs:7:20
|
7 | assert_eq!(repeat("na", 4), "nananana");
| ^^^^^^ not found in this scope
|
help: consider importing one of these functions
|
3 + use std::array::repeat;
|
3 + use std::io::repeat;
|
3 + use std::iter::repeat;
|
3 + use core::array::repeat;
|
= and 1 other candidate
warning: unused import: `super::*`
--> src/lib.rs:3:9
|
3 | use super::*;
| ^^^^^^^^
The compiler can't find repeat — and it's helpfully suggesting four alternatives from the standard library. Worth noting: std::iter::repeat is a real thing — an infinite iterator that repeats a value forever. Useful, but not what we want. We're building our own.
Ignore all four suggestions.
Make it pass
Add repeat above the test module:
#![allow(unused)] fn main() { pub fn repeat(s: &str, n: usize) -> String { let mut result = String::new(); for _ in 0..n { result.push_str(s); } result } }
Run cargo test:
running 1 test
test tests::repeat_a_string ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. There's a lot happening in a few lines — let's unpack it.
String::new() creates an empty, owned, heap-allocated string. let mut result — the mut is new. In Rust, variables are immutable by default. Without mut, you can't change result after it's created. Try removing it and the compiler will tell you:
error[E0596]: cannot borrow `result` as mutable, as it is not declared as mutable
for _ in 0..n — 0..n is a range, producing the values 0, 1, 2... up to but not including n. The _ means "I don't need the loop variable" — we just want to run the body n times.
push_str appends a &str to a String in place.
The last line — result with no semicolon — is the implicit return. We've seen this pattern before.
n: usize — why usize and not i32? Because usize is Rust's type for counts and indices: things that can't be negative. It's also what ranges produce. Using i32 would work but feel off, and you'd hit type mismatches in certain contexts.
Commit.
String vs &str
We've been using &str and String since Hello World without a proper explanation. Now that we're building one, it's time.
&str is a string slice — a reference to some string data that lives somewhere else. String literals like "na" are &str; the data is baked into the binary. You can read a &str but you can't grow or modify it.
String is an owned string — heap-allocated, growable, and owned by whoever holds it. When a String goes out of scope, the memory is freed.
The function signature makes this concrete:
#![allow(unused)] fn main() { pub fn repeat(s: &str, n: usize) -> String }
The parameter is &str — we're borrowing the input, just reading it. The return type is String — we're creating something new and handing ownership to the caller. We couldn't return &str here because the string we're building doesn't exist anywhere before this function runs; there's nothing to reference.
This distinction — borrowed vs owned — runs through all of Rust. We'll keep coming back to it.
Refactor
The naive loop works, but the standard library already has what we need. &str has a repeat method that does exactly this:
#![allow(unused)] fn main() { pub fn repeat(s: &str, n: usize) -> String { s.repeat(n) } }
Run cargo test:
running 1 test
test tests::repeat_a_string ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Same test, one line. Notice the compiler's four suggestions earlier didn't include this — repeat on &str lives in std::str, not in a separate module you'd import. It's just there on the type. This is worth knowing: when you find yourself writing a loop to accumulate a string, there's often a method that already does it. The standard library is worth getting familiar with.
Commit.
Wrapping up
Rust concepts introduced
Stringvs&str— owned heap-allocated string vs borrowed string reference; you build withString, you borrow with&strlet mut— variables are immutable by default;mutopts in to mutationString::new()andpush_str— building a string incrementallyfor _ in 0..n— for loops and rangesusize— the type for counts and indices; can't be negative- Implicit return —
resultwith no semicolon returns the value
Testing concepts
- Tests drive you to the right types — the return type
Stringvs&strwas forced by the requirement to build something new
Coming up
We wrote the loop manually first, then replaced it with a standard library method. There's a whole family of tools in Rust for working with iterators — map, filter, collect and more. We'll get to those in a later chapter.
See The TDD Cycle for more on why each step matters.
Further reading
Stringvs&str— ownership explains why Rust has two string types; this chapter makes it click- Control flow —
forloops, ranges, and the iterator pattern that underpins them
Structs and Methods
Requirement: We want some geometry code to calculate the perimeter and area of shapes.
Perimeter
Create a new library crate:
cargo new --lib structs
Write this test in structs/src/lib.rs:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn perimeter_of_rectangle() { let got = perimeter(10.0, 10.0); assert_eq!(got, 40.0); } } }
Run cargo test:
error[E0425]: cannot find function `perimeter` in this scope
--> src/lib.rs:7:19
|
7 | let got = perimeter(10.0, 10.0);
| ^^^^^^^^^ not found in this scope
warning: unused import: `super::*`
--> src/lib.rs:3:9
|
3 | use super::*;
| ^^^^^^^^
Add the function above the test module:
#![allow(unused)] fn main() { pub fn perimeter(width: f64, height: f64) -> f64 { 2.0 * (width + height) } }
f64 is Rust's 64-bit floating point type — the right choice for geometry. There's also f32 but f64 is the default and you'd need a specific reason to prefer the smaller type.
Run cargo test:
running 1 test
test tests::perimeter_of_rectangle ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Commit.
Area
You have everything you need. Write a test for area(width, height) returning width × height, make it pass, and commit.
Introducing Rectangle
Look at the two functions:
#![allow(unused)] fn main() { pub fn perimeter(width: f64, height: f64) -> f64 { ... } pub fn area(width: f64, height: f64) -> f64 { ... } }
There's a problem here. Nothing stops a caller from passing the dimensions of a triangle and getting a wrong answer. The types don't encode the intent — two bare f64 values could be anything.
We can fix this by defining our own type called Rectangle that makes the intent explicit. A struct is a named type with fields:
#![allow(unused)] fn main() { pub struct Rectangle { pub width: f64, pub height: f64, } }
pub on the struct makes it visible outside this module. pub on each field is also required — without it, code outside the module couldn't read r.width or r.height.
Now update the tests to use Rectangle instead of bare floats:
#![allow(unused)] fn main() { #[test] fn perimeter_of_rectangle() { let r = Rectangle { width: 10.0, height: 10.0 }; let got = perimeter(r); assert_eq!(got, 40.0); } #[test] fn area_of_rectangle() { let r = Rectangle { width: 10.0, height: 10.0 }; let got = area(r); assert_eq!(got, 100.0); } }
Rectangle { width: 10.0, height: 10.0 } is a struct literal — you name each field. Run cargo test:
error[E0422]: cannot find struct, variant or union type `Rectangle` in this scope
15 | let r = Rectangle { width: 10.0, height: 10.0 };
error[E0422]: cannot find struct, variant or union type `Rectangle` in this scope
22 | let r = Rectangle { width: 10.0, height: 10.0 };
error[E0061]: this function takes 2 arguments but 1 argument was supplied
16 | let got = perimeter(r);
error[E0061]: this function takes 2 arguments but 1 argument was supplied
23 | let got = area(r);
The compiler tells you exactly what's broken. Add the struct and update both functions:
#![allow(unused)] fn main() { pub fn perimeter(r: Rectangle) -> f64 { 2.0 * (r.width + r.height) } pub fn area(r: Rectangle) -> f64 { r.width * r.height } }
r.width and r.height are field accesses — the . operator reaches into the struct.
Run cargo test:
running 2 tests
test tests::area_of_rectangle ... ok
test tests::perimeter_of_rectangle ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Commit.
Circle
Add a Circle struct and a test for its area:
#![allow(unused)] fn main() { pub struct Circle { pub radius: f64, } }
#![allow(unused)] fn main() { #[test] fn area_of_circle() { let c = Circle { radius: 10.0 }; let got = area(c); assert_eq!(got, 314.1592653589793); } }
Now try to write area for Circle as a free function, the same way you did for Rectangle:
#![allow(unused)] fn main() { pub fn area(c: Circle) -> f64 { 0.0 } }
Run cargo test:
error[E0428]: the name `area` is defined multiple times
--> src/lib.rs:18:1
|
14 | pub fn area(r: Rectangle) -> f64 {
| -------------------------------- previous definition of the value `area` here
...
18 | pub fn area(c: Circle) -> f64 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `area` redefined here
|
= note: `area` must be defined only once in the value namespace of this module
Rust won't allow two free functions with the same name, even with different parameter types. Unlike some languages, there's no function overloading.
This is the problem that methods solve.
Methods
Instead of a free function area that takes a shape, each shape gets its own area method — rectangle.area() and circle.area(). They live on different types so there's no collision.
Start with the test. Update the circle test to express the API you want:
#![allow(unused)] fn main() { #[test] fn area_of_circle() { let c = Circle { radius: 10.0 }; assert_eq!(c.area(), 314.1592653589793); } }
Also delete the area(c: Circle) free function — you're replacing it. Run cargo test:
error[E0599]: no method named `area` found for struct `Circle` in the current scope
|
7 | pub struct Circle {
| ----------------- method `area` not found for this struct
...
| assert_eq!(c.area(), 314.1592653589793);
| ^^^^ method not found in `Circle`
The compiler is telling you exactly what's missing. Now add it.
A method is defined in an impl block:
#![allow(unused)] fn main() { impl Circle { pub fn area(&self) -> f64 { todo!() } } }
impl Circle opens a block where you define methods that belong to Circle. The first parameter &self is how a method refers to the value it's called on — self is the circle, & means it's borrowed rather than owned. Inside the method you access fields as self.radius.
The disciplined move is to get back to green as quickly as possible — fix Circle first, without touching the Rectangle code that's still working.
For the circle area formula you'll need π. It lives in the standard library:
#![allow(unused)] fn main() { use std::f64::consts::PI; }
Put that at the top of the file. The area of a circle is π × r²:
#![allow(unused)] fn main() { impl Circle { pub fn area(&self) -> f64 { PI * self.radius * self.radius } } }
Run cargo test:
running 3 tests
test tests::area_of_circle ... ok
test tests::area_of_rectangle ... ok
test tests::perimeter_of_rectangle ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. Now do the same for Rectangle. But follow the same discipline — tests first. Update both rectangle tests to call r.perimeter() and r.area() instead of the free functions. Run cargo test and see them fail, then update the production code to match.
Once you're green again, your final lib.rs should look like this:
#![allow(unused)] fn main() { use std::f64::consts::PI; pub struct Rectangle { pub width: f64, pub height: f64, } pub struct Circle { pub radius: f64, } impl Rectangle { pub fn perimeter(&self) -> f64 { 2.0 * (self.width + self.height) } pub fn area(&self) -> f64 { self.width * self.height } } impl Circle { pub fn area(&self) -> f64 { PI * self.radius * self.radius } } #[cfg(test)] mod tests { use super::*; #[test] fn perimeter_of_rectangle() { let r = Rectangle { width: 10.0, height: 10.0 }; assert_eq!(r.perimeter(), 40.0); } #[test] fn area_of_rectangle() { let r = Rectangle { width: 10.0, height: 10.0 }; assert_eq!(r.area(), 100.0); } #[test] fn area_of_circle() { let c = Circle { radius: 10.0 }; assert_eq!(c.area(), 314.1592653589793); } } }
Commit.
Wrapping up
Rust concepts introduced
f64— 64-bit floating point; the default for decimalsstruct— a named type with fields; groups related data and encodes intent in the type systempubon struct and fields — required for visibility outside the module- Struct literal syntax —
Rectangle { width: 10.0, height: 10.0 } - Field access with
.—r.width,r.height impl— attaches methods to a type&self— borrows the receiver; the method can read the struct's fields without taking ownershipuse std::f64::consts::PI— importing a constant from the standard library
Testing concepts
- Write the test before the type exists — the compiler error tells you exactly what to build
- Fix one thing at a time — when the build is broken, restore green before adding the next requirement
Coming up
Rectangle and Circle both have an area method, but they have no connection in the type system. You can't write a function that accepts either one. That's what traits are for — the next chapter.
See The TDD Cycle and Separating Concerns.
Further reading
- Structs — the full picture: tuple structs, unit structs, and when to use each
- Method syntax —
impl,&self, associated functions, and the difference between methods and free functions - Ownership — the deeper reason
&selfborrows instead of taking ownership; essential Rust reading
Traits
At the end of the last chapter, Rectangle and Circle both have an area() method. But they have no connection in the type system. You can't write a single function that accepts either one.
Requirement: We want a function that can describe the area of any shape.
The problem
This chapter continues in the structs crate. Add a test for a describe_area function that takes a Rectangle:
#![allow(unused)] fn main() { #[test] fn description_of_rectangle() { let r = Rectangle { width: 10.0, height: 10.0 }; assert_eq!(describe_area(&r), "This shape has an area of 100"); } }
Run cargo test:
error[E0425]: cannot find function `describe_area` in this scope
--> src/lib.rs:53:20
|
53 | assert_eq!(describe_area(&r), "This shape has an area of 100");
| ^^^^^^^^^^^^^ not found in this scope
Add the function:
#![allow(unused)] fn main() { pub fn describe_area(r: &Rectangle) -> String { format!("This shape has an area of {}", r.area()) } }
A quick note on the format string: {} on a whole-number f64 like 100.0 prints 100, not 100.0. That's why the test expects "100" rather than "100.0". Rust's default float formatting drops trailing zeros and the decimal point when they're not needed.
Run cargo test:
running 4 tests
test tests::area_of_circle ... ok
test tests::area_of_rectangle ... ok
test tests::description_of_rectangle ... ok
test tests::perimeter_of_rectangle ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Commit.
The wall
Now add a test for Circle:
#![allow(unused)] fn main() { #[test] fn description_of_circle() { let c = Circle { radius: 10.0 }; assert_eq!(describe_area(&c), "This shape has an area of 314.1592653589793"); } }
Run cargo test:
error[E0308]: mismatched types
--> src/lib.rs:63:34
|
63 | assert_eq!(describe_area(&c), "This shape has an area of 314.1592653589793");
| ------------- ^^ expected `&Rectangle`, found `&Circle`
| |
| arguments to this function are incorrect
|
= note: expected reference `&Rectangle`
found reference `&Circle`
describe_area only accepts &Rectangle. There's no type you can put in the signature that means "anything with an area method". This is the problem that traits solve.
Defining a trait
A trait defines a contract — a named set of method signatures that a type must implement. Define one:
#![allow(unused)] fn main() { pub trait Shape { fn area(&self) -> f64; } }
This says: any type that wants to be a Shape must have an area method that takes &self and returns f64. Nothing more.
Adding this to your file and running cargo test won't change the error yet — describe_area still expects &Rectangle, so the compiler still reports the same mismatch:
error[E0308]: mismatched types
--> src/lib.rs:68:34
|
68 | assert_eq!(describe_area(&c), "This shape has an area of 314.1592653589793");
| ------------- ^^ expected `&Rectangle`, found `&Circle`
| |
| arguments to this function are incorrect
|
= note: expected reference `&Rectangle`
found reference `&Circle`
note: function defined here
--> src/lib.rs:33:8
|
33 | pub fn describe_area(r: &Rectangle) -> String {
| ^^^^^^^^^^^^^ -------------
Defining a trait doesn't sign anyone up to it. The next step is implementing it for each type and updating describe_area to use the trait as its bound.
Implementing the trait
impl Shape for Rectangle signs the contract for Rectangle. The syntax mirrors the impl Rectangle blocks you've already written — same keyword, different purpose.
First, update describe_area to accept any type that implements Shape:
#![allow(unused)] fn main() { pub fn describe_area(shape: &impl Shape) -> String { format!("This shape has an area of {}", shape.area()) } }
&impl Shape means "a reference to some concrete type that implements Shape". The compiler resolves the actual type at compile time — there's no runtime overhead.
Run cargo test:
error[E0277]: the trait bound `Rectangle: Shape` is not satisfied
--> src/lib.rs:61:34
|
61 | assert_eq!(describe_area(&r), "This shape has an area of 100");
| ------------- ^^ unsatisfied trait bound
| |
| required by a bound introduced by this call
|
help: the trait `Shape` is not implemented for `Rectangle`
--> src/lib.rs:3:1
|
3 | pub struct Rectangle {
| ^^^^^^^^^^^^^^^^^^^^
help: this trait has no implementations, consider adding one
--> src/lib.rs:27:1
|
27 | pub trait Shape {
| ^^^^^^^^^^^^^^^
note: required by a bound in `describe_area`
--> src/lib.rs:32:35
|
32 | pub fn describe_area(shape: &impl Shape) -> String {
| ^^^^^ required by this bound in `describe_area`
Now the compiler knows exactly what it needs. Sign the contract for both types. Note that the area method that previously lived in impl Rectangle and impl Circle now lives here instead — remove those old methods when you add these blocks:
#![allow(unused)] fn main() { impl Shape for Rectangle { fn area(&self) -> f64 { self.width * self.height } } impl Shape for Circle { fn area(&self) -> f64 { PI * self.radius * self.radius } } }
Run cargo test:
running 5 tests
test tests::area_of_circle ... ok
test tests::area_of_rectangle ... ok
test tests::description_of_circle ... ok
test tests::description_of_rectangle ... ok
test tests::perimeter_of_rectangle ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Commit.
Adding a Triangle
Add the struct and test first:
#![allow(unused)] fn main() { pub struct Triangle { pub base: f64, pub height: f64, } }
#![allow(unused)] fn main() { #[test] fn description_of_triangle() { let t = Triangle { base: 5.0, height: 10.0 }; assert_eq!(describe_area(&t), "This shape has an area of 25"); } }
Run cargo test:
error[E0277]: the trait bound `Triangle: Shape` is not satisfied
--> src/lib.rs:69:34
|
69 | assert_eq!(describe_area(&t), "This shape has an area of 25");
| ------------- ^^ unsatisfied trait bound
| |
| required by a bound introduced by this call
|
help: the trait `Shape` is not implemented for `Triangle`
--> src/lib.rs:12:1
|
12 | pub struct Triangle {
| ^^^^^^^^^^^^^^^^^^^
help: the following other types implement trait `Shape`
--> src/lib.rs:21:1
|
21 | impl Shape for Rectangle {
| ^^^^^^^^^^^^^^^^^^^^^^^^ `Rectangle`
...
27 | impl Shape for Circle {
| ^^^^^^^^^^^^^^^^^^^^^ `Circle`
note: required by a bound in `describe_area`
--> src/lib.rs:34:35
|
34 | pub fn describe_area(shape: &impl Shape) -> String {
| ^^^^^ required by this bound in `describe_area`
The compiler even tells you which types already implement Shape. Implement it for Triangle (area = half base × height):
#![allow(unused)] fn main() { impl Shape for Triangle { fn area(&self) -> f64 { 0.5 * self.base * self.height } } }
Run cargo test:
running 6 tests
test tests::area_of_circle ... ok
test tests::area_of_rectangle ... ok
test tests::description_of_circle ... ok
test tests::description_of_rectangle ... ok
test tests::description_of_triangle ... ok
test tests::perimeter_of_rectangle ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
describe_area didn't need to change at all. That's the point — the trait is the stable contract. New shapes plug in without touching existing code.
Commit.
Table tests
Three separate description tests are testing the same behaviour with different inputs. This is a good candidate for a table test — a list of cases run in a loop.
This is a restructuring of the tests, not a change to what they assert. The existing tests are already green; we're reorganising them. Since we're not changing behaviour, we should not need to change any assertions — the existing expected values stay exactly as they are.
Replace the three individual description tests with a single table test:
#![allow(unused)] fn main() { #[test] fn description_of_shapes() { let shapes: Vec<(&str, &dyn Shape, &str)> = vec![ ("rectangle", &Rectangle { width: 10.0, height: 10.0 }, "This shape has an area of 100"), ("circle", &Circle { radius: 10.0 }, "This shape has an area of 314.1592653589793"), ("triangle", &Triangle { base: 5.0, height: 10.0 }, "This shape has an area of 25"), ]; for (name, shape, want) in shapes { assert_eq!(describe_area(shape), want, "failed for {}", name); } } }
Run cargo test:
error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time
--> src/lib.rs:75:38
|
75 | assert_eq!(describe_area(shape), want, "failed for {}", name);
| ------------- ^^^^^ doesn't have a size known at compile-time
| |
| required by a bound introduced by this call
|
= help: the trait `Sized` is not implemented for `dyn Shape`
note: required by an implicit `Sized` bound in `describe_area`
--> src/lib.rs:40:30
|
40 | pub fn describe_area(shape: &impl Shape) -> String {
| ^^^^^^^^^^ required by the implicit `Sized` requirement on this type parameter in `describe_area`
help: consider relaxing the implicit `Sized` restriction
|
40 | pub fn describe_area(shape: &impl Shape + ?Sized) -> String {
| ++++++++
The compiler is pointing at the root of the problem. &impl Shape works for a single call where the concrete type is known at compile time — it secretly requires Sized. A Vec must hold elements of a single, uniform type, but Rectangle, Circle, and Triangle are three different concrete types. They can't all live in the same Vec<&impl Shape>.
&dyn Shape is a trait object — a fat pointer that carries both a reference to the value and a pointer to a vtable of method implementations. The compiler doesn't need to know the concrete type at compile time; it resolves area() at runtime through the vtable. That's the cost: a small runtime indirection. The benefit: a heterogeneous collection of any type implementing Shape.
Update describe_area:
#![allow(unused)] fn main() { pub fn describe_area(shape: &dyn Shape) -> String { format!("This shape has an area of {}", shape.area()) } }
Run cargo test:
running 4 tests
test tests::area_of_circle ... ok
test tests::area_of_rectangle ... ok
test tests::description_of_shapes ... ok
test tests::perimeter_of_rectangle ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
A few more things to unpack about the table test:
Vec<...> is Rust's growable array type. vec![...] is the macro that creates one.
Each element is a tuple of three values: a name (&str), a shape (&dyn Shape), and the expected output (&str). Tuples group values of different types — you access them by position in a for destructure: (name, shape, want).
The third argument to assert_eq! is an optional failure message. Without it, a failing table test only shows you the mismatched values — not which case failed. With "failed for {}", name you get:
assertion `left == right` failed: failed for rectangle
left: "This shape has an area of 100"
right: "This shape has an area of 999"
Before committing, deliberately break one assertion — change an expected value to something wrong — and run cargo test to confirm the failure message is useful. A test you've never seen fail is a test you can't fully trust. Once you're satisfied, restore it and commit.
Wrapping up
Rust concepts introduced
trait— a named contract: a set of method signatures a type must implementimpl Trait for Type— explicitly signs the contract for a type; the compiler enforces it&impl Shape— a function parameter that accepts any type implementingShape, resolved at compile time&dyn Shape— a trait object; accepts any type implementingShape, resolved at runtime; required for heterogeneous collectionsVec<T>andvec![]— growable array and its creation macro- Tuples — grouping values of different types; destructured in
forloops assert_eq!failure message — third argument formats a message shown when the assertion fails
Testing concepts
- Table tests reduce repetition when you're testing the same behaviour with different inputs
- When restructuring tests, don't change the assertions — the existing expected values are your safety net
- After restructuring, break one assertion deliberately to confirm the test can still fail and the failure message is useful
A note on Go
If you're coming from Go, Rust's explicit impl Trait for Type may feel verbose compared to Go's implicit interface satisfaction. The tradeoff is deliberate: in Rust you can always see exactly which traits a type implements by reading the file. Neither approach is objectively better — they reflect different priorities.
Coming up
&impl Shape and &dyn Shape are two ways to use traits in function signatures. There's a third — generics (fn foo<T: Shape>(s: &T)) — which gives you more flexibility at the cost of more syntax. Standard library traits like Display, Debug, and Iterator are also worth knowing; they unlock a lot of Rust's ergonomics. Both are coming in later chapters.
See Test Behaviour, Not Implementation and The TDD Cycle.
Further reading
- Traits: defining shared behaviour — default implementations, trait bounds, and the full
impl Traitvs generic syntax comparison - Trait objects (
dyn) — how vtables work, when to prefer&dyn Traitover generics, and the performance tradeoffs - Vectors — how
Vec<T>manages memory, and the full range of operations available
Enums and Pattern Matching
Requirement: We want a bank account that tracks a balance. You can deposit any amount. Withdrawing fails if there are insufficient funds — or if you try to take out more than £500 at once. The failure is a typed value, not a panic, not a sentinel like -1.
By the end of the chapter, you'll be able to write this:
#![allow(unused)] fn main() { let mut account = Account::new(100.0); account.deposit(50.0); account.withdraw(30.0)?; // ok account.withdraw(999.0)?; // returns Err }
Along the way you'll meet Rust's enum, the Result<T, E> type, exhaustive match, the ? operator, and impl Display.
The Account struct
Create a new library crate:
cargo new --lib enums
Replace enums/src/lib.rs with just the test:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn new_account_has_correct_balance() { let account = Account::new(100.0); assert_eq!(account.balance(), 100.0); } } }
Run cargo test:
error[E0433]: cannot find type `Account` in this scope
--> src/lib.rs:7:23
|
7 | let account = Account::new(100.0);
| ^^^^^^^ use of undeclared type `Account`
The compiler can't find Account. Define it above the test module:
#![allow(unused)] fn main() { pub struct Account { balance: f64, } impl Account { pub fn new(balance: f64) -> Account { Account { balance } } pub fn balance(&self) -> f64 { self.balance } } #[cfg(test)] mod tests { use super::*; #[test] fn new_account_has_correct_balance() { let account = Account::new(100.0); assert_eq!(account.balance(), 100.0); } } }
Run cargo test:
running 1 test
test tests::new_account_has_correct_balance ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
The balance field is private — no pub. The only way to read it is through the balance() method. This keeps the implementation detail hidden from callers; they can't reach in and modify balance directly. The method signature &self means "borrow the Account, don't take ownership of it". We've seen &self before in the structs chapter.
Commit.
Mutation — deposit
Add the test:
#![allow(unused)] fn main() { #[test] fn deposit_increases_balance() { let mut account = Account::new(100.0); account.deposit(50.0); assert_eq!(account.balance(), 150.0); } }
Run cargo test:
error[E0599]: no method named `deposit` found for struct `Account` in the current scope
--> src/lib.rs:28:17
|
1 | pub struct Account {
| ------------------ method `deposit` not found for this struct
...
28 | account.deposit(50.0);
| ^^^^^^^ method not found in `Account`
Add deposit to the impl Account block:
#![allow(unused)] fn main() { pub fn deposit(&mut self, amount: f64) { self.balance += amount; } }
Run cargo test:
running 2 tests
test tests::deposit_increases_balance ... ok
test tests::new_account_has_correct_balance ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
&mut self means "borrow the Account mutably". The test needs let mut account for the same reason — Rust tracks mutability at every point. Without mut, the compiler would say cannot borrow as mutable. Try it if you want to see the error.
The contrast with balance() is worth noting:
fn balance(&self)— read-only borrow; the caller keeps ownershipfn deposit(&mut self, ...)— mutable borrow; the caller keeps ownership, but the method can change the value
Commit.
Fallible withdrawal — introducing Result and enum
We want withdraw to fail gracefully when there's not enough money. Before we can express failure, we need a type to put in the error slot. That type is an enum.
An enum in Rust is not a list of integer constants. It's a sum type — a type that can be one of several named variants, and nothing else. Here's what ours looks like:
#![allow(unused)] fn main() { pub enum BankError { InsufficientFunds, } }
BankError can be exactly one thing: BankError::InsufficientFunds. That's all it can ever be, for now.
Add the test:
#![allow(unused)] fn main() { #[test] fn withdraw_fails_when_overdrawn() { let mut account = Account::new(100.0); let result = account.withdraw(150.0); assert!(result.is_err()); } }
Run cargo test:
error[E0599]: no method named `withdraw` found for struct `Account` in the current scope
Now add BankError above impl Account, and add withdraw to the impl block:
#![allow(unused)] fn main() { pub enum BankError { InsufficientFunds, } }
#![allow(unused)] fn main() { pub fn withdraw(&mut self, amount: f64) -> Result<(), BankError> { if amount > self.balance { return Err(BankError::InsufficientFunds); } self.balance -= amount; Ok(()) } }
Run cargo test:
running 3 tests
test tests::deposit_increases_balance ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test tests::new_account_has_correct_balance ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
Result<(), BankError> is the standard Rust type for operations that either succeed or fail:
Ok(())means success; the()(unit type) says "there's no meaningful value to return on success"Err(BankError::InsufficientFunds)means failure with a specific reason
Result is itself an enum — one defined in the standard library. Its two variants are Ok(T) and Err(E). We've chosen BankError as the error type E.
is_err() is a method on Result that returns true if the value is the Err variant. There's a matching is_ok() for the other side.
Commit.
The ? operator
Add a test for the successful withdrawal case:
#![allow(unused)] fn main() { #[test] fn withdraw_decreases_balance() -> Result<(), BankError> { let mut account = Account::new(100.0); account.withdraw(50.0)?; assert_eq!(account.balance(), 50.0); Ok(()) } }
Run cargo test:
error[E0277]: `BankError` doesn't implement `Debug`
--> src/lib.rs:49:40
|
48 | #[test]
| ------- in this attribute macro expansion
49 | fn withdraw_decreases_balance() -> Result<(), BankError> {
| ^^^^^^^^^^^^^^^^^^^^^ the trait `Debug` is not implemented for `BankError`
|
= note: add `#[derive(Debug)]` to `BankError` or manually `impl Debug for BankError`
When a test function returns Result<(), E>, Rust needs to be able to display E if the test fails. That requires the Debug trait. Add the derive attribute:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum BankError { InsufficientFunds, } }
Run cargo test:
running 4 tests
test tests::deposit_increases_balance ... ok
test tests::new_account_has_correct_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
Two new things here. First, the test signature: fn withdraw_decreases_balance() -> Result<(), BankError>. Tests can return Result. If the test returns Err, it fails. If it returns Ok(()), it passes.
Second, the ? on account.withdraw(50.0)?. The ? operator does one thing: if the value is Ok(v), unwrap it and continue; if it's Err(e), return Err(e) from the current function immediately. Because our test returns Result, ? works here — on an error it would propagate out of the test, which would fail it.
The test ends with Ok(()) to signal success — the same way a non-test function would.
#[derive(Debug)] is a compiler-generated implementation of the Debug trait, which lets Rust print a value for debugging purposes (using {:?} in format strings). We'll see this more when we get to generics. For now: enums used in Result in tests need it.
Commit.
Exhaustive matching
We can check whether a withdrawal succeeded with is_ok() and is_err(), but we often want to act on the specific outcome. That's where match comes in.
Write a helper function and a test for the successful case:
#![allow(unused)] fn main() { pub pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => todo!(), } } }
#![allow(unused)] fn main() { #[test] fn describe_successful_withdraw() { let mut account = Account::new(100.0); let result = describe_withdraw(&mut account, 50.0); assert_eq!(result, "Withdrew successful. New Balance: £50"); } }
Run cargo test:
running 5 tests
test tests::new_account_has_correct_balance ... ok
test tests::describe_successful_withdraw ... ok
test tests::deposit_increases_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. The match expression here has two arms: one for Ok(()), one for Err(BankError::InsufficientFunds). The todo!() macro in the second arm is a placeholder — it compiles, but panics at runtime if that arm is ever reached.
Now write a test that forces the InsufficientFunds path:
#![allow(unused)] fn main() { #[test] fn describe_insufficient_funds_withdraw() { let mut account = Account::new(100.0); let result = describe_withdraw(&mut account, 101.0); assert_eq!(result, "Could not withdraw, insufficient funds! Current balance £100") } }
Run cargo test:
---- tests::describe_insufficient_funds_withdraw stdout ----
thread 'tests::describe_insufficient_funds_withdraw' (11642906) panicked at src/lib.rs:13:46:
not yet implemented
The todo!() fires. Replace it:
#![allow(unused)] fn main() { pub pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance), } } }
Run cargo test:
running 6 tests
test tests::describe_insufficient_funds_withdraw ... ok
...
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. Commit.
Adding a variant — the compiler as safety net
Here's where enums earn their reputation. We want a second business rule: you can't withdraw more than £500 in a single transaction. Add the variant to BankError:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum BankError { InsufficientFunds, WithdrawalLimitExceeded, } }
And enforce it in withdraw:
#![allow(unused)] fn main() { pub fn withdraw(&mut self, amount: f64) -> Result<(), BankError> { if amount > 500.0 { return Err(BankError::WithdrawalLimitExceeded); } if amount > self.balance { return Err(BankError::InsufficientFunds); } self.balance -= amount; Ok(()) } }
Add a test, and run cargo test:
#![allow(unused)] fn main() { #[test] fn withdraw_fails_when_limit_exceeded() -> Result<(), BankError> { let mut account = Account::new(1000.0); let result = account.withdraw(600.0); assert!(result.is_err()); Ok(()) } }
error[E0004]: non-exhaustive patterns: `Err(BankError::WithdrawalLimitExceeded)` not covered
--> src/lib.rs:12:11
|
12 | match account.withdraw(amount) {
| ^^^^^^^^^^^^^^^^^^^^^^^^ pattern `Err(BankError::WithdrawalLimitExceeded)` not covered
|
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
14 ~ Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance),
15 ~ Err(BankError::WithdrawalLimitExceeded) => todo!(),
The compiler refuses to proceed. Our match in describe_withdraw doesn't handle the new variant, and Rust will not let that slide. This is exhaustive matching — every possible variant must be covered.
This is one of Rust's best safety properties. If you add a new error case to your enum, the compiler will find every match in your codebase that doesn't handle it and refuse to compile. No silent falls-through, no forgotten cases.
Add the test for the new behaviour and handle the arm:
#![allow(unused)] fn main() { #[test] fn describe_withdrawal_limit_exceeded() { let mut account = Account::new(100.0); let result = describe_withdraw(&mut account, 501.0); assert_eq!(result, "You cannot withdraw more than £500") } }
#![allow(unused)] fn main() { pub pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance), Err(BankError::WithdrawalLimitExceeded) => format!("You cannot withdraw more than £500"), } } }
Run cargo test:
running 8 tests
test tests::deposit_increases_balance ... ok
test tests::describe_insufficient_funds_withdraw ... ok
test tests::describe_successful_withdraw ... ok
test tests::describe_withdrawal_limit_exceeded ... ok
test tests::new_account_has_correct_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_limit_exceeded ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green. Notice the if chains inside withdraw — those are not cries for match. The conditions are boolean expressions, not enum variants. match is for enums; if is for booleans. Using match true { ... } to replace if chains is not idiomatic Rust.
Commit.
Human-readable errors — impl Display for BankError
Right now, BankError can only be printed in debug format — something like InsufficientFunds. That's fine for debugging, but when you want to show an error to a user, or include it in a formatted message, you want something readable.
The standard way to provide that is to implement the Display trait. Write the test first:
#![allow(unused)] fn main() { #[test] fn bank_error_display() { assert_eq!(BankError::InsufficientFunds.to_string(), "insufficient funds"); assert_eq!(BankError::WithdrawalLimitExceeded.to_string(), "withdrawal limit exceeded"); } }
Run cargo test:
error[E0599]: `BankError` doesn't implement `std::fmt::Display`
--> src/lib.rs:107:49
|
6 | pub enum BankError {
| ------------------ method `to_string` not found for this enum because it doesn't satisfy `BankError: ToString` or `BankError: std::fmt::Display`
...
107 | assert_eq!(BankError::InsufficientFunds.to_string(), "insufficient funds");
| ^^^^^^^^^ method cannot be called on `BankError` due to unsatisfied trait bounds
|
= note: the following trait bounds were not satisfied:
`BankError: std::fmt::Display`
which is required by `BankError: ToString`
The compiler's message is precise: to_string() comes from the ToString trait, which is automatically implemented for anything that implements Display. So we need Display.
Add the implementation above impl Account:
#![allow(unused)] fn main() { use std::fmt; impl fmt::Display for BankError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { BankError::InsufficientFunds => write!(f, "insufficient funds"), BankError::WithdrawalLimitExceeded => write!(f, "withdrawal limit exceeded"), } } } }
Run cargo test:
running 9 tests
test tests::bank_error_display ... ok
test tests::deposit_increases_balance ... ok
test tests::describe_insufficient_funds_withdraw ... ok
test tests::describe_successful_withdraw ... ok
test tests::describe_withdrawal_limit_exceeded ... ok
test tests::new_account_has_correct_balance ... ok
test tests::withdraw_decreases_balance ... ok
test tests::withdraw_fails_when_limit_exceeded ... ok
test tests::withdraw_fails_when_overdrawn ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Green.
fmt::Display is the trait Rust calls when you use {} in a format string. You implement one method: fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result. The write! macro inside writes into the formatter. If you've used Go: fmt::Formatter is like io.Writer, and write! is like fmt.Fprint.
Note that we're using match again here — matching on self to dispatch to the right string for each variant. The compiler will again enforce exhaustiveness; if you add a third variant to BankError later, this fmt implementation will refuse to compile until you handle it too.
Commit.
Final state
Here's the complete enums/src/lib.rs:
#![allow(unused)] fn main() { pub struct Account { balance: f64, } #[derive(Debug)] pub enum BankError { InsufficientFunds, WithdrawalLimitExceeded, } pub fn describe_withdraw(account: &mut Account, amount: f64) -> String { match account.withdraw(amount) { Ok(()) => format!("Withdrew successful. New Balance: £{}", account.balance), Err(BankError::InsufficientFunds) => format!("Could not withdraw, insufficient funds! Current balance £{}", account.balance), Err(BankError::WithdrawalLimitExceeded) => format!("You cannot withdraw more than £500"), } } use std::fmt; impl fmt::Display for BankError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { BankError::InsufficientFunds => write!(f, "insufficient funds"), BankError::WithdrawalLimitExceeded => write!(f, "withdrawal limit exceeded"), } } } impl Account { pub fn new(balance: f64) -> Account { Account { balance } } pub fn balance(&self) -> f64 { self.balance } pub fn deposit(&mut self, amount: f64) { self.balance += amount; } pub fn withdraw(&mut self, amount: f64) -> Result<(), BankError> { if amount > 500.0 { return Err(BankError::WithdrawalLimitExceeded); } if amount > self.balance { return Err(BankError::InsufficientFunds); } self.balance -= amount; Ok(()) } } }
Wrapping up
Rust concepts introduced
enum— a sum type whose value is exactly one named variant; not a C-style integer alias, but a full algebraic typematch— exhaustive pattern matching; every variant must be handled, or the compiler refuses to compileResult<T, E>— the standard two-variant enum for fallible operations;Ok(v)for success,Err(e)for failure?operator — propagates anErrout of the current function; tests can returnResultto use it- Custom error types — a bare
enumwith named variants; typed, exhaustively matchable, far more expressive than a sentinel value or a string #[derive(Debug)]— a compiler-generatedDebugimplementation; required when a type appears inResultreturned from a testimpl Display for T— how to make a type printable with{}; also unlocks.to_string();write!into afmt::Formattertodo!()— a macro that compiles but panics at runtime; useful for stubbing arms while building up amatch- Private fields — struct fields without
pubcan only be read or mutated through methods; callers can't reach in directly &mut selfvs&self— methods that change state take a mutable borrow; methods that only read take a shared borrow
Testing concepts
- Tests can return
Result— if the test returnsErr, it fails; this lets you use?inside tests, which is much cleaner than.unwrap()everywhere todo!()as a stepping stone — write the test, stub the implementation to get it compiling, then write the test that forces you to replace the stub; the compiler keeps you honest about what's done- The compiler is the first test — when you add a new enum variant, every
matchthat doesn't handle it fails to compile; you can't silently forget a case assert!(result.is_err())— a clean way to assert that an operation failed without caring about which specific error it was; use it when the caller only cares that something went wrong
Further reading
Result<T, E>— the full story on recoverable errors in Rust, including chaining,unwrap,expect, and when each is appropriateenumand pattern matching — the Rust Book's full chapter on enums, including variants with data,Option<T>, andif letOption<T>— why Rust has nonull, and howOptionmodels the absence of a value safely; we deferred it here but it's essential readingDisplayandDebug— more on the two formatting traits;Debugis for developers,Displayis for users- The
?operator in depth — how?uses theFromtrait for error conversion; the full power it offers when building larger programs
The TDD Cycle
Test-Driven Development is a discipline, not just a technique. It shapes how you think about software, not just how you write it. The cycle has three steps, and each step matters for a specific reason.
Red
Write a failing test.
Before writing any production code, write a test that describes a small piece of behaviour you want. Run it. Watch it fail.
This step is often skipped by newcomers who feel it's wasteful — "I know it'll fail, why bother running it?" The reason is this: a test that you haven't seen fail is a test you can't fully trust. You need to confirm:
- The test actually runs
- The failure message is clear and meaningful
- The test is testing what you think it's testing
A red test is a precise, executable description of a requirement.
Green
Write the minimum code to make the test pass.
Not good code. Not extensible code. The minimum. This is a constraint worth taking seriously.
When you're in the green step, your only job is to satisfy the test. This keeps your focus narrow and your steps small. If you find yourself writing more than you need to make the current test pass, stop — you're speculating about future requirements.
"Make it work, make it right, make it fast." — Kent Beck
Refactor
Clean up the code, with your tests as a safety net.
Now that the behaviour is correct and verified, improve the code. Rename things. Extract functions. Remove duplication. This is the step where design happens — but safely, because you have a test suite telling you if you've broken anything.
Crucially: refactoring must not change behaviour. If your tests pass before and after, you've refactored. If a test changes or you change what the code does, you've done something else.
The cycle in practice
Write a failing test → Make it pass → Refactor → repeat
The loop should be fast. Seconds, not minutes. If you find yourself in a long green or refactor phase without running tests, something has gone wrong. The tightness of the feedback loop is what gives TDD its power.
You'll know you've internalised TDD when a long stretch without a green test starts to feel uncomfortable — like a long time between saves in a document you're writing.
Why not write all the tests first?
TDD is not "write all your tests and then write all your code". That defeats the purpose. Each test should be written immediately before the code that makes it pass. The tests drive the design of the code, one small step at a time.
See also
Test Behaviour, Not Implementation
This is one of the most important — and most commonly violated — principles in testing.
The problem
Imagine you're building a square. You decide to implement it using two right-angled triangles. You write tests for the square (it has equal sides, it has right angles) and also tests for the triangles (angles sum to 180°, two triangles present, etc).
Later, someone realises squares can be made from two rectangles instead. She starts refactoring. Suddenly, tests for triangles start failing. She has to dig through them all to understand whether the behaviour she cares about — the square — is actually broken.
It isn't. The square still works. But the tests have falsely elevated the importance of the implementation detail (triangles) over the actual behaviour (a correct square).
This is the real reason developers say "unit tests get in the way of refactoring". It's not unit tests that are the problem — it's tests written at the wrong level.
The principle
Test what a piece of code does, not how it does it.
- Test the public API, not private internals
- Test the outcome, not the steps taken to reach it
- Avoid asserting on which collaborators were called, in what order, with what arguments — unless that sequence is the behaviour you care about
What is a "unit"?
A unit is not a function or a struct. A unit is a coherent piece of behaviour in your domain.
A Wallet is a unit. You test that you can deposit money, withdraw money, and check the balance. You don't test that internally it uses a Vec<i64> vs an i32 field — that's an implementation detail.
If you change the internals and the tests don't break, you've succeeded. That's what good unit tests feel like.
In Rust
Rust makes this natural. Mark your tests in a #[cfg(test)] module and test through the same public interface your callers would use. Don't reach into private fields or call private functions in tests.
#![allow(unused)] fn main() { pub struct Wallet { balance: i64, } impl Wallet { pub fn deposit(&mut self, amount: i64) { self.balance += amount; } pub fn balance(&self) -> i64 { self.balance } } #[cfg(test)] mod tests { use super::*; #[test] fn deposit_increases_balance() { let mut w = Wallet { balance: 0 }; w.deposit(10); assert_eq!(w.balance(), 10); } } }
The test doesn't know or care how balance is stored. It tests the behaviour.
See also
Incremental Development
Software is never finished. It evolves. The only question is whether it evolves in a controlled, understandable way — or in a chaotic, frightening one.
Incremental development is the practice of making software in small, safe, verifiable steps. TDD enforces this, but the principle goes deeper than the test cycle.
Small steps
Each step should:
- Leave the code in a working state (tests green)
- Add one piece of meaningful behaviour
- Be small enough that you could throw it away without losing much
When a step feels big, break it down. If you can't break it down, that's often a signal the design is more tangled than it should be.
The "walking skeleton"
When starting something new, the first goal is not to build a feature — it's to build a thin, end-to-end slice that actually runs. A skeleton. Then you flesh it out.
This is more important than it sounds. Getting everything connected early — even in a trivial, incomplete way — surfaces integration problems before you've invested too much in each layer.
Commit often
Every time your tests are green and you've made meaningful progress, consider committing. Source control is not just a backup — it's a way to document your thinking and give yourself a safe point to return to if a refactor goes wrong.
LGWT's Hello World chapter explicitly says: once tests pass, commit before you refactor. That discipline is worth adopting.
The ratchet
Think of incremental development as a ratchet: you can only turn it one way. Each green test locks in a behaviour. You can refactor around it, but you can't accidentally un-implement it without noticing.
This is what makes TDD feel safe. The test suite is a ratchet on correctness.
Resist the urge to over-engineer
Incremental development means trusting that you can add complexity later when you need it. Don't add it speculatively.
"You ain't gonna need it." — YAGNI
The flip side: don't build so incrementally that you never integrate. Small steps towards a real goal, not small steps that circle endlessly.
See also
Outside-In TDD
Outside-in TDD (also called "London School TDD" or "mockist TDD") is an approach to test-driven development that starts from the user's perspective and works inward through the system.
The idea
Rather than starting with the lowest-level building blocks and assembling them upward, outside-in starts at the outermost behaviour — what a user or caller actually experiences — and uses that as the driver for what to build next.
You write a high-level test first. It fails, probably because the types and functions it needs don't exist yet. Then you build just enough of the internals to make it pass, guided by the tests you write at each layer.
Why outside-in?
The alternative — inside-out development — risks building things that are technically correct but don't serve the real need. You might build a beautifully designed data structure that nobody calls, or a function with the wrong signature for how it's actually used.
Outside-in keeps the purpose in front of you. Every internal unit exists because an outer test required it.
Acceptance tests and unit tests
Outside-in often involves two levels of tests:
- Acceptance tests — high-level tests that describe end-to-end behaviour from a user's perspective. These are written first and are often slow or integration-heavy. They stay red for a while.
- Unit tests — written as you implement each internal piece. These drive the design of the internals.
The acceptance test is the "outer loop". It defines what you're building. The unit tests are the "inner loop". They define how you build it.
A note of caution
Outside-in TDD involves more mocking and interface design up front, because outer layers need to stand in for inner layers that don't exist yet. This is a legitimate trade-off, not a flaw.
However, it can be overused. If you mock everything and never let the real components interact, your tests won't catch integration problems. Use real implementations wherever practical; reach for mocks when the alternative is slow, non-deterministic, or has side effects you don't want in tests.
In practice for this book
Most chapters start with a simple goal stated from the outside:
"I want to be able to call
hello("World")and get"Hello, World"back."
That's outside-in thinking. We write the test for the behaviour we want, then build what's needed to satisfy it.
See also
Listening to Your Tests
Tests are not just a safety net. They give you feedback about your design.
When tests are painful to write, that pain is telling you something. Learn to listen.
Common signals and what they mean
"This is hard to test without setting up a lot of state"
Your component has too many dependencies, or its dependencies are too concrete. Consider injecting abstractions instead of constructing things internally.
"I have to change many tests when I refactor the internals"
Your tests are testing implementation details rather than behaviour. See Test Behaviour, Not Implementation.
"I need to mock everything to test anything"
Your code is doing too much, or concerns are mixed. A function that both fetches data and formats it is harder to test than two functions that do each separately. See Separating Concerns.
"My tests are slow"
Something in the code under test is doing I/O, sleeping, or hitting a real service. Extract the slow part behind an interface and inject a fast substitute in tests.
"I can't see how to write a test for this"
This usually means the behaviour isn't clearly defined, or the code is entangled with things it shouldn't be. It's rarely a testing problem — it's a design problem that the test is exposing.
The key insight
If you view testing as an afterthought — something you do after the code is "done" — you lose the design feedback. The tests are supposed to push back. They're supposed to make you rethink how things are structured.
TDD makes this feedback fast. You feel the friction before you've written too much code to change comfortably.
Rust-specific notes
Rust's type system and ownership model are unusually good at this. If something is hard to test in Rust, it's often because ownership boundaries aren't clear, or because a type is doing too much. The borrow checker will refuse to let you take shortcuts that would create hidden coupling in other languages.
Take that friction seriously. It's almost always pointing at something real.
See also
Separating Concerns
Good code separates what a thing does from how it interacts with the world.
This is one of the oldest principles in software design, and it applies with full force when writing testable code.
Domain vs side effects
Consider a function that greets someone by name:
#![allow(unused)] fn main() { fn greet(name: &str) -> String { format!("Hello, {}!", name) } }
This is pure. It takes input, returns output. You can test it with no setup:
#![allow(unused)] fn main() { #[test] fn greets_by_name() { assert_eq!(greet("World"), "Hello, World!"); } }
Now consider this version:
#![allow(unused)] fn main() { fn greet(name: &str) { println!("Hello, {}!", name); } }
You can't easily test this without capturing stdout. The greeting logic and the output are tangled together.
Separate them. The business logic (what to say) belongs in pure functions. The side effects (printing, writing to a file, sending a network request) belong at the boundary of the system, called from main or an application layer.
What counts as a "side effect"?
- Writing to stdout or stderr
- Reading from or writing to the file system
- Network I/O
- Reading the clock or generating random numbers
- Mutating global state
These are all things that make tests slow, flaky, or hard to set up. Keep them at the edges.
Dependency injection
When your code needs to interact with the outside world, inject the mechanism rather than hard-coding it. Pass a writer instead of calling println!. Pass a clock interface instead of calling std::time::Instant::now().
This lets tests substitute fast, controlled fakes for slow, uncontrollable real things.
In Rust, this typically means accepting a generic type parameter or trait object:
#![allow(unused)] fn main() { use std::io::Write; fn greet<W: Write>(writer: &mut W, name: &str) { writeln!(writer, "Hello, {}!", name).unwrap(); } #[test] fn greet_writes_to_writer() { let mut output = Vec::new(); greet(&mut output, "World"); assert_eq!(output, b"Hello, World!\n"); } }
In the real application, you'd call greet(&mut std::io::stdout(), "World"). In the test, you use a Vec<u8> — fast, in-memory, and fully inspectable.
The payoff
When concerns are separated:
- Tests are fast and easy to write
- The core logic can be understood in isolation
- Side effects are explicit and localised
- Refactoring the implementation doesn't break the tests
This isn't just about testing. Separated code is easier to read, reason about, and change — for any reason.