THIS EXPLANATION
THE ROOM
CDA·218 Computing, Data & AI 6 MIN · 8 STATIONS

Testability as a design property

A Socratic walk-through of testability as a design property — reasoned out one step at a time, not lectured.

abcdefgh
a

The question we started with

THE QUESTION #

Why is code that is hard to test usually badly designed rather than just hard to test?

You sit down to write a test for a function and find you cannot. Not that the test fails — you cannot even get to the point of running it. To call the function you must first stand up a database, set an environment variable, and let it reach the network. The natural reaction is annoyance at testing: the tools are awkward, the harness is clumsy, this is overhead.

But hold that reaction up to the light for a moment. Nothing about writing a test changed the code. The difficulty was already there, sitting in the code, before anyone thought about testing it. So what exactly did the attempt to test reveal?

b

Reasoning it through

REASONING #

Ask what a test actually requires. Only three things: you must be able to put the code in a known starting condition, run it, and observe what happened. That is all. Now notice that each of those is a requirement about the code's relationship with the rest of the world, not about testing at all.

Take the first. To put code in a known condition you must be able to construct it without dragging half the system along. If creating one object requires four collaborators, each requiring three more, you have discovered that the object cannot exist independently of them. That is not a testing fact. It is a fact about coupling, and it applies just as much to a developer trying to reuse the object elsewhere, or to reason about it in isolation while fixing a bug.

The second: running it. Suppose you can only reach the interesting behaviour through five layers of unrelated machinery — an HTTP route, a session, a template. Then the interesting behaviour has no seam of its own. What does that tell you about someone who wants to call that logic from a scheduled job instead of a web request?

And the third: observing the result. If the function returns nothing and its only effect is a row written somewhere far away, then to check it you must go looking. Ask why that is uncomfortable. It is uncomfortable because the effect is invisible at the point of the call — which means anyone reading the calling code cannot see what it does either.

Do you see what has happened? Every complaint about testability turned out to be a restatement of an ordinary design complaint: too much coupling, no clear seam, hidden effects, too many responsibilities in one place. Testing did not create these; it forced us to meet them, because a test is simply the first client of the code that is not embedded in the running system, and so the first one that cannot paper over the dependencies by inheriting them from context.

That is the honest way to put the claim. Testability is not a separate virtue that good code happens to have. It is a proxy — a cheap, mechanical signal that detects properties we already wanted but that are otherwise hard to measure. We have no dial for "well-coupled." We do have "can I instantiate this in three lines."

And a proxy deserves the scepticism proxies always deserve. Two caveats matter. First, the signal is not perfect: some code is genuinely hard to test for reasons unconnected to its design — a GPU kernel, a hardware driver, a distributed timing behaviour. Difficulty there is intrinsic, not a smell. Second, and more common in practice, optimising for the proxy rather than the property produces its own damage: interfaces invented for no reason but to allow a mock, indirection layers with one implementation each, a design shredded into fragments that are individually testable and collectively incomprehensible. Chasing the measurement instead of the thing measured is a real failure mode, not a hypothetical one.

c

The analogy

THE ANALOGY #
THE FIGURE

Think of a doctor tapping just below your knee. The reflex test does not treat anything, and nobody's health consists of having a good knee-jerk. What it does is give a cheap, fast look at a nervous pathway you cannot otherwise inspect — if the reflex is absent, something upstream is wrong, and the reflex is how you found out. Writing a test is that tap: two minutes of effort that probes structure you cannot see by reading.

WHERE IT BREAKS DOWN

A reflex test is passive and cannot be gamed, whereas a codebase can be reshaped specifically to make the tap look good — interfaces added purely so a mock can be injected — which is exactly how a project ends up with an excellent-looking signal and a worse design underneath.

d

Clarifying the model

THE MODEL #

Notice this reframes the usual argument. "We do not have time to write tests" is generally heard as a claim about testing effort. Read through the lens above, it often means something else: the code is shaped so that testing it is expensive, and that shape is charging a toll on every other activity too — onboarding, debugging, changing a requirement — only those tolls are not itemised anywhere.

It also explains why writing the test first tends to produce different code. If you must call the thing before it exists, you are forced to design its interface from the outside, as a caller experiences it, rather than from the inside outward. That is why test-driven development has a design effect at all, and it is a real one, though the empirical literature on whether it improves defect rates and productivity is genuinely mixed — the strong claims made for it outrun the evidence.

One correction worth making explicitly: none of this says untestable code is wrong. It says untestable code is telling you something, and it is worth listening before deciding. Sometimes the answer really is "this is a thin adapter over a vendor API, there is nothing to isolate, we will cover it at integration level." Fine. That is an informed decision. The failure is not making one.

e

A picture of it

THE PICTURE #
Testability as a design property
Testability as a design property Start at the input node at the top and walk the three diamonds in order -- construct, reach, observe. Every "no" branch lands on an ordinary design complaint, and all three funnel into the same reading; the dotted back-edge is re-running the tap after a real fix, while the branch to the shaded node is the shortcut that satisfies the proxy without touching the design. {"generator":"[email protected]","source":"../Socrates/.diagram-cache/_src/testability-as-a-design-property.md","sourceIndex":1,"sourceLine":4,"sourceHash":"6126352ec55640aa05c7c46eabc1cf85ddef84c9bd7b19fbf93f0459b94c3fb1","diagramType":"flowchart-v2","layoutVariant":"source","repairedDuplicateIds":[],"motion":"entrance-with-reduced-motion-fallback","presentation":"editorial","attempt":1,"viewBox":{"x":0,"y":0,"width":1791,"height":1096},"qa":{"passed":true,"findings":[]}} no yes no yes no yes re-tap tempting shortcut Try to write a test Can I construct it alone? Too many collaborators to wireup Can I reach the behaviourdirectly? No seam -- logic buried in alayer Can I observe the result? Hidden effect, far from the call Design signal is clean Read as a coupling problem Fix the design, not the test Mock everything to force a pass
KINDSsourcedecisionprocessoutcomeriskconnector

How to readStart at the input node at the top and walk the three diamonds in order — construct, reach, observe. Every "no" branch lands on an ordinary design complaint, and all three funnel into the same reading; the dotted back-edge is re-running the tap after a real fix, while the branch to the shaded node is the shortcut that satisfies the proxy without touching the design.

f

What became clearer

WHAT CLEARED #
WHAT CLEARED

The difficulty of testing is not a cost imposed by testing. It is a measurement of coupling, seams, and visible effects that was always there and that a test is simply the cheapest instrument for reading. Which is why the right response to "this is hard to test" is usually curiosity rather than a better mocking library — and why a design bent into shape to satisfy the instrument has thrown away the only reason the instrument was worth having.

g

Where to go next

ONWARD #
  • Where the signal legitimately fails: concurrency, hardware, and timing-dependent behaviour.
  • The difference between a mock, a stub, and a fake, and why over-mocking couples tests to implementation.
  • Why integration and end-to-end tests trade this design pressure away for realism.
h

Key terms

TERMS #
TermWhat it means
Testabilityhow cheaply code can be placed in a known state, executed, and observed.
Couplingthe degree to which one unit cannot be used, understood, or changed without others.
Seama place where behaviour can be invoked or substituted without editing the surrounding code.
Test-driven developmentwriting the test before the implementation, so the interface is designed from the caller's side.

Every term the collection defines is gathered in the glossary.

Nearby on the shelf

4