Back to Ideas 5 min read

Testing a Release Without Releasing

E
EkoHacks Team
·
Testing a Release Without Releasing

Encoding the release, part 3 of 8.

The release command's job description is a testing nightmare: branch and push, open a PR, watch CI until it concludes, merge on green, cut a GitHub Release, approve a deployment gate, poll npm until the new version appears. How do you test watch CI until green several hundred times a day without a CI run in sight?

Our answer is James Shore's Nullables pattern — no mocks, no spies, anywhere in the suite. Every piece of infrastructure is a thin wrapper with two factories: create() talks to the real thing, createNull() answers from configured state and touches nothing. The crucial property is that real and null share every line of code above the bottom layer. The null for our gh wrapper does not stub out methods — it stubs the gh CLI itself, answering the same shapes the real binary produces: a PR URL on create, JSON rows for checks. All the parsing, mapping and error handling that runs in production runs in every unit test.

Two idioms carry most of the weight.

Output trackers, not spies. When a test needs to know what the tool did to the world, it asks the wrapper for a tracker and asserts on its data:

const merges = gh.trackMerges();
// ... run the policy ...
expect(merges.data).toEqual([154]);

That reads like a spy but is a different thing philosophically: it is the wrapper's own record of its outputs, part of its real interface, not a test framework's interception of its internals. The most important assertions in the suite are about what a tracker does not contain — part 4 hangs on an approvals tracker being empty.

Configured rounds, for time. A release is full of waiting: checks pending, then concluded; a publish run absent, then waiting; npm serving the old version, then the new. The nulls model time as a list of successive answers, where each call takes the next round and the last round repeats:

const gh = GhWrapper.createNull({
  checkRounds: [
    [{ name: 'build', concluded: false, passed: false }],
    [{ name: 'build', concluded: true, passed: true }],
  ],
});

With that, "poll CI until it concludes, then merge" is an ordinary synchronous test that walks a PR from pending to green in microseconds. The same idiom gave us waitingRunRounds for the deployment gate and version rounds for the registry — the npm null answers 0.4.0 during preflight and 0.5.0 after the publish, because that is what the real registry does.

One boundary we drew deliberately: the null never pretends to be GitHub's behaviour, only its shapes. We refused to write an elaborate fake GitHub, because a fake you wrote yourself only ever proves you agree with yourself. The wrapper's comment states the doctrine: the real side is proven by a real release rather than a faked GitHub. That is a genuine trade — it means the first real release is a scheduled collision with reality. Part 7 is about the day that bill came due, and why we would sign the same deal again.

Next: The Gate Always Asks.

E

Written by

EkoHacks Team

More from Ideas

·6 min read

What the Nullable Gave Back

One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.

E
EkoHacks Team
·6 min read

Twenty Six More Tests, Four Fewer Behaviours

Removing the mocks grew the suite from 44 tests to 70 and quietly deleted four behaviours, every one of them a failure path. Test count is not coverage.

E
EkoHacks Team
·6 min read

The Best Coverage Number in the Room

Same commit, same spec, same test count. The mocked suite ran 5.6 times faster, covered 3.5 fewer points of real code, and posted the best branch coverage.

E
EkoHacks Team