Purity and Testing
A pure function needs no setup: you call it and assert. Every line of setup a test requires is the design telling you what that code depends on, which makes test friction the cheapest design signal available.
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.
What is my test setup telling me about the dependencies of the code under test?
A team wants better test coverage on the entitlement rules — who can see which features on which plan. The rules are eleven lines. The test for them is ninety, of which seventy-eight are setup: a user, an organisation, a subscription, a database, a flag client and a stubbed clock.
The setup is the cost of testing realistically. Build good fixture helpers — createUserWithSubscription() — so the seventy-eight lines become three, and get on with adding cases.
It works, and it hides the signal. The setup did not go away; it moved into a helper that now every test in the codebase depends on, and the dependencies it encodes are no longer visible to anybody reading a test (Hidden Global State applies to fixtures too).
- It works, and it hides the signal. The setup did not go away; it moved into a helper that now every test in the codebase depends on, and the dependencies it encodes are no longer visible to anybody reading a test (Hidden Global State applies to fixtures too).
- As rules multiply, the helper grows parameters.
createUserWithSubscription(plan, trial, seats, flags, region)is the entitlement rules' input list, discovered accidentally and expressed as a fixture builder rather than as a type. - When the schema changes, every entitlement test breaks despite no rule having changed — the tests are coupled to persistence because the code is (Mocking).
- The rules stay slow to test, so the eleven lines accumulate special cases nobody adds a case for, which is how coverage goals produce more assertions and less confidence.
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.
- Coverage is a management goal with a date, so whatever is done has to increase covered rules quickly (Constraints Are Part of the Design).
- The entitlement code is called from the API, the UI and a nightly report, so it cannot simply be moved into one of them.
- Nobody has budget to rewrite the persistence layer, which is where most of the setup comes from.
- Every entitlement decision is reproducible from its inputs — same user state, same plan, same instant, same answer.
- A test for a rule fails only when that rule changes, not when an unrelated schema, flag or fixture changes (Divergent Change).
- The rules are callable from all three consumers without any of them supplying infrastructure they do not have.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The rule owns deciding, given plain inputs. It owns no loading, no flag evaluation and no clock reading (Side Effects).
- The test owns describing the rule, which it can only do if the setup is the inputs and nothing else.
- The caller owns assembling the inputs, and there are three of them doing it three ways — which is fine, and is where the fixture helpers legitimately live (Functional Core, Imperative Shell).
- The fixture helper owns convenience only. The moment it owns knowledge about what a valid entitlement input looks like, that knowledge has left the domain.
- The useful boundary is the one at which a test needs no infrastructure. Find it by asking what the smallest set of plain values is that determines the answer.
- Below that boundary, tests are fast, parallel and numerous. Above it they are few, slow and about wiring — and both kinds are necessary (What a Unit Is).
- The boundary is not "the class". Purity is a property of a function; a class with a database in its constructor has no pure boundary at all until something is extracted (Extract Function).
Read the setup as a dependency list
The two tests below assert the same rule. The first one describes, line by line, everything the entitlement code reaches for; the second one describes the rule. Neither is more rigorous — they check the same behaviour.
The useful exercise is to read the first test's setup out loud as a sentence about the production code: "deciding whether a user can export requires a database, an organisation record, a subscription row, a flag client and the current time." Said that way, it is obviously a finding rather than a fact of life.
it('denies export on the free plan after trial', async () => {
const db = await testDb()
const org = await db.orgs.insert({ name: 'Acme', region: 'eu' })
const user = await db.users.insert({ orgId: org.id, role: 'member' })
const sub = await db.subs.insert({ orgId: org.id, plan: 'free',
trialEndsAt: '2026-01-01' })
flagClient.set('exports-ga', true)
clock.set('2026-02-01')
const svc = new Entitlements(db, flagClient, clock)
expect(await svc.canExport(user.id)).toBe(false)
})
// 8 lines of world to assert one rule. Changing the users
// schema breaks this test, and the rule did not move.it('denies export on the free plan after trial', () => {
expect(canExport({
plan: 'free',
trialEndsAt: date('2026-01-01'),
now: date('2026-02-01'),
exportsEnabled: true,
})).toBe(false)
})
// The four things that decide the answer, and nothing else.
// A schema change cannot break this. Twenty more cases
// are twenty more lines.The second test can only be written once the rule takes its inputs as values, so writing it forces the extraction — which is why "make this testable" is a design instruction and not a testing one. The cost is real and visible in the parameter list: somebody upstream now has to load the subscription and evaluate the flag, and that assembly code is the part the second test does not cover (Functional Core, Imperative Shell).
What each kind of setup is telling you
Setup is not uniformly a smell. Different setup means different things, and the response differs — which is why "the test needs a lot of setup" is a starting point rather than a diagnosis.
The right-hand column is deliberately not "extract a pure function" in every row. Two of these are cases where the setup is honest and the correct response is to write fewer tests at that level rather than to restructure the code.
| What the test has to build | What that says about the code | Response |
|---|---|---|
| Plain values only | The decision depends on its inputs. This is the target. | Add cases freely; they cost a line each. |
| A database, for a decision | The rule reaches for persistence it does not need to reach for. | Extract the decision; load in the caller (Functional Core, Imperative Shell). |
| A stubbed clock or RNG | A hidden effect is being read inside the logic. | Pass the instant or the seed as a parameter (Time as a Dependency). |
| A mock asserting call order | The test is coupled to how, not what. It will break on every refactor. | Assert on the returned decision instead; if there is none, extract one (Mocking). |
| A container or framework boot | The unit cannot exist outside the framework. | Usually acceptable at the edge; a warning sign in the domain (What a Framework Charges). |
| Several real collaborators, deliberately | You are testing a seam, and the setup is the point. | Keep it. Have few of these and make them count (Where a Test Must Be Real). |
| Fixtures nobody can explain | Knowledge about valid domain state lives in the test helpers. | Move the validity rule into a type that cannot be constructed wrongly (Making Illegal States Unrepresentable). |
Priced: twenty more entitlement rules
The coverage goal in the requirement is the wrong target and the right pressure. What actually matters is the marginal cost of the next rule and its test, because that number decides whether the twentieth rule gets a test at all.
Product adds twenty entitlement rules over two quarters: seat limits, regional restrictions, trial extensions, add-on features and a grandfathered legacy plan.
Each rule needs a fixture that satisfies four schemas, so the fixture helper grows a parameter per rule and every test in the file depends on all of them. The suite time grows linearly with rules, and the practical outcome is that rules ship with one happy-path test rather than the six cases they have.
Twenty rules are twenty branches and roughly a hundred and twenty table rows, running in well under a second. The three assembly tests do not change at all unless a rule needs data nobody loads.
How to build it
Most important first.
- Read the setup as a specification of the inputs. Seventy-eight lines of setup is seventy-eight lines describing what the eleven lines actually depend on, most of which nobody intended (Testing as Design Feedback).
- Extract the decision as a function of plain values:
canAccess(feature, plan, seats, trialEndsAt, now). If naming the parameters is hard, that difficulty is the finding. - Move loading up to the callers, all three of them, and let each assemble the inputs its own way.
- Keep the slow tests, fewer of them. One integration test per consumer that the assembly is right; dozens of pure tests that the rules are right (Where a Test Must Be Real).
- Use fixture builders for the integration tests only. In a pure test, constructing the input literally is shorter than calling a builder and says more (Naming).
- Treat any test that needs a mock for a decision as a signal to extract, not as a reason to reach for a mocking library (Mocking).
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: adding an entitlement rule costs a code change plus a fixture that satisfies four unrelated schemas, and the test runs in a suite that takes minutes. The marginal cost of the twentieth rule is the same as the first, which is why nobody adds the twentieth.
- After: adding a rule costs one branch and a three-line test. The marginal cost falls with each rule because the input type is already there, which is the compounding this domain is looking for (Changeability Is the Goal).
- Changing the *inputs* — entitlement now depends on region — costs a field on the input type and a compile error at each of the three assembly sites. Three known places, named by the compiler.
- What stays expensive: a rule that genuinely needs data nobody loads yet. That is a query change, an assembly change and a type change, in all designs — purity does not make new data free (Cost-Aware Interfaces).
- Pure tests test the rules and not the system. A codebase with excellent pure coverage and no integration tests fails at the seams, and the seams are where production incidents live.
- Extracting for testability can produce a function with no domain meaning — a bag of parameters that exists to be testable. That is a real design cost and worth resisting (Over-Decomposition).
- The signal is loud but not precise: heavy setup sometimes means the code is badly factored and sometimes means the domain genuinely involves five things. Reading it as an automatic verdict produces pointless extractions.
What can go wrong
- The function is extracted and still takes an entity loaded from the ORM, so the test still needs a database to construct one. Purity by parameter count is not purity (Primitive Obsession cuts the other way here: sometimes plain values really are the answer).
- Everything becomes pure and the assembly code — now the only untested part — grows the interesting bugs. Purity moved the risk; it did not delete it.
- The team concludes that integration tests are unnecessary and deletes them, and the next outage is a wiring bug that every pure test passed through.
- The mitigation fails on its own terms: a rule function with eleven parameters is technically pure and unreadable, and the parameter object that fixes it re-introduces a type that has to be constructed (Introduce Parameter Object).
- The rule function depends on plain types and nothing else, which is what lets the API, the UI and the report all call it (Dependency Direction).
- Tests depend on the rule's input types, which makes changing those types a visible, compiler-checked cost rather than a fixture-helper edit.
- The consumers gain the dependency on loading, which they already had — it is now declared instead of inherited (Functional Core, Imperative Shell).
- "Hard to test means bad code." It means the code has dependencies that are expensive to supply. Sometimes that is a design flaw and sometimes it is a database migration doing exactly what a database migration does (When Design Does Not Pay).
- "Use mocks and the setup problem goes away." Mocks convert setup into coupling: the test now encodes which calls the implementation makes, so every refactor breaks it. The setup was information; a mock discards it (Test Doubles, Precisely).
- "Pure means no dependencies." Pure means no *hidden* ones. A pure function can take a repository-shaped argument and still be pure as long as it only calls it in ways the caller determines — though at that point you have a function whose test needs a fake, and the signal is telling you something.
- "Aim for one hundred percent pure code." The program has to touch the world or it does nothing. The aim is for the *decisions* to be pure and the actions to be few and thin (Functional Core, Imperative Shell).
- long-parameter-list
- feature-envy
Testing it, and how it ages
- Table-driven tests over the rule function: one row per case, no setup, milliseconds (What a Unit Is).
- One integration test per consumer proving the assembly loads what the rule needs, which is the only thing integration should be asserting here.
- A property test that the rule is deterministic and total — no input combination throws (Property-Based Testing).
- Watch the setup-to-assertion ratio as a design metric over time. When it starts climbing in a module, something has acquired a dependency; the ratio is a signal, not a score.
- Pure rule functions accumulate cases indefinitely and stay cheap. What ages is the input type, which grows a field per new consideration until it is worth splitting by feature area.
- The pressure that eventually forces a change is a rule that needs to *do* something — record a usage counter, emit an audit event — at which point the function returns a decision plus an effect description rather than a boolean (Effect Boundaries).
- It stops being right when the assembly is the whole problem: an entitlement system whose difficulty is fetching from four services has a trivial rule and a hard shell, and pure tests will be measuring the easy part (When Design Does Not Pay).
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.
- GENERALThe relationship between setup cost and dependency count holds in every language and test framework: what a test must construct is exactly what the code reaches for, and no idiom changes that.
- PARADIGM-SPECIFICIn a functional codebase most functions are already pure and the signal is quiet — heavy setup stands out immediately. In an OO codebase where objects are constructed with collaborators, some setup is idiomatic and unavoidable, so the signal has to be read as a trend within a module rather than as an absolute threshold.
- CONTESTEDThe strongest opposing view: tests exist to catch regressions in the running system, and a suite of pure unit tests over extracted functions can reach high coverage while never exercising the wiring where bugs actually occur — teams that follow the testability signal too far end up with an untested shell and a false sense of safety. Practitioners who favour testing through the public interface with real dependencies argue this and have the incident data on their side more often than unit-test advocates admit. The counter is that a suite too slow to run on every change stops being run, and rules nobody can cheaply test stop being extended.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — how many tests to have at each level, and what coverage actually buys, is that domain's subject; this lesson only claims that setup cost is a design signal.