Testing With Nullables
Core concept
Nullables are production classes with an infrastructure "off switch." Each infrastructure class provides two factories:
create() — production instance with real I/O
createNull() — same class, same code paths, but I/O suppressed at the third-party boundary
Tests exercise real production code. Only the lowest-level third-party calls are stubbed.
class CommandLine {
private constructor(private stdout: WritableStream, private _args: string[]) {}
static create(): CommandLine {
return new CommandLine(process.stdout, process.argv.slice(2));
}
static createNull(options?: { args?: string[] }): CommandLine {
return new CommandLine(new NullWritableStream(), options?.args ?? []);
}
write(text: string): void { this.stdout.write(text); }
args(): string[] { return this._args; }
}
Workflow: Making a dependency testable with Nullables
- Identify the infrastructure boundary. Find the class that talks to an external system (HTTP, file system, database, stdout).
- Create an Infrastructure Wrapper if one doesn't exist. One wrapper per external system. Translate between external formats and domain types.
- Add an Embedded Stub. Stub the third-party code (not yours) with a minimal implementation covering only the methods your code calls. Keep it in the same file.
- Add
createNull() factory that injects the embedded stub instead of the real third-party dependency. Both factories return the same class.
- Add Configurable Responses to
createNull() so tests can control what the dependency returns. Name parameters by behavior (verificationStatus), not implementation (httpResponseBody).
- Add Output Tracking via
trackXxx() methods that record writes using an event emitter. This enables state-based assertions on side effects.
- Compose upward. Higher-level classes call
createNull() on their dependencies — no new stubs needed (Fake It Once You Make It).
Workflow: Writing tests in the Nullables style
- Write narrow tests. One test file per module/class. Each test has a single reason to fail.
- Make tests sociable. Exercise real dependencies — don't mock them. If
App uses Rot13, tests run the real Rot13.
- Assert on state, not interactions. Check return values, object state, or tracked output. Never assert on whether methods were called or in what order.
- Use signature shielding. Create test helper functions that wrap
createNull() calls. When the factory signature changes, update one helper instead of every test.
- Write narrow integration tests for each infrastructure wrapper against real (but local/test-isolated) external systems. Run these in CI, not on every save.
- Add smoke tests sparingly. One or two end-to-end tests as a safety net. If you need many, your narrow tests have gaps.
Workflow: Migrating from mocks to Nullables
- Pick the most-mocked dependency.
- Make it Nullable (follow the workflow above).
- Replace its mocks with
createNull() in tests — keep other mocks unchanged.
- Run tests. They should still pass.
- Repeat for the next most-mocked dependency.
Nullables coexist with mocks. No big-bang rewrite needed.
Key rules
- Stub third-party code, not your code. Your production code runs in full during tests.
create() and createNull() return the same class — not a subclass, not a fake.
- Constructors do no work (Zero-Impact Instantiation). Defer connections, servers, and I/O to explicit methods.
- Pure logic classes don't need Nullables. Only infrastructure wrappers need
createNull().
- Output Tracking works on both real and Nulled instances. It's useful in production too (monitoring, auditing).
- Design configurable responses from the consumer's perspective, not the implementation's.
Architecture recommendation
Use A-Frame Architecture for maximum testability:
Application / UI
/ \
Logic Infrastructure
\ /
Values (shared)
- Application layer: Coordinates logic and infrastructure via Logic Sandwich (read → process → write). No business logic, no I/O details.
- Logic layer: Pure business rules. Depends only on values. Test with simple state-based assertions.
- Infrastructure layer: Wraps external systems. One wrapper per system. Made testable via Nullables.
Edge cases
- Languages requiring interfaces (Java, C#): Use Thin Wrapper pattern — define a custom interface covering only methods you use, with real and null implementations. See references/nullability-core.md.
- Event-driven applications: Use Behavior Simulation — add
simulateXxx() methods to infrastructure wrappers that trigger the same handler as real events. See references/nullability-advanced.md.
- Complex stateful protocols: For deeply stateful interactions (multi-step auth, transaction sequences), interaction-based testing may be more natural. Combine Nullables for most infrastructure with mocks for interaction-heavy boundaries. See references/comparison-and-tradeoffs.md.
- Legacy codebases: Start from outside in (Descend the Ladder) or inside out (Climb the Ladder). See references/legacy-and-adoption.md.
Reference material
Consult these for detailed patterns, code examples, and guidance:
- Philosophy and foundational patterns: references/philosophy-and-foundations.md — core philosophy (sociable, state-based, production code), narrow tests, overlapping sociable tests, smoke tests, zero-impact instantiation, parameterless instantiation, signature shielding
- Architecture patterns: references/architecture.md — A-Frame architecture, logic sandwich, traffic cop, grow evolutionary seeds
- Logic and infrastructure patterns: references/logic-and-infrastructure.md — easily-visible behavior, testable libraries, collaborator-based isolation, infrastructure wrappers, narrow integration tests, paranoic telemetry
- Nullability core patterns: references/nullability-core.md — Nullables, embedded stub, thin wrapper, configurable responses
- Nullability advanced patterns: references/nullability-advanced.md — output tracking, fake it once you make it, behavior simulation
- Legacy migration and adoption: references/legacy-and-adoption.md — descend/climb the ladder, replace mocks incrementally, throwaway stubs, step-by-step adoption guide
- Comparison and tradeoffs: references/comparison-and-tradeoffs.md — Nullables vs. mocks tables, when each approach fits, common objections and responses
1---2name: testing-with-nullables3description: Guides writing fast, reliable, refactoring-friendly tests using the Nullables pattern language (James Shore's 'Testing Without Mocks'). Produces narrow, sociable, state-based tests with production code that has an infrastructure 'off switch' — no mock frameworks needed. Use when writing tests, replacing mocks with Nullables, making infrastructure code testable, adding createNull() factories, implementing output tracking, wrapping external dependencies, or adopting testing-without-mocks patterns. Covers embedded stubs, configurable responses, behavior simulation, A-Frame architecture, and incremental migration from mock-based test suites.4---56# Testing With Nullables78## Core concept910Nullables are production classes with an infrastructure "off switch." Each infrastructure class provides two factories:1112- `create()` — production instance with real I/O13- `createNull()` — same class, same code paths, but I/O suppressed at the third-party boundary1415Tests exercise real production code. Only the lowest-level third-party calls are stubbed.1617```typescript18class CommandLine {19 private constructor(private stdout: WritableStream, private _args: string[]) {}2021 static create(): CommandLine {22 return new CommandLine(process.stdout, process.argv.slice(2));23 }2425 static createNull(options?: { args?: string[] }): CommandLine {26 return new CommandLine(new NullWritableStream(), options?.args ?? []);27 }2829 write(text: string): void { this.stdout.write(text); }30 args(): string[] { return this._args; }31}32```3334## Workflow: Making a dependency testable with Nullables35361. **Identify the infrastructure boundary.** Find the class that talks to an external system (HTTP, file system, database, stdout).372. **Create an Infrastructure Wrapper** if one doesn't exist. One wrapper per external system. Translate between external formats and domain types.383. **Add an Embedded Stub.** Stub the *third-party code* (not yours) with a minimal implementation covering only the methods your code calls. Keep it in the same file.394. **Add `createNull()`** factory that injects the embedded stub instead of the real third-party dependency. Both factories return the same class.405. **Add Configurable Responses** to `createNull()` so tests can control what the dependency returns. Name parameters by *behavior* (`verificationStatus`), not implementation (`httpResponseBody`).416. **Add Output Tracking** via `trackXxx()` methods that record writes using an event emitter. This enables state-based assertions on side effects.427. **Compose upward.** Higher-level classes call `createNull()` on their dependencies — no new stubs needed (Fake It Once You Make It).4344## Workflow: Writing tests in the Nullables style45461. **Write narrow tests.** One test file per module/class. Each test has a single reason to fail.472. **Make tests sociable.** Exercise real dependencies — don't mock them. If `App` uses `Rot13`, tests run the real `Rot13`.483. **Assert on state, not interactions.** Check return values, object state, or tracked output. Never assert on whether methods were called or in what order.494. **Use signature shielding.** Create test helper functions that wrap `createNull()` calls. When the factory signature changes, update one helper instead of every test.505. **Write narrow integration tests** for each infrastructure wrapper against real (but local/test-isolated) external systems. Run these in CI, not on every save.516. **Add smoke tests sparingly.** One or two end-to-end tests as a safety net. If you need many, your narrow tests have gaps.5253## Workflow: Migrating from mocks to Nullables54551. Pick the most-mocked dependency.562. Make it Nullable (follow the workflow above).573. Replace its mocks with `createNull()` in tests — keep other mocks unchanged.584. Run tests. They should still pass.595. Repeat for the next most-mocked dependency.6061Nullables coexist with mocks. No big-bang rewrite needed.6263## Key rules6465- **Stub third-party code, not your code.** Your production code runs in full during tests.66- **`create()` and `createNull()` return the same class** — not a subclass, not a fake.67- **Constructors do no work** (Zero-Impact Instantiation). Defer connections, servers, and I/O to explicit methods.68- **Pure logic classes don't need Nullables.** Only infrastructure wrappers need `createNull()`.69- **Output Tracking works on both real and Nulled instances.** It's useful in production too (monitoring, auditing).70- **Design configurable responses from the consumer's perspective**, not the implementation's.7172## Architecture recommendation7374Use A-Frame Architecture for maximum testability:7576```77 Application / UI78 / \79 Logic Infrastructure80 \ /81 Values (shared)82```8384- **Application layer**: Coordinates logic and infrastructure via Logic Sandwich (read → process → write). No business logic, no I/O details.85- **Logic layer**: Pure business rules. Depends only on values. Test with simple state-based assertions.86- **Infrastructure layer**: Wraps external systems. One wrapper per system. Made testable via Nullables.8788## Edge cases8990- **Languages requiring interfaces (Java, C#)**: Use Thin Wrapper pattern — define a custom interface covering only methods you use, with real and null implementations. See [references/nullability-core.md](references/nullability-core.md).91- **Event-driven applications**: Use Behavior Simulation — add `simulateXxx()` methods to infrastructure wrappers that trigger the same handler as real events. See [references/nullability-advanced.md](references/nullability-advanced.md).92- **Complex stateful protocols**: For deeply stateful interactions (multi-step auth, transaction sequences), interaction-based testing may be more natural. Combine Nullables for most infrastructure with mocks for interaction-heavy boundaries. See [references/comparison-and-tradeoffs.md](references/comparison-and-tradeoffs.md).93- **Legacy codebases**: Start from outside in (Descend the Ladder) or inside out (Climb the Ladder). See [references/legacy-and-adoption.md](references/legacy-and-adoption.md).9495## Reference material9697Consult these for detailed patterns, code examples, and guidance:9899- **Philosophy and foundational patterns**: [references/philosophy-and-foundations.md](references/philosophy-and-foundations.md) — core philosophy (sociable, state-based, production code), narrow tests, overlapping sociable tests, smoke tests, zero-impact instantiation, parameterless instantiation, signature shielding100- **Architecture patterns**: [references/architecture.md](references/architecture.md) — A-Frame architecture, logic sandwich, traffic cop, grow evolutionary seeds101- **Logic and infrastructure patterns**: [references/logic-and-infrastructure.md](references/logic-and-infrastructure.md) — easily-visible behavior, testable libraries, collaborator-based isolation, infrastructure wrappers, narrow integration tests, paranoic telemetry102- **Nullability core patterns**: [references/nullability-core.md](references/nullability-core.md) — Nullables, embedded stub, thin wrapper, configurable responses103- **Nullability advanced patterns**: [references/nullability-advanced.md](references/nullability-advanced.md) — output tracking, fake it once you make it, behavior simulation104- **Legacy migration and adoption**: [references/legacy-and-adoption.md](references/legacy-and-adoption.md) — descend/climb the ladder, replace mocks incrementally, throwaway stubs, step-by-step adoption guide105- **Comparison and tradeoffs**: [references/comparison-and-tradeoffs.md](references/comparison-and-tradeoffs.md) — Nullables vs. mocks tables, when each approach fits, common objections and responses