output_skills/testing/nullables/SKILL.md
Writes tests without mocks using Nullables. Use when writing tests, especially testing code with external I/O (HTTP, files, databases, clocks, random numbers), designing infrastructure wrappers or replacing mocking libraries.
npx skillsauth add lexler/skill-factory nullablesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
STARTER_CHARACTER = ⭕️
External I/O is slow and flaky. Tests hitting real databases, APIs, or file systems run slow and fail randomly. We want tests that run in milliseconds and never fail due to network issues.
Mocking libraries solve speed but introduce a new problem: they couple tests to implementation by verifying specific method calls. Test code using mocking libraries is brittle—it breaks when code is refactored, even when behavior is unchanged.
Nullables are production code with an "off switch" for infrastructure—not test doubles, but real code you can ship (dry-run modes, cache warming, offline operation). They enable narrow, sociable, state-based tests:
Use Nullables for:
Don't use Nullables for:
Greenfield: Add wrappers incrementally as tests demand—don't over-engineer upfront.
Existing codebase: See migration.md for incremental conversion strategies.
A-Frame is the architectural insight that makes Nullables work especially well. Traditional layered architecture stacks Logic on top of Infrastructure, making Logic depend on slow, brittle I/O. A-Frame makes them peers instead:
Application (coordinates)
↓ ↓
Logic (pure, tested) Infrastructure (Nullables)
Key rule: Logic never imports Infrastructure directly. Application coordinates between them via Logic Sandwich: read → process → write.
create()/createNull()This separation lets you swap real infrastructure for nulled versions without touching Logic. For full details, see a-frame.md. For event-driven code, see event-driven.md.
Every infrastructure wrapper has two creation paths:
class Clock {
static create() {
return new Clock(Date); // Real system clock
}
static createNull(now = "2020-01-01T00:00:00Z") {
return new Clock(new StubbedDate(now)); // Controlled clock
}
constructor(dateClass) {
this._dateClass = dateClass;
}
now() {
return new this._dateClass().toISOString();
}
}
// Embedded stub - lives in production code, not test files
class StubbedDate {
constructor(isoString) {
this._time = new Date(isoString).getTime();
}
toISOString() {
return new Date(this._time).toISOString();
}
}
Key principles:
createNull() parameters match the caller's abstraction level (ISO strings, not milliseconds)For complete construction details, see infrastructure-wrappers.md.
Every wrapper follows the same pattern. Here's how you test code that uses one:
describe("App", () => {
it("transforms input and writes result", () => {
const { output } = run({ args: ["hello"] });
assert.deepEqual(output.data, ["uryyb\n"]); // ROT-13
});
function run({ args = [] } = {}) {
const commandLine = CommandLine.createNull({ args });
const output = commandLine.trackOutput();
new App(commandLine).run();
return { output };
}
});
Tests exercise real App code. Only infrastructure I/O is neutralized. The run() helper protects tests from constructor changes (Signature Shielding).
// BAD: Breaks if format changes (also leaks implementation details into your clients, creates bad coupling)
assert.deepEqual(output.data, [{ level: "info", message: "Done", ts: 123 }]);
// GOOD: Uses dependency's format
assert.deepEqual(output.data, [logger.formatEntry("info", "Done")]);
For testing techniques (sequences, time, events, errors), see test-patterns.md.
These patterns work together:
Using mock libraries — Couples tests to implementation. Don't import sinon, jest.mock, etc. Nullables replace them.
Constructor connects to infrastructure — Constructors should perform no work. Defer connections to explicit methods. See Zero-Impact Instantiation.
Parameters at wrong abstraction level — createNull() should accept domain concepts, not implementation details:
// BAD: Leaking HTTP details
LoginClient.createNull({ httpResponse: { status: 200, body: '{"email":"x"}' } });
// GOOD: Domain level
LoginClient.createNull({ email: "[email protected]", verified: true });
Stubs in test files — Stubs belong in production code alongside the wrapper. See embedded-stubs.md.
Stub as complex as the real thing — If your stub needs significant logic, reconsider the abstraction.
development
Test-driven development (TDD) process used when writing code. Use whenever you are adding any new code, unless the user explicitly asks to skip TDD or the code is exploratory/spike.
testing
Scannable BDD tests written in domain language. Use when doing BDD.
development
Writes approval tests (snapshot/golden master testing) for Python, JavaScript/TypeScript, or Java. Use when verifying complex output, characterization testing legacy code, testing combinations, or working with .approved/.received files.
tools
Iterative refinement through multiple passes. Use when the user asks to 'meditate on', 'distill', 'refine', or 'iterate on' something, or proactively when a problem benefits from multiple passes rather than a single attempt.