Unit Testing

Lesson 1 of 5

Good vs Brittle Unit Tests

"Test behaviour, not implementation. The user of your code — including future you — never once asked how it works inside." — a lesson every engineer learns the second time they refactor

Somewhere in your career, usually on a Friday, you will do everything right and still get punished. You will rename a private helper, split one function into two, or swap a for loop for a .map() — a change that alters nothing a caller could ever observe — and forty tests will turn red. You didn't break the code. You broke the tests. And in that moment you will feel the specific, radicalising fury that turns engineers off testing for years: the suite that was supposed to protect you just mugged you instead.

That experience is not an argument against testing. It is an argument against bad testing. There is a real, teachable, unglamorous craft to writing unit tests that catch genuine regressions and stay silent through honest refactors — tests that behave like a smoke detector (screaming only when there's actually fire) rather than a car alarm (screaming when a leaf lands on the bonnet). This chapter is that craft. It is the foundation everything else in Part II stands on, because a matcher you don't understand or an edge case you forgot matters far less than the single decision that separates a suite you trust from a suite you learn to ignore: what, exactly, are you asserting about?

You have already tested code informally throughout this Library — the slugify you hardened in the JavaScript volume (V1), the typed domain models you designed in the TypeScript volume (V2), the API handlers behind the Backend service in Volume 8. You've run things and eyeballed the output. Now we make it rigorous. And we start with the two questions that a surprising number of professional engineers never explicitly answer: what is a unit, and what am I actually allowed to test about it?

The big question

If refactoring means changing how code works without changing what it does, why do so many test suites break every time you refactor — and how do you write tests that break only when the behaviour actually changes?

Core Concepts

What a "unit" actually is

Ask five engineers to define "unit" and you'll get five answers: a function, a class, a method, a file, a module. They're all reaching for the same intuition and all slightly missing it. A unit is a unit of behaviour, not a unit of code. It is the smallest piece of your system that does something meaningful and observable from the outside — one rule, one decision, one transformation. It might be implemented as a single pure function. It might be a class with three collaborating methods. It might be two small functions that only make sense together. The code boundary is negotiable; the behaviour boundary is the point.

This distinction is not pedantry — it decides everything downstream. If you believe a unit is "one function," you will write one test per function, including the tiny private helpers, and you will couple your tests to your internal decomposition. Then the day you inline a helper or rename it, its tests have nowhere to live and they die screaming. If instead you believe a unit is "one behaviour," you test the public thing the outside world calls, let it exercise its private helpers as a side effect, and you are free to reorganise the insides however you like.

Everyday analogy

Think of a restaurant kitchen. The behaviour is "I order jollof rice and I get correct, hot jollof rice in a reasonable time." That is what you, the customer, can observe and what the restaurant owes you. Whether the chef chops the peppers before or after the onions, uses one pot or two, or reorganises the whole kitchen on Monday — none of that is your business, and a review that complained "they moved the salt to a different shelf" would be absurd. A brittle test is exactly that absurd review: it grades the kitchen on where the salt lives instead of on the food.

Here is the working definition to carry for the rest of your life: a unit test verifies one behaviour of a unit through its public interface, with everything slow or unpredictable held still. "Public interface" is the load-bearing phrase. It's the contract — the inputs a caller passes and the outputs and effects a caller observes. Everything on the other side of that contract is implementation, and implementation is precisely the thing you are allowed to change without telling anyone.

Behaviour versus implementation — the whole ballgame

Every function has two sides. There is what it promises — "give me a list of prices and I'll give you the total with tax" — and there is how it keeps the promise — the loop, the accumulator variable, the intermediate Math.round, the helper it delegates to. Callers depend on the promise. Only the code itself depends on the how.

A good test asserts against the promise. A brittle test asserts against the how. That's it. That's the entire chapter in one sentence, and everything else is elaboration and evidence.

Watch it happen on a concrete function. Suppose you're building the checkout logic for a store — the kind of cart you'll test end-to-end much later in this volume, but here reduced to its core rule.

Tests — Run & Watch Them Pass

Run to execute the tests…

Those three tests describe the contract. Notice what they never mention: reduce, subtotal, Math.round, the accumulator. They pass sample carts through the front door and check what comes out the front door. Tomorrow you could rewrite cartTotal with a plain for loop, precompute tax differently, or extract a lineTotal helper — and as long as the promise holds, all three stay green. They are coupled to behaviour, and behaviour is what you were hired to preserve.

Now watch the same function tested badly.

Tests — Run & Watch Them Pass

Run to execute the tests…

The brittle version passes today, and that is exactly why it's dangerous — green tests feel like safety. But it has welded itself to details no caller cares about. Rewrite cartTotal as a for loop and the reduce spy fails, even though every customer still gets the right total. The test now punishes correct refactors and rewards leaving bad code alone. Multiply that by a thousand tests and you understand why some teams are terrified to touch their own codebase: the tests have quietly become handcuffs.

The engineer's notebook

Here's the field test I apply before writing any assertion: "Could a caller, using only the public interface, tell the difference if this were false?" If yes, it's behaviour — assert it. If no — if the only way to know is to read the source — it's implementation, and asserting it is how you build a suite that fights you. I have never regretted deleting a test that failed this question.

Coupling to internals is the root evil

Every brittle test traces back to a single sin: coupling to internals. The test knows something it shouldn't — a private method name, an intermediate variable, the number of times an inner helper is called, the exact shape of a data structure the caller never sees. Coupling is what makes a test break on a change that changed nothing observable, because the test was watching the internals and the internals moved.

There's a precise way to see this. A test creates a dependency from the test onto whatever it asserts about. Assert on the public interface and you depend only on the contract, which is stable by definition — that's what "contract" means. Assert on the internals and you depend on the implementation, which is supposed to change; that's what refactoring is. You have coupled a stable thing to a volatile thing and then act surprised when the volatile thing shakes it loose.

Rendering diagram…

This is why over-mocking (which you'll study properly in Part III) is so often a brittleness factory. The moment you mock a collaborator and then assert "it was called exactly twice with these arguments," you've frozen an interaction detail. If the real code can satisfy its contract by calling the collaborator once, or three times, or with reshaped arguments, your test now forbids a correct implementation. Mocks are sometimes necessary — a unit that sends real emails or charges real cards must be isolated — but every mock is a small bet that a particular interaction is part of the contract. Bet only when it truly is.

The counter-discipline has a name in the literature: test through the public API, and treat the unit as a black box wherever you can. The blacker the box, the freer you are inside it. When you find yourself needing to reach past the interface to test something, that's usually a design signal — the thing you want to verify wants to be public, i.e. it wants to be its own unit with its own front door. You'll feel this pull most sharply in Part IV, where TDD uses exactly this pressure to shape better designs.

The false economy of "testing everything"

New engineers, taught that testing is virtue, sometimes conclude that more assertions are more virtue, and start asserting on everything in sight — every field, every intermediate, every call. This is the road to brittleness paved with good intentions. A test suite is not measured by how much it asserts; it's measured by how much real breakage it catches per false alarm it raises. A suite that screams at every refactor has a terrible signal-to-noise ratio, and humans respond to noisy alarms in exactly one way: they stop listening.

In the real world

At a fintech I worked with, a "risk scoring" module had 94% coverage and 300 tests — and nobody would touch it. Every attempt to clean up the code broke dozens of tests that asserted on private method calls and internal intermediate scores. The tests had inverted their purpose: instead of enabling change, they prevented it. We spent a sprint rewriting them to assert only on the final risk score and the emitted decision — the two things a caller actually consumed. Test count dropped to 70. Coverage stayed high. And for the first time in two years, an engineer refactored that module in an afternoon without a single false red. The suite got smaller and got better.

Good tests catch real regressions

Everything so far might read as "assert less" — but that's only half the craft, and the dangerous half if taken alone. The point of a test is to catch a real regression the instant it appears. A suite that never breaks is as useless as a smoke detector with the battery removed: blissfully quiet, and no protection at all. The art is asserting on exactly the behaviour that matters — no less (or you miss real bugs) and no more (or you drown in false alarms).

A regression is a behaviour that used to be right and silently became wrong. The right test is the one that goes red the moment the promise is broken and stays green through every honest reshuffle of the how. Let's prove both halves in one runnable block: the robust test survives a refactor and still catches a genuine bug.

Tests — Run & Watch Them Pass

Run to execute the tests…

Run it. The refactor (V2) passes untouched — the tests didn't even notice the internals moved. And the genuine bug (applyDiscountBuggy) fails loudly: 10% off 1000 returns -9000 instead of 900, and the failure message tells you precisely what broke. This is a good test. It is deaf to how the code is written and hair-triggered on what the code does. Silent through refactors, screaming through regressions — the two properties that make a suite worth keeping.

FIRST — the five properties of a test you can trust

There is a well-worn mnemonic for the qualities of a good unit test, and it's worn for a reason. FIRST: Fast, Isolated, Repeatable, Self-validating, Timely. It's easy to recite and hard to live, so let's take each one as an engineering constraint, not a slogan.

Letter Property What it demands What breaks it
F Fast Milliseconds each; the whole suite in seconds Real DB/network/sleep calls; slow suites go unrun
I Isolated No shared state; any order; no leftovers Global mutation, order dependence, shared fixtures
R Repeatable Same result every run, every machine Date.now(), Math.random(), timezones, network
S Self-validating Passes or fails on its own — no eyeballing logs console.log "tests"; assertions a human must read
T Timely Written with the code, not "later" Deferred tests; "later" is where tests go to die

Fast is not vanity. A unit test that finishes in 40 milliseconds gives you feedback faster than you can form the next thought, which is why you'll actually run it — before every commit, in a watch loop, constantly. A suite that takes ten minutes gets run once a day, and a test you don't run protects nothing. Speed is what keeps the safety net in your hands instead of in a nightly CI job you've stopped reading.

Isolated means no test may depend on another test's leftovers or on the order tests happen to run in. Each test arranges its own world, acts, asserts, and cleans up — so that running one test, or all of them, or a random third of them, gives identical results. The moment test B only passes because test A ran first, you have a suite that fails mysteriously the day someone adds a test in the middle, and "mysterious" is the most expensive word in testing.

Repeatable is the property whose violation does the most damage, because its failure mode is the flaky test — one that passes and fails on the same code depending on the clock, a random value, the network, or timezone. Flakiness is poison not because it's occasionally wrong but because it teaches your team to shrug at red. Once "oh, that one's just flaky" enters the vocabulary, the next red — the real one — gets the same shrug, and a genuine bug sails into production behind the cover of learned indifference. A flaky test is worse than a deleted one. Anything that touches time, randomness, or the outside world is where repeatability goes to die, and it's exactly where the test doubles of Part III earn their keep.

Where this came from

The FIRST acronym was popularised by Robert C. Martin and the Clean Code movement in the late 2000s, distilling lessons the Extreme Programming community had been living since Kent Beck's original SUnit and JUnit in the late 1990s. But the deeper idea — that tests should verify observable behaviour, not internal structure — runs straight back to David Parnas's 1972 work on information hiding: a module should expose a stable interface and hide its volatile internals. A brittle test is, at bottom, a violation of Parnas's principle committed by the very code meant to protect it.

Self-validating means the test decides pass or fail by itself, with a real assertion — no human squinting at printed output to judge whether it "looks right." If a person has to interpret the result, it isn't a test; it's a chore that will be skipped. And Timely means you write the test close in time to the code, ideally alongside or (in TDD) just before it — because "I'll add tests later" is one of engineering's most reliable lies. Later, the context is gone, the deadline is closer, and the untested code has already shipped a bug.

Rendering diagram…

Hold FIRST and the behaviour-versus-implementation rule together and you have the whole philosophy of Part II in a single frame: test observable behaviour, keep the volatile world held still, and make failure fast, deterministic, and loud. Everything remaining in this Part — matchers, edge cases, parametrisation, coverage — is technique in service of that philosophy.

Common Mistakes

  • Testing private methods directly. If you're reaching for a private helper to test it in isolation, you've coupled your test to internal structure that refactoring will move. Fix: test the private behaviour through the public method that uses it; if it feels too important to test only indirectly, that's a design smell telling you it wants to be its own public unit.
  • Asserting on call counts and argument shapes of mocked collaborators. expect(mock).toHaveBeenCalledTimes(2) freezes an interaction detail that isn't part of the contract. Fix: assert on the outcome the collaborator produces, not the choreography of getting there; reserve interaction assertions for genuine contract points (e.g. "the payment gateway was charged exactly once").
  • Snapshotting everything. Giant snapshot tests capture entire rendered structures, so any cosmetic change turns them red and everyone just re-runs --update without reading the diff. Fix: assert on the specific text or values that matter; use snapshots sparingly and only for output that's genuinely meant to be pinned.
  • One test that asserts fifteen things. When a mega-test fails, you can't tell which behaviour broke, and it usually fails for a reason unrelated to what you changed. Fix: one behaviour per test, named for that behaviour, so a red result reads like a sentence.
  • Coupling to data-structure shape the caller never sees. Asserting on an internal cache's exact keys or an intermediate array's order breaks the moment you optimise storage. Fix: assert only on what the public interface exposes.
  • Chasing a coverage number instead of catching bugs. Adding assertion-free tests to turn a line green is theatre; you'll meet this trap head-on in the coverage chapter. Fix: aim tests at behaviour that could plausibly break, not at colouring the coverage report.
  • Depending on test order or shared fixtures. A test that only passes after another mutated a shared object is a time bomb. Fix: each test builds its own fixtures in beforeEach; never let state leak between them.
Thought experiment

Imagine two engineers hand you their test suites for the very same module and challenge you: without reading the production code, tell me which suite is better. You may only do one thing — refactor the module's internals (rename helpers, reorder logic, swap a loop for a map) without changing a single observable behaviour, then re-run both suites. What happens to each, and what does that tell you? Now the harder half: you introduce one genuine bug that changes an output a caller depends on. Which suite catches it? The suite you'd want is the one that ignored the refactor and caught the bug — and notice you could rank them without ever reading the production code, purely by how they responded to change. That response is the definition of quality here.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
What makes a unit test "good"? Can you name the FIRST properties?
Model answer — A good unit test verifies one observable behaviour through the code's public interface and fails only when that behaviour actually breaks. FIRST names the qualities: Fast (milliseconds, so you run it constantly), Isolated (no shared state or order dependence), Repeatable (same result every run — no clock, randomness, or network), Self-validating (a real assertion decides pass/fail, no eyeballing logs), and Timely (written with the code, not "later"). The one I guard hardest is Repeatable, because a flaky test destroys trust in the whole suite.
Intermediate
A colleague's test broke when you renamed a private method, even though behaviour was unchanged. What's wrong, and how would you fix it?
Model answer — The test is coupled to implementation — it knows about a private method that callers never see, so it broke on a refactor that changed nothing observable. That's brittleness, and its cost is that people stop refactoring for fear of false reds. The fix is to test the private helper's behaviour through the public interface that exercises it, asserting on the outcome a caller consumes rather than the internal call. If the helper feels too important to test only indirectly, that's a design signal that it wants to become its own public unit with its own tests — a pull TDD uses deliberately.
Senior
Your team's suite has 90% coverage but nobody dares refactor because tests break constantly. Diagnose and lead the fix.
Model answer — High coverage with high brittleness means the tests assert on internals — private calls, intermediate state, over-specified mock interactions — so they've inverted their purpose from enabling change to preventing it. Coverage measures execution, not assertion quality, so the number is hiding the rot. I'd audit for the anti-patterns: assertions on private methods, toHaveBeenCalledTimes on non-contract collaborators, giant snapshots, order-dependent fixtures. Then rewrite tests to assert on observable outcomes through the public API, mocking only genuine external boundaries. I'd expect fewer tests, similar coverage, and — the real metric — engineers refactoring without false reds. I'd also add a team norm: every mock and every private-touching assertion must justify itself as a real contract point, and I'd track "false red rate" as a health signal, because a suite's value is regressions caught per false alarm raised.
Company lens — how the same topic gets asked
micro1In the AI-screened live round, narrate the behaviour-vs-implementation decision out loud as you write: "I'll assert on the return value, not the internal loop, so a refactor won't break this." They score reasoning and communication as much as the code.
GoogleExpect a follow-up on hermetic, deterministic tests and testing the invariants of a function rather than one example — they probe whether you distinguish contract from implementation at scale.
MetaPragmatism: "which of these tests would you keep and which delete?" They want you to spot the brittle, low-value tests and protect CI speed by not over-asserting.
AmazonFramed via Ownership and Insist on the Highest Standards: "how do you prevent a whole class of brittle-test problems on your team?" Talk process — review norms for mocks, the false-red metric, testing behaviour as an operational discipline.

Hands-on

Hands-on

Below is a formatMoney function and a starter suite containing one brittle test that couples to internals. Your job: (1) delete or rewrite the brittle test so it asserts on observable behaviour instead, and (2) add tests for the empty/zero and negative cases. Then prove your tests are robust — rewrite the body of formatMoney (e.g. build the string a different way) and confirm your tests stay green while still catching a genuine bug if you break the output. Assert on the returned string, never on how it was built.

Tests — Run & Watch Them Pass

Run to execute the tests…

Summary

  • A unit is a unit of behaviour, not a unit of code — the smallest observable rule, tested through its public interface.
  • Test the promise, not the how. Assert on inputs and outputs a caller can see; never on private helpers, intermediate state, or the internal shape of things.
  • Coupling to internals is the root evil. Every brittle test is a stable thing (the test) welded to a volatile thing (the implementation), so it breaks on refactors that changed nothing observable.
  • A good test is silent through refactors and loud through regressions — deaf to how the code is written, hair-triggered on what it does.
  • More assertions is not more virtue. A suite is measured by real breakage caught per false alarm raised; noisy suites train people to ignore red.
  • FIRST — Fast, Isolated, Repeatable, Self-validating, Timely — are the properties of a test you can actually trust, and Repeatable (no flakiness) is the one whose violation does the most damage.
  • Over-mocking is a brittleness factory: every mock bets that an interaction is part of the contract, so bet only when it truly is.

You now know what to assert about — behaviour through the public interface — but not yet the full vocabulary for how to say it. toBe or toEqual? How do you check a float without exact equality, a thrown error, a substring, or the negation of any of these? The next chapter, Assertions & Matchers, hands you the whole toolbox and shows you which tool the failure message thanks you for choosing.

Editorial reflection

The engineers I trust most are strangely relaxed about their own code being wrong and strangely strict about their tests being honest. They know a green suite is only as truthful as its assertions, and a brittle one is a comfortable lie — it feels like safety while quietly making the codebase unchangeable. The craft you've started here is really a form of humility: you're admitting that your internals will change, that future-you will want to refactor, and that the kindest thing present-you can do is write tests that let that happen. Test the promise. Free the internals. That freedom is the whole reason we test at all.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.