Skip to content

Unit Tests — Fast, Isolated, Many

The overview drew the shape of a testing strategy as a stack of levels, each answering a different question with a different cost. This page zooms all the way into the base of that stack — the widest, cheapest, most numerous band — and asks the first-principles question: what is the smallest thing you can test, and what does testing it in isolation actually buy you?

The answer is the unit test. It is the level you will write the most of, run the most often, and rely on the most for fast feedback. It is also the level most often misunderstood — praised as a rule about small functions, when its real defining property is something else entirely.

A unit is the smallest piece of behavior you can meaningfully verify on its own. In practice that is usually a function, a method, or a small cluster of them behind one entry point. But size is not the point. The point is isolation.

A unit test exercises one unit with everything else held still. It does not talk to a database, a network, the clock, the filesystem, or another running process. If the unit under test normally depends on any of those, you replace the dependency with a stand-in — a stub, a mock, or a fake — so that the only thing that can vary between test runs is the code you are actually testing.

┌─────────────────────────────────────┐
│ unit test │
│ │
│ input ──► [ unit ] ──► output │
│ │ │
│ ▼ │
│ stub / mock / fake │ ← dependencies replaced,
│ (in-memory, deterministic) │ nothing real is touched
└─────────────────────────────────────┘

That is why “unit” is defined by what it is isolated from, not by its line count. A 200-line pure function with no dependencies is a perfectly good unit. A 5-line function that opens a socket is not a unit test candidate until you replace the socket — otherwise you are testing the network, not the function.

There is a second, sharper reason isolation matters: fault localization. When a test that touches only one unit fails, the bug is in that unit — there is nowhere else for it to hide. Compare that to a test that touches ten collaborators through real I/O: when it goes red, the fault could be in any of the ten, or in the wiring between them, or in the environment. Isolation is what turns a red test into a pointer, not a search. That property is worth as much as the speed.

Stubs, mocks, and fakes — the isolation toolkit

Section titled “Stubs, mocks, and fakes — the isolation toolkit”

These three words get used loosely, but they name genuinely different tools:

  • Stub — returns canned answers. “When asked for the current time, always return 2026-01-01T00:00:00Z.” It removes variability so a branch becomes reachable and repeatable.
  • Mock — a stub that also records and asserts on how it was called. “This test fails unless save() was called exactly once, with this record.” It verifies an interaction, not just a return value.
  • Fake — a real, working, lightweight implementation. An in-memory map standing in for a database; it behaves like the real thing but lives entirely in RAM.

All three exist for one reason: to cut the unit loose from the slow, non-deterministic world so it can be tested at the speed of a function call. A useful rule of thumb: reach for a fake when the collaborator has interesting behavior you want to exercise (an in-memory store you can read back from), a stub when you only need it to not get in the way (a fixed clock so a “expired?” branch is reachable), and a mock only when the interaction itself is the thing under test (proving a retry actually retried). Over-reaching for mocks is the single most common way a unit suite drifts into testing its own assumptions — a trap we make concrete below. Which one you reach for is a design choice we return to in Integration Tests, where the real collaborators come back.

A unit test that touches nothing real is bounded only by CPU. No DNS lookup, no TCP handshake, no disk seek, no process spawn. That single fact changes everything about how you can use them.

Because each test is cheap, three good things follow. You can run the whole suite on every save, so feedback arrives in seconds. You can write many small tests — one per branch, one per edge — without the suite becoming slow. And each failure points at a small blast radius: when one unit test goes red, the bug is in one unit, not somewhere in a chain of ten.

Unit tests are the right tool for anything you can decide by feeding an input and checking an output:

  • Branch logic — every if, match, and early return. A unit test per branch proves each path does what you claim.
  • Edge conditions — empty input, zero, the maximum value, off-by-one boundaries, malformed data. These live inside a single unit, so a unit test reaches them directly and cheaply.
  • Pure transformations — parsing, formatting, arithmetic, sorting, mapping one shape to another. Deterministic in, deterministic out — the ideal shape for a fast assertion.

All of this is logic, and logic is exactly what runs correctly at the speed of a function call. That is the natural home of the unit test.

A unit test worth keeping tends to follow one small structure, often called Arrange–Act–Assert:

Arrange set up the input and any stand-in dependencies
Act call the one unit, once
Assert check the output against the expected value

The discipline is that a good unit test does one thing and names it. If you find yourself asserting on five unrelated outcomes, that is usually five tests wearing one name — split them, so a failure names exactly what broke. The test’s name is documentation: unknown_level_is_an_error tells you, without reading the body, both the input class and the expected behavior. When it goes red, the name alone often tells you what regressed.

Good unit tests also share a set of properties sometimes abbreviated FIRST — and each property is a direct consequence of isolation:

  • Fast — microseconds, because nothing real is touched, so you can run the whole suite on every save.
  • Isolated — independent of each other and of the outside world; test order never changes the result.
  • Repeatable — deterministic, because the clock, randomness, and network have all been stubbed to fixed values.
  • Self-validating — the test asserts pass/fail itself; no human eyeballs a log to decide.
  • Timely — cheap enough to write alongside the code, not bolted on months later.

A test that violates any of these is usually a sign that a real dependency has leaked in. A “flaky” unit test — one that passes and fails without the code changing — is almost always a clock, a random seed, or a shared bit of state that was never properly isolated.

Here is the honest boundary, and it is a boundary of kind, not of effort. No amount of extra unit tests crosses it.

  • How units wire together. Each unit can be individually correct while the composition is wrong — a mismatched contract at the seam, an argument in the wrong order, a wrong assumption about what the collaborator returns. Isolation is precisely what hides this. That is the job of Integration Tests.
  • Real I/O behavior. Your fake database never deadlocks, never returns a driver-specific error, never times out, never disagrees with the real schema. The stub you wrote encodes what you believe the dependency does — which is not the same as what it does.
  • False confidence from over-mocking. This is the subtle one. If you mock so heavily that the test only checks “the code called the mock the way I told it to,” you have written a test that asserts your code matches your assumptions — and passes even when both are wrong together. A suite that is all green while production is on fire is usually a suite that mocked away the part that broke.
unit test says: "each brick is sound" ✅
production asks: "does the wall stand?" ❓ ← a different question,
answered one level up

The lesson is not “mock less” as a slogan. It is: know which question a unit test answers, and do not mistake a green base of the pyramid for proof that the whole system works.

Picture a save_user function that validates a record and then calls a Database. Over-mock it and the test becomes:

mock the Database → assert save_user called db.insert(record) once
✅ passes forever — even if:
• the real db column is NOT NULL and record.email is None
• insert() actually returns a conflict error you never handle
• the schema renamed the column last week

Every one of those is a real bug, and the mock is blind to all of them, because the mock is the assumption being tested. The test now says only “my code calls the collaborator the way I told the collaborator to expect” — a tautology. This is not an argument against mocks; it is an argument for knowing that a mock verifies an interaction, not a reality. Verify the reality one level up, against something closer to the real dependency.

Under the hood — tests that live next to the unit

Section titled “Under the hood — tests that live next to the unit”

The cleanest expression of “isolation is the point” is a test that sits right beside the code it tests. Rust’s convention makes this concrete. In the logwise log parser, the parse_line function and the Level enum live in src/parser.rs, and their tests live in the same file, in a module gated by #[cfg(test)]:

src/parser.rs
pub fn parse_line(raw: &str) -> Result<LogLine, ParseError> {
// ... splits a raw log line into level, timestamp, message ...
}
#[cfg(test)]
mod tests {
use super::*; // pulls in EVERYTHING in this file — including private items
#[test]
fn level_parsing_is_case_insensitive() {
assert_eq!("info".parse::<Level>().unwrap(), Level::Info);
assert_eq!("WARNING".parse::<Level>().unwrap(), Level::Warn);
}
#[test]
fn unknown_level_is_an_error() {
assert_eq!(
"LOUD".parse::<Level>(),
Err(ParseError::UnknownLevel("LOUD".into()))
);
}
}

Two design decisions are doing real work here:

  • #[cfg(test)] means the module is compiled only during cargo test and stripped from the shipped binary. The tests cost nothing at runtime and add nothing to the release artifact.
  • use super::* gives the test module access to the parent module’s private items — internal enums, helper functions, fields that are not part of the public API. This is the unit test’s superpower: it can verify the small internal pieces directly, without forcing you to make them public just to test them.

Notice what these tests touch: a string goes in, a Level or a ParseError comes out. No file is opened, no log is read from disk, no clock is consulted. parse_line reads real files in production — but the unit under test is pure, so the test feeds it a literal &str. That is isolation in one line of code, and it is why these tests run in microseconds and can be run on every keystroke.

Unit tests are the widest, cheapest band at the base of the pyramid from the overview — you write the most of them and run them the most often. But base is not the same as sufficient. The Ariane 5 lesson is the whole reason the levels above this one exist: the seam between correct units is tested by Integration Tests, the whole assembled product by System & End-to-End Tests, and whether that product is the right thing at all by Acceptance Tests. How many of each — and whether a pyramid or a trophy is the right shape for your system — is the question we assemble into a full plan in Composing the Levels into a Strategy.

The takeaway to carry forward: unit tests give you fast, precise confidence about logic. Everything they buy you comes from isolation, and everything they cannot see is the cost of that same isolation. Spend the next levels buying back exactly what isolation hid.

  • Why does it exist? To get correctness feedback on the smallest piece of behavior at the speed of a function call, so a developer learns a change broke something within seconds of making it.
  • What problem does it solve? Slow, coarse feedback. Without unit tests, the cheapest way to find a logic bug is to run the whole system — orders of magnitude slower, with a blast radius spanning the whole codebase instead of one function.
  • What are the trade-offs? Speed and precision are bought with isolation, and isolation is a lie about the world: the stubs and mocks that make a unit fast also make it blind to how it behaves against real collaborators and real I/O.
  • When should I avoid it? When the behavior you care about is the integration — the wiring, the real database’s error modes, the actual protocol. Mocking those away doesn’t test them; it tests your assumptions about them. Reach one level up instead.
  • What breaks if I remove it? The fast inner loop. Every logic bug now costs a slow, wide test to find, developers stop running tests on each change, feedback collapses to minutes, and the other levels inherit failures they were never meant to localize.
  1. A colleague says “any function under ten lines is a unit, anything longer isn’t.” What is wrong with defining a unit by size, and what is the property that actually defines it?
  2. Explain, using orders of magnitude, why you can afford tens of thousands of unit tests but only a handful of end-to-end tests.
  3. Name the three kinds of isolation tool (stub, mock, fake) and give the one-sentence distinction between a stub and a mock.
  4. Give one concrete bug a unit test is well suited to catch and one it structurally cannot catch. Why is the second one a difference of kind, not of effort?
  5. In the logwise example, what do #[cfg(test)] and use super::* each contribute, and why can these tests run in microseconds?
Show answers
  1. Size is incidental. A large pure function is a fine unit; a tiny function that opens a socket is not, until the socket is replaced. The defining property is isolation — the unit is tested with all its external dependencies (network, disk, clock, other processes) held still via stubs, mocks, or fakes, so the only variable is the code under test.
  2. A pure function call is ~1–100 ns; a real dependency (disk ~100 µs, network ~500 µs, cross-region ~100 ms) is roughly a thousand to a million times slower. Unit tests touch nothing real, so 100,000 of them run in about a second of CPU; the same count doing real I/O would take minutes to hours. The ratio is what makes many unit tests cheap and many E2E tests unaffordable.
  3. Stub returns canned answers to remove variability; mock does that and records/asserts on how it was called; fake is a real but lightweight working implementation (e.g. an in-memory map for a database). The stub-vs-mock distinction: a stub only supplies inputs to your code, while a mock additionally verifies the interaction (that a call was made, how, and how many times).
  4. Catches: branch logic, edge conditions (empty/zero/max/off-by-one), and pure transformations — anything decided by input-in, output-out. Cannot catch: how units wire together, real I/O behavior, and false confidence from over-mocking. It is a difference of kind because isolation is precisely what hides integration and real-world faults; adding more isolated tests cannot reveal a fault that only appears when the isolation is removed.
  5. #[cfg(test)] compiles the test module only under cargo test, so it costs nothing at runtime and isn’t shipped in the release binary. use super::* grants access to the parent module’s private items, so internal pieces can be tested directly without being made public. The tests run in microseconds because the unit under test (parse_line / Level parsing) is pure — a &str goes in and a value comes out, with no file, network, or clock touched.