Testing as Design Feedback
Code that is miserable to test is usually hiding a dependency or mixing two jobs. That is real information — and it is not a licence to bend the production design around a test runner.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
This code is painful to test. Is that a testing problem, a design problem, or neither?
Someone asks for a test on sendRenewalReminders(). Writing it needs a database, a live clock, an SMTP server and the feature-flag service, and the resulting test takes forty seconds and fails on the first of the month.
Testing is an activity that happens after the design is finished. If a test is hard to write, reach for a heavier tool: start a container, freeze the global clock, patch the module loader so the SMTP client is replaced at import time.
Every one of those tools works, and each one suppresses the signal. The difficulty was information about the code, and the tool spends money to stop hearing it.
- Every one of those tools works, and each one suppresses the signal. The difficulty was information about the code, and the tool spends money to stop hearing it.
- A globally frozen clock is shared mutable state across the whole suite. The first test that forgets to restore it makes an unrelated test fail, and the failure appears in a file nobody touched (Hidden Global State).
- Patching the module loader couples the test to the import graph. Moving the SMTP client to a different file is a pure structural change with no behaviour change, and it breaks thirty tests — so the suite now argues against the refactoring it exists to enable.
- The deeper cost is not in the test at all. The same hidden dependency that made the function untestable is what makes it unexplainable in an incident: nobody can say what "now" was, or which flag state produced the send.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The function is in production and demonstrably works; nothing here is a bug report.
- CI has a twelve-minute budget for the whole suite, so a forty-second test is not free at any scale.
- The language gives no free virtualisation of time or I/O — anything ambient has to be made explicit by hand.
- The team has shipped for two years without this test, so "we cannot ship until it is testable" is not an available argument.
- Whatever is done to make the code testable must not change observable behaviour — that is the definition of the move (What Refactoring Actually Is).
- A reminder is sent at most once per subscription per billing cycle. That is true with tests, without tests, and regardless of how the test is written.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Something owns the decision "which subscriptions are due for a reminder today" — and that is a pure function of a subscription list and a date.
- Something else owns loading subscriptions, sending mail, recording that a send happened, and knowing what time it is.
- The test owns asserting behaviour at a boundary. It does not own asserting which internal calls happened in which order — that is the design mirrored back at you.
- The seam falls between deciding and doing, because that is where the ambient dependencies actually enter (Functional Core, Imperative Shell).
- Time, randomness and identity are boundaries even though they do not look like I/O — they are inputs pretending to be language features (Time as a Dependency, Randomness as a Dependency).
- The boundary is drawn for reasoning first. If it happens to be the boundary the test wants too, that is a good sign; if the only argument for it is the test, that is the warning in this lesson.
Three different pains, three different fixes
"Hard to test" is not one problem. It is at least three, they feel identical from inside the frustration, and each has a fix that makes the other two worse. Naming which one you have is most of the work.
The tell is what the test setup is full of. Lines that construct the world point at hidden dependencies; lines that construct data point at a missing type or a mixed responsibility; lines that arrange doubles point at a boundary in the wrong place.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| The test needs a database, a clock, an HTTP server and a queue to assert one rule | Forty lines of setup before the first assertion; the test is slow and occasionally fails at midnight | The rule and the effects live in the same function, so exercising the rule drags in the world | Split deciding from doing; the rule becomes a pure function of its inputs (Functional Core, Imperative Shell) |
| The test builds an eleven-field object to check a two-field rule | Enormous fixtures, copied between test files and slowly diverging | The function takes a whole aggregate because it was convenient, not because it needs one (Long Parameter List) | Narrow the parameter to what the rule reads, or introduce the value object the rule is actually about (Value Objects) |
| The test replaces four collaborators to observe one outcome | The test reads as a transcript of the implementation and breaks on every refactor | The unit under test is orchestrating; the boundary is drawn inside a cluster of things that change together (Mocking) | Move the boundary out to the edge of the cluster and assert the outcome instead of the calls |
| The test cannot reach the behaviour without calling a private method | Someone proposes widening visibility "just for tests" | Either the behaviour has no public expression — a genuine design finding — or it is an implementation detail that does not deserve a test | Decide which. Extract it to its own unit with a real public contract, or delete the test (Designing a Module Interface) |
| The test passes locally, fails in CI, passes on re-run | Flakiness blamed on infrastructure | Ambient shared state — a static, a global clock, a singleton cache — which is a production property before it is a test property | Make the state owned and passed, not ambient (State Ownership) |
Deciding and doing
The single most productive response to test pain is also the one that improves the code on its own terms: separate the part that computes an answer from the part that changes the world. The first is a function of its arguments and needs nothing; the second is short, boring, and honestly needs a real integration test.
Notice what changed and what did not. The rule is identical. The behaviour is identical. What moved is which inputs are visible in the signature — and that is exactly the thing that was making both the test and the incident review hard.
async function sendRenewalReminders() {
const subs = await db.subscriptions.findAll()
const today = new Date() // ambient
for (const s of subs) {
if (!flags.isOn('reminders')) continue // ambient
if (daysUntil(s.renewsOn, today) === 7) {
await mailer.send(reminderFor(s))
await db.marks.insert(s.id, today)
}
}
}// decide: no db, no clock, no mailer
export function dueForReminder(
subs: Subscription[], today: LocalDate, leadDays: number,
): Subscription[] {
return subs.filter((s) => daysUntil(s.renewsOn, today) === leadDays)
}
// do: thin, and integration-tested once
async function sendRenewalReminders(clock: Clock) {
const due = dueForReminder(await repo.all(), clock.today(), 7)
for (const s of due) await notify(s)
}The rule is now a total function of three visible inputs, so a test for "three days instead of seven" needs no infrastructure and no doubles. The gain is not primarily in the test: the same change makes the production question "why did this customer get a reminder?" answerable from a log line, because everything the decision used is now something you can print.
The half of §159 that says stop
The rule "listen to the tests" has a boundary, and teams that only learn the first half build a characteristic kind of codebase: every collaborator behind an interface with exactly one implementation, a container to wire them, and a test suite that mirrors the object graph. Every one of those decisions was individually defensible and the total is worse than what it replaced.
The discriminating question is cheap to ask and hard to dodge: would this change still be an improvement if the test suite disappeared tomorrow? Splitting a rule out of a loop survives that question — it makes the rule findable, nameable and loggable. Adding an interface so a mock can be injected usually does not.
looks like An interface with exactly one production implementation, whose only other implementer is a test double; a constructor parameter that is never substituted outside tests; a method made public with a comment saying // visible for testing.
suggests The seam was cut to satisfy a test runner rather than to contain a change. The abstraction has no second case, so it encodes no variation and teaches the reader nothing about the domain (What an Abstraction Actually Is).
fix Delete the interface and call the concrete thing. If a test then becomes hard, re-read the table above: the fix is usually to move the test boundary outward, not to put the interface back (What a Unit Is).
How to build it
Most important first.
- Name the pain precisely before fixing it. "Hard to test" covers three distinct problems with three different fixes, and the wrong fix makes it worse — see the table below.
- Separate deciding from doing.
dueForReminder(subscriptions, today)returns a list;sendAll(list)performs the effect. The first needs no infrastructure at all and holds the rule everyone actually cares about (Side Effects). - Make ambient inputs into parameters. A clock passed in is a dependency you can see in a stack trace, in a signature and in a log line, not only in a test (A Deterministic Core).
- Fix the dependency, not the visibility. Widening a private method to public so a test can reach it changes the module's contract to serve the test and leaves the real problem untouched (Exposing Too Much).
- Stop when the design is better on its own terms. §159 is explicit about the other half: do not distort production design solely to satisfy unit tests. If the only justification for an extra interface, an extra layer or an extra indirection is "so we can mock it", the change has stopped being design feedback and become a testing tax paid by every future reader (Premature Abstraction).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: "reminders should go out three days before renewal instead of seven" costs a change inside a forty-second test that needs a database, plus a manual verification because nobody trusts the test that fails on the first of the month. Call it half a day, most of it not typing.
- After: the same change is one line in
dueForReminderand one new case in a test that runs in a millisecond. The next four requirements about *when* a reminder goes out are all inside that function. - What did not get cheaper: "reminders must be sent through the new provider" still lives in the shell and still needs an integration test. The split bought change locality along the rule axis and nothing along the delivery axis — which is the honest limit, and the reason you choose the axis deliberately (Choosing the Model).
- The permanent cost is one extra hop in every stack trace and one more parameter at the call site, paid by every reader forever.
- Splitting decide from do adds a level of indirection and a data structure between them. For a function that will never change again, that is pure cost.
- Explicit clocks and ids are more parameters at every call site, and they make the signature uglier in exchange for making the dependency visible. Reasonable engineers dislike this, and they are not wrong about the ugliness.
- Treating testability as design feedback biases towards small, pure, parameterised units — which is genuinely better for rules and genuinely worse for code that is mostly orchestration, where the split produces two things that always change together (Cohesion).
What can go wrong
- The signal is over-read: every awkward test becomes a refactor, and the codebase accumulates ports, adapters and interfaces with one implementation each, all justified by tests nobody would otherwise have needed (Speculative Generality).
- The signal is under-read: the container is started, the test passes, and the hidden clock dependency stays until it causes a production incident on a leap day.
- The refactor to make the code testable is performed on code with no tests, which is the one order in which it is genuinely dangerous (Refactoring Without Tests).
- The split lands, but the rule ends up in the shell anyway —
dueForReminderreturns everything andsendAllfilters — so the pure function is a decoration and the decision is still untestable.
- After the split, the decision function depends on nothing — no clock, no database, no config. That is the point, and it is what makes it cheap to test and cheap to reason about.
- The shell depends on everything volatile, and is deliberately thin enough that the integration test covering it is short (Volatile Dependencies).
- The test depends on the boundary, not on the internals. A test that depends on internals is a second, worse consumer of a private interface.
- "If it is hard to test, the design is wrong." Sometimes the design is fine and the *test* is wrong — it is being written at the wrong boundary, or it is trying to unit-test something that is only meaningful integrated (Where a Test Must Be Real).
- "So make everything injectable." Injecting a dependency you will never substitute buys nothing and costs a constructor parameter and a wiring line forever (Volatile Dependencies).
- "TDD produces good design automatically." It produces a design shaped by the tests you happened to write first, which is a real force and not a guaranteed improvement. The feedback is worth listening to; it is not an oracle.
- "This means no test may touch a database." It means know which boundary you are testing. A repository whose only job is a query is meaningless without the query engine, and mocking it tests nothing (Where a Test Must Be Real).
- hidden-global-state
- god-object
Testing it, and how it ages
- Test the decision function directly, with a fixed date and a handful of subscriptions. No infrastructure, no doubles, no setup — if it needs any, the split is not finished (What a Unit Is).
- Test the shell once, end to end, against a real database and a mail server you can inspect, because that is where the abstractions are actually load-bearing (Where a Test Must Be Real).
- Assert the invariant, not the calls: run the shell twice for the same cycle and assert one mail, rather than asserting
sendwas called once (Idempotency by Design).
- The decision function accumulates rules — trial subscriptions, paused ones, dunning states — and stays testable, because none of them need infrastructure. That accumulation is what pays for the split.
- The shell tends to grow quietly and stop being thin. When it has branches of its own, the boundary has drifted and part of the decision has leaked back out (Invariant Leaks).
- It stops being the right shape if the decision genuinely needs to query as it goes — a rule that depends on data you cannot load up front turns the pure function into a chatty one, and at that point a repository interface earns its place on merit rather than for the test (N+1 as a Design Problem).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat an ambient dependency is invisible to a caller and therefore awkward to substitute is a property of the dependency, not of a language or framework, so the diagnostic holds broadly.
- LANGUAGE-SPECIFICIn Python or Ruby, monkeypatching makes almost anything substitutable without changing the design at all, so the pain signal is much weaker and teams get less feedback for free; in Go, Rust or Java the compiler forces the dependency to be explicit before it can be replaced, so the same code is louder about the same problem. The advice is identical; the volume differs.
- CONTESTEDThe strongest opposing position — "test-induced design damage", argued forcefully by DHH and by many working Rails and Django teams — holds that a large fraction of ports, adapters, repository interfaces and service objects exist purely to satisfy isolated unit tests, and that a direct, integrated design with slower tests against a real database is simpler, cheaper to read and no less correct. That case is strong, and it is strongest exactly where this lesson is weakest: code that is mostly orchestration over a framework. The distinction worth holding is whether the change would still be an improvement if the tests vanished.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — coverage, flakiness, fixture management, test-suite performance and mutation testing are the craft of testing itself. This lesson only reads the design signal a test emits; everything about writing good tests lives there.
- — Programming Languages & Runtime Internals — what a language makes substitutable without changing the design (monkeypatching, dynamic dispatch, link-time seams, compile-time generics) decides how loud this signal is, and that is a runtime-model question.