Migrating an Existing Codebase to Nullables
Converting an existing codebase incrementally — replacing mock-based tests, or making untested code testable. The conversion orders below apply either way. Mocks and Nullables coexist in the same suite and even the same test, so every step ships green — no big-bang rewrite. Convert where testing hurts; skip code that's already easy to maintain.
Contents
- Replace mocks in one test
- Choose a conversion order
- Throwaway stubs
- Checklist
Replace mocks in one test
Substitute one double at a time; run tests after each swap.
Order within a test: doubles that are only configured first, the ones you verify() against last.
- Replace the double with a nulled real instance.
- Its
when().thenReturn()configuration becomescreateNull(...)arguments (Configurable Responses). - Its event-emission setup becomes
simulateX()calls (Behavior Simulation). - Its
verify(...)assertions become tracker assertions (Output Tracking).
// Before
const emailer = mock(Emailer);
when(emailer.send).thenResolve({ sent: true });
const service = new UserService(emailer);
await service.createUser({ email: "test@example.com" });
verify(emailer.send).calledWith({ to: "test@example.com", subject: "Welcome" });
// After
const emailer = Emailer.createNull();
const emails = emailer.trackOutput();
const service = new UserService(emailer);
await service.createUser({ email: "test@example.com" });
assert.deepEqual(emails.data, [{ to: "test@example.com", subject: "Welcome" }]);Each replacement requires the dependency to be Nullable — that's the conversion-order question.
Choose a conversion order
"Converting" names a class, never a branch or the whole app: converting X means X's tests become sociable and nulled, which requires X's direct dependencies to have createNull() — nothing deeper. Every conversion leaves tests green and the codebase shippable, so the tree converts one class-sized increment at a time, and the purpose of the work decides how many increments to do.
App
┌───────────┼───────────┐
ReportClient Emailer Cli
│ │ │
HttpClient SmtpClient stdout ← three technologies, three edgesConverting App gives its three direct dependencies createNull() (throwaway stubs if the branches below aren't ready) and rewrites App's tests. The branches stay untouched. Converting ReportClient later descends one branch; the console branch can wait forever if testing never hurts there.
Map the dependency tree first. Each class falls into one of three cases:
- No infrastructure anywhere below → nothing to convert; plain tests.
- Low-level wrapper on third-party infrastructure → narrow integration tests + embedded stub (see the building-low-level-wrappers file for your language).
- Everything else → make its direct dependencies Nullable, then compose (see building-high-level-wrappers.md).
Climb the ladder (small tree): convert the whole tree bottom-up — leaves first, then each parent by composition, converting tests as you go. No temporary work.
1. HttpClient — embedded stub + narrow integration tests
2. Auth0Client — createNull() by composition; replace mocks in its tests
3. LoginController — createNull() by composition; replace mocks in its tests
4. Router — sameDescend the ladder (large tree): convert one class and its direct dependencies only, top-down; dependencies that aren't Nullable yet get throwaway stubs. Every rung leaves tests green and the codebase shippable, so the conversion can stop at any rung — at the cost that the sociable chain stays broken below each remaining throwaway stub. When the goal is converting the whole tree anyway, climb instead; throwaway stubs are pure waste then.
1. Router first: give LoginController a throwaway stub, make Router Nullable,
replace mocks in Router's tests.
2. Later, LoginController: its dependency Auth0Client gets converted (or stubbed),
LoginController's throwaway stub is replaced by real composition.
3. Repeat downward as far as the task's scope requires.Throwaway stubs
A temporary embedded stub for a dependency you aren't ready to convert. It breaks the sociable chain (changes in the stubbed dependency no longer reach these tests), so:
- Mark it clearly as temporary; keep it minimal — only what current tests need.
- It stays an implementation detail: callers still see only
create()/createNull(), never a stub parameter. - Replace it with real composition as soon as the dependency becomes Nullable; delete it.
Checklist
- Find mock usage:
grep -rE "mock|jest\.fn|sinon|Mockito|td\.instance" test/ - Map the dependency tree; pick climb (small) or descend (large)
- Per class:
create()+createNull(), output tracking and configurable responses as its tests demand - Per test: replace doubles one at a time, configured ones first, verified ones last; green after each
- Replace throwaway stubs as their dependencies become Nullable
- When a package is mock-free, drop the mocking library from it