How Writing the Test First Changes the Design
The red-green-refactor loop looked like a discipline for verifying code: write a failing test, make it pass, clean up. But something quietly happened while you were following the loop. The moment you wrote the assertion before the implementation, you had to answer a question you would otherwise have deferred: what does calling this thing actually look like? You had to name it, decide what it takes in, decide what it hands back, and decide how to set it up — all before a single line of it existed.
That is not testing. That is design. This page is about the claim that makes TDD interesting rather than merely tidy: writing the test first is a design activity in disguise. The tests are a happy by-product; the real output is code shaped by having been used before it was built.
Keep the test-first mindset from this part’s overview in view as you read: the reason we care about design here is not aesthetics. A well-shaped unit — small, with its dependencies visible and injectable — is one you can point a test at from every angle, including the awkward edge cases. Testable structure is what makes thorough edge-case coverage affordable. So the design pressure this page describes feeds directly into the throughline of the whole book: earning justified confidence that software works, and being able to reach the inputs that break it.
You are the first client of your own API
Section titled “You are the first client of your own API”Every function, class, and module has clients — the code that calls it. Normally the first client shows up after the code is written: you implement parse_config(), ship it, and a week later someone tries to call it and discovers it needs a global logger initialised first, throws instead of returning an error, and returns a tuple whose fields you can never remember the order of. The awkwardness is now baked in. Changing it means changing every caller.
Test-first inverts the order. The test is the first client, and it exists before the implementation has hardened:
def test_parses_a_valid_config(): config = parse_config("port = 8080\nhost = localhost") assert config.port == 8080 assert config.host == "localhost"Look at what you were forced to decide by writing three lines:
- The function takes a string, not a file path. (You just made it testable without touching disk — a design decision you would have gotten wrong if you had written the body first and reached for
open().) - It returns a config object, rather than mutating a global or printing.
- The result has named fields you can read back.
None of that is verification. All of it is interface design, and you did it while the cost of changing your mind was zero. The test is a usability test for your API, run by its most demanding user: you, five seconds before you have to implement it.
Contrast that with what you would likely have written if the body came first. Starting from the implementation, the natural first line is with open(path) as f: — and now the function takes a file path, does I/O, and can only be tested by creating real files on disk. The interface was shaped by the convenience of the implementer, not the needs of the caller. Test-first flips whose convenience wins. Because the caller (the test) speaks first, the interface is optimised for being called — which, it turns out, is also what every other caller wanted. The habit is subtle but decisive: whoever writes first decides the shape.
test-after: design ──► implement ──► (freeze) ──► write test ▲ │ └──── awkwardness already baked in ◄─────┘
test-first: write test ──► feel the awkwardness ──► fix the design ──► implement ▲ │ └──────── cost of changing = near zero ──┘Hard to test is a design smell, not a testing problem
Section titled “Hard to test is a design smell, not a testing problem”Here is the reframing that does most of the work in this chapter. When code is painful to test, the instinct is to blame the test — “this is hard to test, testing is annoying.” Flip it. The difficulty is the code telling you something about its design. The test is a diagnostic, and the pain is the readout.
Three of the most common “this is untestable” complaints are really three named design smells:
Hidden dependencies
Section titled “Hidden dependencies”def charge_customer(amount): gateway = StripeGateway() # reaches out and grabs a real dependency return gateway.charge(amount)To test this you must either hit Stripe for real or perform surgery to intercept the constructor. The test is hard because the function reaches out and grabs its dependency instead of being handed it. The dependency is hidden inside the body. That is not a testing obstacle; it is tight coupling, and it hurts every future caller who wanted to use a different gateway, a sandbox, or a fake.
Global and shared state
Section titled “Global and shared state”def next_id(): global _counter _counter += 1 return _counterThis is hard to test because tests must run in a specific order and reset a global between them, and one forgotten reset makes a different test fail mysteriously. The pain is real, but the disease is the shared mutable state, which will bite you identically the first time two requests run concurrently in production. The test found a concurrency bug before you shipped it.
Functions that do too much
Section titled “Functions that do too much”A 200-line function that reads a file, validates it, transforms it, calls three services, and writes a report needs an enormous amount of setup to test, and every assertion is entangled with every other. The test is hard because the function has five responsibilities. Splitting it so each can be tested in isolation is exactly the split you wanted for readability and reuse anyway.
You can even feel this smell as a growth in the test’s arrange section. When the setup needed to get a function into the state you want to assert on balloons past the assertion itself — ten lines of scaffolding for one line of check — the code is telling you it has too many preconditions, which is another way of saying too many responsibilities. Test-first surfaces this early because you write the painful setup before the tangled implementation exists, so you feel the pain while the fix is still “design the function to need less setup” rather than “unpick a function that is already written.”
In all three cases the test did not create the problem — it surfaced one that was already there and would have cost you far more later. That is the whole value: testability and good design are the same property viewed from two angles.
The reason this equivalence holds is not a coincidence of tooling. A test is, mechanically, just another caller — one that wants to invoke your code in isolation, with controlled inputs, and inspect the result. Everything that makes code hard for a test to call in isolation is the same thing that makes it hard for a human to reason about in isolation: you cannot follow a function whose behaviour depends on hidden globals, you cannot reuse a function welded to a specific gateway, and you cannot understand a function that does five unrelated things. Readability, reusability, and testability are three names for “can be understood and used on its own.” So when a test complains, it is complaining on behalf of every future reader and caller who has no voice yet. That is why the smell is worth listening to even if you did not care about tests at all.
Seams and dependency injection: designing for testability
Section titled “Seams and dependency injection: designing for testability”If “reaching out and grabbing a dependency” is the smell, the cure has a name: a seam. A seam is a place where you can change what a piece of code does without editing that piece of code — a point where behaviour can be substituted. Tests need seams so they can swap a real database for an in-memory one, a real clock for a fixed time, a real payment gateway for a fake that records what it was asked to do.
The simplest, most durable way to create a seam is dependency injection: instead of a function constructing its dependencies, it receives them as arguments.
# no seam — dependency is welded indef charge_customer(amount): return StripeGateway().charge(amount)
# with a seam — the dependency is injecteddef charge_customer(amount, gateway): return gateway.charge(amount)The second version costs one parameter and buys you everything: the test passes a fake gateway, production passes the real one, and a future integration with a second provider passes a third. Notice that nobody set out to “add dependency injection.” Writing the test first made passing the gateway the path of least resistance, and DI fell out as a consequence. That is the pattern for the whole chapter — good structure is not imposed by a rule, it is pulled into existence by the pressure of being the first caller.
It is worth being precise about what “fake” means here, because it is a spectrum and the wrong choice re-introduces the very coupling you were escaping. A stub returns canned answers (“charge always returns success”). A fake is a real, working, lightweight implementation (an in-memory gateway that actually records balances). A mock additionally asserts on how it was called (“charge was invoked exactly once with amount=500”). Prefer stubs and fakes for the seam, and reach for mocks sparingly — a test that asserts on the exact sequence of internal calls is testing implementation, not behaviour, and will break the moment you refactor even though nothing observable changed. The seam gives you the freedom to substitute; the discipline is to substitute something that checks outcomes, not mechanics.
The repository’s own Rust code shows the same seam without any framework or ceremony. In rust/kvlite/tests/server.rs, the integration test never lets the server open a hard-coded file or bind a fixed port. Both are injected:
// The WAL path is chosen by the test (a temp file), not hard-coded in the db.let (db, _) = Db::open(&path).unwrap();
// The listener is created by the test on port 0 (OS picks a free port),// then handed to the server — the server does not bind its own address.let listener = TcpListener::bind("127.0.0.1:0").unwrap();let addr = listener.local_addr().unwrap();thread::spawn(move || { let _ = server::serve_on(listener, db);});Db::open(path) and serve_on(listener, db) are seams. Because the path and the socket are parameters, the test can run in isolation, in parallel, with a throwaway file, on a random port — no global config, no fixed port collisions, no shared state between test runs. A design that had serve_on open its own socket from a constant would be untestable in exactly the ways this one is testable. The tests did not bolt these seams on afterward; the code was born with them because it was written to be called by a test first.
Under the hood — the many faces of a seam
Section titled “Under the hood — the many faces of a seam”Dependency injection is the most common seam, but it is not the only one. Testable design is really about always leaving one substitutable point between your logic and the messy outside world:
- Constructor / parameter injection — pass the dependency in. The default, shown above.
- Interfaces / traits / protocols — depend on an abstraction (a
Clock, aGatewaytrait) rather than a concrete type, so any implementation slots in. - Higher-order functions — pass behaviour as a function:
retry(operation, now=fake_clock). - Pure core, thin shell — push all I/O to the edges and keep a pure, deterministic core that takes data and returns data. A pure function needs no seam because it has no hidden dependency to substitute; the whole “functional core, imperative shell” style is testability made structural.
The through-line: a seam is anywhere you can say “in the test, do this instead.” Code with no seams can only be tested by running the whole world. Code that is all seams is a mess of indirection. TDD keeps you near the healthy middle, because you only add a seam when a test actually demands one — which brings us to the last idea.
There is a nice self-correcting property here. If you find yourself wanting many seams to test one unit — a fake clock, a fake gateway, a fake filesystem, a fake queue, all for a single function — that itself is a smell: the function is doing too much and touching too many parts of the outside world. The healthy response is not “write more mocks” but “split the unit,” pushing the I/O to the edges and leaving a pure core that needs no seams at all. So even the number of seams a test demands is design feedback. A unit that needs one injected dependency is usually fine; a unit that needs six is usually six units wearing a trench coat.
Emergent design and YAGNI: build only what a test demands
Section titled “Emergent design and YAGNI: build only what a test demands”TDD has a strict rule that sounds like a constraint and turns out to be a design philosophy: you may only write production code to make a failing test pass. No test is asking for it? You do not write it. This is YAGNI — You Aren’t Gonna Need It — enforced mechanically rather than by willpower.
The consequence is emergent design: the architecture is not drawn up front and then filled in; it grows, one demanded behaviour at a time. You do not build the pluggable multi-provider payment abstraction on day one. You build charge_customer(amount, gateway) because one test needed to substitute a fake gateway. If a second provider ever shows up, the seam is already there and you extend it — driven, again, by a test. If it never shows up, you never paid for the abstraction you imagined you would need.
This matters because speculative generality is one of the most expensive habits in software: the elaborate framework built for requirements that never arrive, now sitting between every developer and the simple thing they actually want to do. TDD’s rhythm starves it. Every line of code has to justify its existence by pointing at a red test. Structure that no test demands is structure you are guessing at — and YAGNI says stop guessing.
The honest caveat is that emergent design is not a licence to never think ahead. Some decisions — a public API contract, a data model that will be hard to migrate, a security boundary — are expensive to change later and deserve deliberate up-front design even under TDD. Emergent design shines for the internal structure of a component, where refactoring is cheap and tests protect you; it is weakest for decisions with high inertia. The skill is knowing which is which, and letting tests drive the reversible parts while thinking carefully about the irreversible ones.
The design that emerges is usually smaller than the one you would have drawn on a whiteboard, and it fits the real requirements better, because every piece of it was pulled into existence by a concrete, executable example rather than an imagined future.
A worked micro-example of the growth makes it concrete. Suppose the task is “format a duration for a log line.”
Test 1: format(0) == "0s" ──► minimal body: return f"{n}s" Test 2: format(65) == "1m5s" ──► now needs minutes; add divmod by 60 Test 3: format(3661) == "1h1m1s" ──► now needs hours; add divmod by 3600 Test 4: format(-5) raises ──► a seam? no — a pure guard clauseAt no point did a test ask for days, weeks, locale-aware output, or a pluggable formatter strategy — so none of that exists. If a requirement for days ever arrives, it arrives as a test, and the code grows to meet it. The final design is the smallest thing that satisfies every example that was actually written down. That is emergent design: not the absence of design, but design that is demanded rather than guessed.
The design pressure of test-first vs the frozen design of test-after
Section titled “The design pressure of test-first vs the frozen design of test-after”Now contrast the two orderings directly, because the difference is the entire argument of this page.
Test-after writes the implementation first and the test second. By the time the test runs, the design is already frozen: the interface exists, callers may already depend on it, and the constructor already reaches out and grabs its own gateway. If the test is now hard to write, your realistic options are (a) do painful surgery to add a seam after the fact, or (b) shrug, mock the whole world, and write a test so entangled with implementation details that it breaks on every refactor. Most teams under deadline pick (b). The design smell survives, and the test that was supposed to protect you instead cements the bad structure in place. Test-after tests can catch regressions, but they exert almost no pressure on design, because they arrive after the design decisions are made and paid for.
Test-first applies the pressure at the only moment it is cheap: before the code exists. Every awkward interface, hidden dependency, and oversized function shows up as test pain while changing the design still costs one edit. You feel the friction, you relieve it by improving the design, and only then do you implement. The test is not judging finished work — it is steering the work as it forms.
writes the test ... design is ... pressure on design test-first before the code still soft HIGH — steers the design test-after after the code already frozen LOW — can only verify itThere is a subtler cost to test-after that the table hides. Because the code already exists, the tester’s mental model is anchored to the implementation they are looking at, so test-after tests tend to mirror the code’s structure — one test per method, assertions on internal state — rather than describe the behaviour a user cares about. Test-first tests, written before there is any implementation to mirror, are forced to describe behaviour, because behaviour is the only thing that exists yet. This is why test-first suites so often read as executable specifications while test-after suites read as change-detectors: it is a direct consequence of when the test was written relative to the code.
Neither ordering is morally superior in all cases, and the next page’s discussion of TDD’s honest benefits and criticisms will be fair about when test-first is not worth it. But the specific thing test-first buys — and test-after structurally cannot — is design feedback delivered while the design can still change for free.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because interfaces harden fast and get expensive to change, and the cheapest moment to notice an awkward design is before the code exists. Test-first exists to move design feedback to that moment.
- What problem does it solve? It stops you from freezing a bad interface, a hidden dependency, or a bloated function in place — by making you the first, most demanding client of your own API while changing your mind is still free.
- What are the trade-offs? It is slower on the first pass and demands the skill of designing for testability; done thoughtlessly it can push you toward over-mocking and testing implementation details rather than behaviour.
- When should I avoid it? When the design is genuinely throwaway (a spike, a one-off script), when the “unit” is dominated by external I/O with little logic to shape, or when you are exploring and do not yet know the interface you want — explore first, then let tests harden it.
- What breaks if I remove it? You lose the design pressure entirely. Tests become test-after: still able to catch regressions, but arriving after every design decision is frozen, so they cement whatever structure — good or bad — was already there.
Check your understanding
Section titled “Check your understanding”- In what concrete sense are you the “first client” of your code when you write the test first, and why does that matter for interface design?
- A colleague says “this class is really hard to test.” Reframe that statement as a claim about the design rather than about testing, and give one likely underlying smell.
- What is a seam, and how does dependency injection create one? Use the
charge_customer(amount, gateway)example. - Point to the seams in the kvlite
server.rssnippet. What would become untestable if the server opened its own file and bound its own fixed port instead? - Explain how TDD’s “only write code to pass a failing test” rule connects to YAGNI and emergent design, and contrast the design pressure of test-first with test-after.
Show answers
- The test calls your function/class before it exists, so you must decide its name, inputs, outputs, and setup as a user of the API rather than its author — surfacing awkward interfaces while the cost of changing them is essentially zero, before any caller depends on them.
- Reframe: “this class is hard to test” means “this class has a design that resists being called in isolation.” Likely smells include a hidden dependency (it constructs its own collaborators), shared/global mutable state, or a single unit doing too many things (too many responsibilities to set up).
- A seam is a place where you can change what code does without editing that code — a substitutable point. Passing
gatewayas a parameter (instead ofcharge_customerconstructingStripeGateway()itself) makes the gateway substitutable: the test injects a fake, production injects the real one, all without touchingcharge_customer’s body. - The seams are
Db::open(&path)(the WAL path is injected, so the test uses a temp file) andserve_on(listener, db)(the listener is created by the test on port 0 and handed in). If the server opened a hard-coded file and bound a fixed port, tests could not run in isolation or in parallel, would collide on the port, and would share on-disk state between runs — all the untestability this design avoids. - The rule forbids writing any production code that no failing test demands, which is YAGNI enforced mechanically: you never build speculative structure. Design therefore emerges one demanded behaviour at a time and stays as small as the requirements. Test-first applies design pressure while the design is still soft (it steers the code as it forms); test-after applies almost none, because the design is already frozen by the time the test is written, so it can only verify the existing structure.