What a Unit Is
A "unit" is not a class and not a method. It is a boundary you have chosen to hold stable — which makes choosing it a design decision, not a testing convention.
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.
Which behaviours deserve their own test, and what should the test be allowed to know?
A team standard says "every public method has a unit test". Coverage is 91%, the suite is green, and renaming a private helper broke thirty tests without changing any behaviour.
A unit is a class. One test file per class, one test per public method, mock the collaborators. It is mechanical, it is easy to review, and coverage tooling agrees with it.
It makes structure part of the contract. Extracting a helper class is a no-op for users of the code and a large edit to the test suite, so the suite starts voting against extraction (Extract Module).
- It makes structure part of the contract. Extracting a helper class is a no-op for users of the code and a large edit to the test suite, so the suite starts voting against extraction (Extract Module).
- It tests methods rather than behaviours, and methods are not what anyone depends on.
applyDiscountreturning the right number in isolation says nothing about whether a discounted order is priced correctly. - Coverage climbs while confidence does not. Ninety-one percent of lines executed with the collaborators replaced tells you that the lines run, not that the pieces agree (Contract Tests).
- It generates a test for every method whether or not the method encodes a decision, so the suite fills with assertions on getters and delegating one-liners that can only ever fail if someone edits them deliberately.
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 standard is written down and enforced in review, so changing it is a social change as well as a technical one.
- The codebase has 4,000 tests; any new convention has to coexist with the ones already written.
- Coverage is reported to management, so "fewer tests" needs an argument that survives that conversation.
- A refactoring — structure changed, behaviour identical — must be able to pass the suite unchanged. A suite that fails on a pure refactor is testing the wrong thing.
- Every behaviour the business depends on has at least one test that would fail if it broke.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The unit under test owns a behaviour someone outside can name — "a paused subscription is not billed", not "
isPaused()returns true". - Its internal collaborators own implementation. They are inside the boundary, and the test is not entitled to know they exist.
- The test owns describing the behaviour in the domain's vocabulary, which is why a good test name reads as a requirement (Ubiquitous Language).
- The unit boundary should coincide with a boundary you already wanted for design reasons — a module's public interface, an aggregate, a pure rule (Designing a Module Interface).
- Anything you would be willing to restructure freely belongs *inside* the boundary, because that is what the boundary is for: it is the promise about what will not move (Stable Boundaries).
- Drawing it around a class works when the class is the boundary. It fails when the class is one of six that only make sense together, which describes most classes (Cohesion).
What the suite has actually promised
Every test is a promise that something will not change without a conversation. That is useful when the something is a behaviour and destructive when it is a structure — and the choice of unit is precisely the choice of which one you promised.
Look at the unit as a thing with responsibilities, the same way you would look at production code. If its list of reasons to change includes "someone reorganised the internals", the boundary is in the wrong place.
- — That
PriceCalculatorexists and has a methodapplyDiscount - — That
DiscountPolicyis a separate collaborator injected in the constructor - — The order in which
PriceCalculatorcalls its collaborators - — That rounding happens in a private helper called
roundToCents
- — Asserts that each method returns the value the implementation returns today
- — Asserts that collaborators were called with particular arguments
- — The class layout of the pricing module
- — The constructor signatures
- — The internal call sequence
- — A pricing rule changes — legitimate; this is the reason the suite exists
- — A class is renamed
- — A helper is extracted or inlined
- — A collaborator is injected differently
- — Two classes are merged because they always changed together
Five reasons to change and only the first is behaviour. Four out of five test edits will be caused by changes that no user of the code can observe, which is the definition of a suite that opposes refactoring. The fix is not to write worse tests; it is to move the boundary out to the pricing module's public surface, where only the first reason survives.
Pricing the same two changes twice
The argument is not aesthetic, so price it. Take two changes a team actually makes — one behavioural, one structural — and count what each costs under both conventions.
First: extract RoundingPolicy out of PriceCalculator because three classes were rounding differently. Then, separately: VAT-inclusive prices must round per line rather than per order.
The extraction — a change with no observable effect whatsoever — costs 47 test edits. The rule change afterwards costs another dozen, spread across files, because the rule is asserted at three levels.
The extraction costs nothing, because nothing outside the boundary moved. The rule change costs two new cases and one changed expectation, in one file, in the vocabulary of the requirement.
pricing_test goes red you are told the pricing module is wrong, not which of its four classes is wrong — so you read a diff or step a debugger instead of reading a filename. On a suite this size that is minutes per failure, several times a week, forever. You are buying refactoring freedom with diagnosis time, and if your code changes shape rarely and breaks often, that is a bad trade.Choosing the boundary on purpose
There is no universal granularity, which is why "a unit is a class" survives — it is at least an answer. A better answer is to pick per module, from what that module is, and to write the choice down where the tests live so the next person does not silently pick differently.
What is the smallest thing here that has a name and a contract someone outside would recognise?
when Tax, pricing, scheduling, parsing, a state transition table — a function of its inputs with no I/O.
cost Test the function directly and narrowly. Cost: nearly nothing, and this is the one case where fine granularity is unambiguously right (Purity and Testing).
when An aggregate and its value objects; a parser and its tokeniser; anything where one piece alone cannot be described without the others.
cost Test at the cluster's entry point with everything real inside. Cost: coarser failure messages, and setup that builds a genuine object (Aggregates).
when A billing module, a notification module — something with a declared surface other modules import.
cost Test that surface. Cost: you must first actually have a declared surface, and many codebases do not (Internal Module Contracts).
when A repository, an HTTP client, a queue publisher.
cost Do not unit-test it — there is no behaviour to test that is not the external system's. Integration-test it instead (Where a Test Must Be Real).
when A handler that loads, calls a rule, saves and publishes.
cost One integration test through the whole path. A unit test here can only assert the call sequence, which is the implementation (Mocking).
How to build it
Most important first.
- Choose the boundary from the design, then write tests at it. The order matters: a suite written first at the wrong granularity is expensive to move afterwards.
- Prefer the smallest boundary that has a name in the domain. If you cannot describe what it does without referring to its collaborators, it is not the boundary (Designing by Responsibility).
- Let tests cross several classes. A test that exercises
Order,LineItemandMoneytogether is still a unit test in every sense that matters — it is fast, deterministic, in-process, and it does not know how those three divide the work. - Test each behaviour once, at one boundary. Duplicated assertions at three levels mean three edits when the rule changes, and it is duplicated knowledge with a test-shaped costume (Duplicate Knowledge).
- Delete tests that only restate the implementation. A test that cannot fail unless someone edits the code it mirrors has negative value: it costs maintenance and provides no evidence.
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.
- Under "one test per public method": extracting a helper costs thirty test edits and zero behaviour change, so the refactor gets deferred. The cost of the next *structural* change is roughly proportional to how many classes it touches, which is the opposite of what you want.
- Under boundary-chosen tests: the same extraction costs zero test edits, because nothing outside the boundary moved. A change to *behaviour* costs one test edit, which is correct — the test should fail, that is its job.
- The trade: when a boundary-level test fails, it tells you the module is wrong and not which of its six classes is wrong. You pay in diagnosis time on each failure, and you are paid in refactoring freedom continuously. That exchange favours the boundary once the code changes shape more often than it breaks.
- Wider units give worse failure localisation. On a suite of 4,000 tests that is a real daily cost, and teams that value fast diagnosis over refactoring freedom are making a defensible trade, not a mistake.
- Boundary tests are slower to write for the first behaviour, because you have to build a realistic object rather than a stub. The payback comes from reuse across the module's other behaviours.
- Refusing to test private behaviour means some genuinely intricate internal algorithm gets no direct test. Sometimes the right answer is to make it a unit with its own public contract — which is a design change, and should be argued as one.
What can go wrong
- The boundary is drawn too wide, every failure points at the same big entry point, and diagnosing a regression means bisecting inside the module by hand.
- It is drawn too narrow, and the suite becomes a cast of the implementation — the exact situation the team is in now.
- The boundary is chosen well, then quietly eroded: someone reaches past it "just this once" to assert on an internal, and six months later half the suite does.
- The convention changes but the old tests stay, so the codebase has two philosophies and no way to tell which file follows which.
- The suite depends on the boundary's public contract and, deliberately, on nothing else. That dependency is the thing you are agreeing to keep stable.
- Choosing a wide boundary means the suite depends on less structure and gives a coarser failure location; choosing a narrow one inverts both. There is no setting that gives you both (The Trade-off Matrix).
- "Unit means one class." That is a convention from a specific tool generation, not a definition. Kent Beck's own usage was "a test that runs in isolation from other tests", which is about test independence rather than production structure.
- "So write fewer tests." Write the same number of *behaviours* and far fewer restatements. The suite should get smaller in files and no weaker in what it would catch.
- "Never use doubles in a unit test." Doubles at the boundary of the unit are exactly right — that is what a boundary means. The objection is to doubles *inside* it (Test Doubles, Precisely).
- "Coverage is meaningless." It is a useful floor and a terrible target. Uncovered code is definitely untested; covered code is not thereby verified (What to Automate Out of Review).
- shotgun-surgery
Testing it, and how it ages
- Test at the module's public entry points, with real internal collaborators and no doubles for anything inside (Mocking).
- Keep one test per behaviour and name it after the requirement, so a failure reads as "paused subscriptions were billed" rather than "assertEquals failed at line 40".
- Add a deliberate check that the suite survives a pure rename: if renaming a private method breaks tests, the boundary has leaked (Rename).
- As a module grows, its internal structure changes several times and the boundary tests survive all of it. That survival is the return on choosing the boundary carefully.
- Eventually the module gets too big and splits. At that point the boundary genuinely moves, some tests move with it, and that edit is real work — the design bought you deferral, not immunity (Module Granularity).
- Coverage as a target ages badly in a specific way: it pushes tests towards whatever is cheapest to cover, which is the code that needs them least (What to Automate Out of Review).
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 a test boundary is a promise about what will not move is a structural fact about test suites in any language; only the syntax of drawing it changes.
- CONTESTEDThe opposing case is not weak: teams running very large suites report that coarse units make failures genuinely hard to localise, and that per-class tests with doubles give a failing test that points at one file, which at 200 engineers is worth real structural overhead. Their counter to "the tests block refactoring" is that a mechanical suite is also mechanical to regenerate. That works when the tests are cheap and shallow, and stops working when they encode subtle expectations nobody remembers writing.
- SCALE-SPECIFICAt five engineers a module-level boundary is obviously right, because whoever broke it can diagnose it in minutes. At several hundred, in a shared monorepo where the failing test is owned by a team you have never met, precise failure localisation stops being a convenience and starts being a coordination mechanism, and the calculus genuinely shifts towards narrower units.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the test pyramid, suite runtime economics, coverage tooling and how to keep 4,000 tests fast are that domain's subject. Here the only question is where the boundary belongs.