The server had 37 instances of vi.mock spread across three test files. Prisma was mocked, the WakaTime HTTP client was hidden inside a private function, the GitHub handler tests asserted on mock call counts, and the integration test for the webhooks route was mocking four handlers to avoid touching the database. None of the tests exercised the database, none of them would have caught a broken Prisma query, and the whole scaffolding broke quietly whenever anyone refactored a function signature.
This is the state that Shore's Nullables pattern was invented to fix. We retrofitted it over five small steps. This post is the walkthrough.
Where we started
Four test files in server/test/. Three used vi.mock to replace Prisma, the XP service, the repo service, and the GitHub event handlers. The only pure file was signature.test.ts, which worked only because HMAC verification is arithmetic.
We also had a teaching kata at katas/wakatime/ that had already done the pure function extraction on paper. It described the shape of the pattern without applying it to the production code.
The five steps
Step 1: extract the pure logic.
The WakaTime service had about 80 lines of arithmetic tangled with Prisma reads and awardXP calls. We lifted four pure functions into server/src/services/wakatime-logic.ts: XP calculation, editor breakdown parsing, streak counting, and language aggregation. 15 unit tests against the new module run in 5 milliseconds and touch nothing external. The original service imports and delegates.
Step 2: set up a real test database.
The existing Docker Postgres on 5433 got a second database called dojo_test. A setup script at server/scripts/setup-test-db.sh drops and recreates it, then runs prisma migrate deploy. Vitest gets a DATABASE_URL pointed at dojo_test via the config. A resetDb helper truncates every application table between tests. An assertTestDatabase guard in the same file throws if the URL ever drifts back to the main database.
Cleaning this up surfaced a side project: the Prisma migrations folder had seven migrations describing an older schema while the main database had been moved forward via prisma db push. We collapsed the seven into a single 0_init generated from the current schema.prisma, marked it applied on the main DB, and pointed the test setup script at migrate deploy. Both databases now agree with the migration history.
Step 3: build the infrastructure wrapper.
server/src/infrastructure/wakatime-client.ts wraps fetch with two factory methods. WakatimeClient.create() returns the real client. WakatimeClient.createNull({ summaries: [...] }) returns an instance whose fetch is replaced with a scripted stub. Both share the same fetchSummary method, so parsing, status handling, and error recovery run end to end in tests. Eight tests exercise both modes and run in 7 milliseconds.
The Nullable pattern here is the piece that does the heavy lifting. You do not mock fetch. You do not wrap the client in a stub object. You replace only the network boundary, inside the real class, by passing a different fetch to the constructor. The production code path is fully exercised in tests.
Step 4: rewrite the service tests sociably.
services/wakatime.ts now takes a WakatimeClient as an optional parameter, defaulting to the real one. Tests pass in a Nullable client configured with a scripted summary and run against the real database. Seven tests cover no API key, no data, sub hour activity, focus hour XP, stacked bonuses, idempotency, and API errors. Each one creates a participant, calls syncWakaTimeActivity, and asserts on rows in WakaTimeActivity and XPTransaction. Zero test doubles, real Prisma, real XP awarding logic.
Step 5: rewrite the GitHub handler tests.
Three test files, one at a time. Each one seeds a participant and whatever prerequisite rows the handler reads, calls the handler directly, and asserts on database state after. The push handler test verifies a GitMetric is created and the repo commit count increments. The check run handler test verifies a pending metric flips to green and the participant streak goes up. The webhooks route integration test hits the full stack with a signed payload and asserts that routing, signature verification, raw body handling, and handler execution all compose correctly against the real database.
What came out the other side
Zero instances of vi.mock anywhere in the test directory. An ESLint rule banning it would be enforceable without any follow up cleanup.
| Metric | Before | After |
|---|---|---|
vi.mock usages | 37 | 0 |
vi.mocked usages | dozens | 0 |
| Total tests | 44 | 70 |
| Pure logic tests | 9 | 32 |
| Infrastructure tests | 0 | 8 |
| Sociable integration tests | 0 | 30 |
| Suite wall clock | 1.2s | 11.9s |
Those numbers are from running the full vitest suite three times on each commit. The 11 seconds is worth naming. It is slower than the previous suite because most of the new tests touch Postgres. A handler runs, a few rows get inserted, the next test truncates. Each round trip costs maybe a hundred milliseconds. The payoff is that every one of those tests catches a broken Prisma query, a broken schema assumption, a broken migration, or a broken service boundary. The old suite caught none of those. The new suite caught three already, as a side effect of the retrofit itself.
The pattern, in one paragraph
Pull pure logic out of side effect laden services. Unit test it directly, no doubles. Wrap each external service in a thin class with a real factory and a Nullable factory. The Nullable replaces only the boundary, usually by passing a scripted stub into the real class, so the rest of the code runs the same way in tests and production. Write application tests sociably, against a real database in a test instance, with Nullable wrappers plugged in. Nothing in your test directory should be the word mock.
What it cost
A resetDb helper and a small setup script. One extra database on the same Postgres container. One configuration line turning off parallel file execution, because tests sharing a database cannot safely run concurrently. About ten additional seconds on the test suite. One afternoon of retrofitting.
What it buys going forward
Refactors stop breaking test setups. Renaming a Prisma field fails the test that was relying on it, at the right layer, with a real error message. Adding a new handler is a copy of the push handler test with a different payload and different assertions, not a copy of a dozen vi.mock lines with slightly different shapes. Teaching new joiners the pattern is a half hour kata, not a week of debugging why their mock fell out of sync with the service.
We have been telling workshop cohorts to stop mocking for two years. This is the first version of our own repository that walks the talk.


