| name | nullables |
| description | Nullables — testing technique alternative to using mocking libraries. Use when writing unit tests, when code touches external I/O or state (HTTP, databases, files, clock, random) anywhere in its dependency chain, when making a system testable, or when tests are slow or flaky. |
Nullables: Testing Without Mocks
STARTER_CHARACTER = ⭕️
Nullables are production code with an off switch: classes with external I/O anywhere in their dependencies offer create() (real) and createNull() (I/O disabled, everything else runs normally). Tests are narrow (focused on one class), sociable (dependencies run for real), and state-based (assert outputs and state, never method calls). Don't use mocking libraries or DI frameworks — Nullables make them unnecessary.
The cut
Stub at the lowest point — the third-party edge — never your own code:
OrderService → PaymentClient → HttpClient → third-party lib
app code high-level low-level ✂ stubbed when nulled
wrapper wrapper
PaymentClient is a high-level wrapper: it abstracts one service and speaks domain language.
HttpClient is a low-level wrapper: it abstracts one technology and is generic and highly reusable.
- The low-level wrapper holds the fork:
create() wires the real library (node http, RestTemplate); createNull() wires an embedded stub — your code, returning canned data, doing no I/O.
- Everything left of the cut is your code and runs for real in tests.
With mocks, you only mock code you own; with Nullables, you only stub code you don't own. Only the bottom layer has a stub — one per technology. Everything above runs real in tests, so a bug anywhere in your code turns tests red. Mocking your own classes breaks that chain: mocked code never runs, and its bugs hide behind green tests.
An invented internal seam is the same break in disguise: cutting at rows() → List<Row> instead of the driver puts your mapping loop below the seam, where nulled tests never run it. The test: any parsing, mapping, or normalization you wrote must sit above the cut. When the third-party API is a chain of objects, mirror it — one stub class can play the whole chain (see the low-level wrapper files).
Two channels, plus events
Every class that talks to infrastructure anywhere in its dependencies offers the same two factory methods:
Clock.create()
Clock.createNull({ now: "2024-01-01" })
Tests interact with a nulled instance through three moves:
-
Reads — configure what the world answers, as createNull(...) parameters in the caller's domain terms: PaymentClient.createNull({ approved: false }), DieRoller.createNull([3, 5, 1]). A single value repeats forever; a list is consumed in order, then fails fast. An error is just another configured response: createNull([{ error: "boom" }]).
-
Writes — observe what the code sent, as domain data (track the data, not the rendered string):
const emails = emailer.trackOutput();
await service.register("a@b.com");
assert.deepEqual(emails.data, [{ to: "a@b.com", subject: "Welcome" }]);
The same tracker can prove a negative — a test where registration is refused asserts emails.data is []: no email went out.
-
Pushed events — fire a simulated incoming event through the same handler path a real event takes: network.simulateMessage("client-1", "Hello").
These ride on two tiny utilities, OutputListener/OutputTracker and ConfigurableResponses. When the codebase lacks them, add them — example implementations in utilities.md.
Procedure
Start from the code you need to test. For a whole system, pick one class and repeat; conversion order across many classes is in migration.md.
- List the dependencies of the class under test. Classify each one you need to control:
- Pure logic, nothing external below → test directly, nothing to null.
- Value object or config →
createTestInstance() with safe overridable defaults. If it holds an infrastructure object, default it to the nulled version.
- Already has
createNull() → go to step 4.
- Touches infrastructure below but has no
createNull() → step 2.
- Follow that dependency's chain down until you reach code you don't own — a third-party library doing I/O. That is the edge.
- Walk back up the chain, giving each class
create() and createNull() that compose its nulled dependencies — follow building-high-level-wrappers.md; its recipe carries the decomposition and tracker moves that keep layers honest, and this is where abstractions leak if you rush. Done when every class between the edge and the class under test has both factories, configuration in its own language decomposed downward, and its write channel tracked.
- Write the tests following consuming-nullables.md — the fixture shape, error configs, and time travel live there. Done when every read, write, and error path of the class is asserted — both directions per dependency: the exact outgoing request (via that dependency's tracker) and the returned answer being used.
Converting a mock-based suite → migration.md. Improving existing nullables → walk their layers and check each against The cut and the rules below, plus: no leftover throwaway stubs. Structuring a new app around this (optional) → architecture.md.
Rules at every layer
Before committing a converted class, walk these as a checklist against it.
create() wires production, createNull() wires nulled — both factories live on the wrapped class, never on the stub. The plain constructor is the test seam: tests use it to inject dependencies they hold handles on.
- Configure and assert as the state of the world the caller wants to control, in the caller's language:
PaymentClient.createNull({ approved: false }), not HTTP statuses. Each layer decomposes its configuration into its dependency's language.
- Bare
createNull() always works: every parameter has a safe default, so one call nulls the whole dependency chain from the top (parameterless instantiation).
- Every invented default is loud and self-naming —
"Nulled HttpClient default body", status 503, timezone Australia/Lord_Howe; stub errors self-name too ("Nulled Jdbc: the database is down"). Nothing breaks on these, but a test that accidentally depends on one sees obviously fake data instead of passing by luck. Collections default empty — the default world is empty; absurd entries would be mistakable for real data. Failing fast is reserved for overrunning explicit configuration: an exhausted response list throws "No more responses configured…".
- Constructors do no work. Connecting, starting, listening happen in explicit methods, so instantiating the whole dependency tree is always safe.
- One test helper owns construction and wiring (signature shielding): optional named parameters with
IRRELEVANT_* defaults, returning a bag of results and trackers. A signature change hits one place.
- Stay in consumer scope: assert that the request went out and the answer got used. The dependency's own tests cover its behavior.
- Wrappers validate external responses hard and throw detailed errors on anything unexpected (paranoic telemetry); callers decide how to recover. Test error paths as thoroughly as happy paths — they cost the same now.
- Only the lowest wrapper gets narrow integration tests against the real system. They document the third-party behavior the stub must match — that pairing keeps the stub honest.
Anti-patterns
- Stubs in test files — the embedded stub is production code and lives with its wrapper. Nulled instances have production uses of their own: a dry-run flag, cache warming.
- A stub that reimplements the real system — stubs return canned data; needing real logic means you're cutting at the wrong level.
- A
nulled flag forking the wrapper's logic with if-branches — nulling swaps the wrapped dependency at the seam; the wrapper keeps one code path.
- Computing an assertion's expected value with the code under test — the test then verifies nothing.