13 - Testing: Typed Code Proves It Twice
Chapter 13 - Testing: Typed Code Proves It Twice

The first rule of the outdoors is never go anywhere alone: always have a buddy, ask before wandering, report back. Code follows the same rule. Types walk with the code at compile time; tests walk with it at run time. A guard like isScout is a promise the compiler checks, and a test is the buddy that proves the promise holds when real data — and real strangers — arrive on the trail. This chapter teaches unit tests over typed code with Vitest, doubles and shared data through mocks and fixtures, async tests over promises, and the test-driven habit that ties them together. It ends in the typed-suite lab, where six red tests wait.
Unit tests over typed code with Vitest
A unit test over typed code tests the guard, not the type. The type already says a Scout is a name, a badge, and a role; no test needs to restate that. What needs proving is the boundary function that admits strangers:
const found = findByBadge(SAMPLE_PATROL, 3);
expect(found?.name).toBe("Amara");
expect(findByBadge(SAMPLE_PATROL, 99)).toBeNull();
expect(findByBadge(SAMPLE_PATROL, "3")).toBeNull();
Three asserts, three jobs. The first proves the happy path: badge 3 resolves to Amara. The second proves the miss: 99 is a number but nobody carries it, so the answer is null. The third proves the near miss, and the near miss is the test that matters most: the string "3" looks right to a human reader and is entirely wrong to the function, which takes a number. Every boundary deserves one near-miss test, because near misses are where production breaks — wrong case, string for number, object shaped almost right.
Vitest gives this section its runner: describe groups the contract, it names one behavior, expect states it. Test names should read as contracts — “finds a scout by badge”, “returns null for strangers” — so the suite output reads as a spec sheet. Run it with npm test (which runs vitest run), and keep the typecheck beside it: npm test && npm run typecheck. Tests prove behavior; the typecheck proves the tests themselves are typed. One without the other is half a suite.
A fourth assert shape proves narrowing, the course’s signature move. After findByBadge returns, one null check must be enough for the compiler to treat the result as a Scout:
const value: unknown = 7;
const found = findByBadge(SAMPLE_PATROL, value);
if (found === null) throw new Error("should have narrowed");
const label: string = `${found.name} #${found.badge}`;
expect(label).toBe("Malia #7");
If .name typechecks with no cast, the guard’s value is Scout return type is doing its job, and the test guards the guard.
New here: describe/it/expect, arrange-act-assert. Docs:
Vitest Guide.
Watch:
Video 132 — Jest Testing Fundamentals
(Blake’s whole video hangs off one story — a function gaining
a parameter while every call site silently breaks: “we’ve
effectively just broken our function, and anywhere this
function is used is now also broken” — then builds the
describe/test/expect apparatus that would have caught
it, including the deliberate red proof).
Mocks and fixtures
Real tests need data and seams. Fixtures supply the data; mocks supply the seams.
A fixture is shared sample data with a fixed address. The lab’s SAMPLE_PATROL lives in fixtures.ts: five patrol rows — Amara, Nia, Siya, Malia, Zola — that every test imports instead of rebuilding. One copy means one fix point. When the Scout shape gains a field, one file changes and the whole suite sees it. Resist the urge to inline test data per test; duplicated rows drift apart, and the fix is the same as every other drift in this course: one source of truth per seam.
A mock is a seam double: it answers like a dependency without ever touching it. The patrol loader pages through a fetchPage function, and no test should hit a network to prove paging. So the test scripts a fake with vi.fn():
const fetchPage = vi.fn();
fetchPage.mockResolvedValueOnce([amaraRow, stowawayRow]);
fetchPage.mockResolvedValueOnce([niaRow]);
fetchPage.mockResolvedValueOnce([]);
mockResolvedValueOnce queues answers in order: two pages of rows, then the empty page that ends the run. The asserts then prove the paging contract — three calls, first call asked for page one — and the guard contract: the stranger row with its string badge never made the patrol. Mock the seam, never the logic under test. If a mock starts asserting on internals of the function under test, it has crossed the seam and become brittle; mocks observe boundaries, nothing deeper.
New here: fixtures, vi.fn(), mockResolvedValueOnce. Docs:
Vitest Guide — Mock Functions.
Watch:
Video 136 — Vitest Mock Functions
(the Net Ninja’s spelling: mocking buys observability — “if
we didn’t mock this function, then we wouldn’t be able to use
this assertion, because we can’t monitor functions by
default” — plus the file-level counter that “doesn’t reset
then for every single test case”, cured by mockClear()).
Async tests
Async tests look like sync tests with one extra word. Mark the test async, await the promise, then assert the settled value like anything else:
const patrol = await loadPatrol(fetchPage);
expect(patrol.map((s) => s.name)).toEqual(["Amara", "Nia"]);
Three async shapes cover nearly everything this course needs. First, resolve with data: pages arrive, guards pass, names come out in order. Second, resolve with strangers: a null page, a bare object page, rows with wrong-typed fields — the run ends with the rows gathered so far, never a throw. Third, reject mid-run: the mock throws “network down” after one good page, and the loader keeps the partial patrol. That third shape is the one beginners skip and production requires. JavaScript promises reject; the network fails; the test proves the fallback.
Note what the loader never does: it never throws on a stranger. null page, non-array page, empty page, throwing fetch — all four end the run with what was gathered. “Never throw on a stranger” has been the course rule since the season-guard lab, and async code is where the rule earns its keep, because an uncaught rejection in a loader becomes a blank screen for a user.
New here: async tests, rejection shapes. Docs: Vitest Guide. Watch: Video 140 — Why Vitest Is Better Than Jest (Kyle’s pricing of the Jest-era setup: “VTest has all the amazing features of Jest built into it, but it’s created for a modern approach to JavaScript, and it has TypeScript support out of the box, import, export support, and you really don’t have to do any configuration at all”).
Test-driven development
Test-driven development turns the red-green loop into a habit: the test exists before the code it describes. Red first — watch the new test fail before touching source, which proves the test runs at all. Green second — the smallest code that passes, nothing cleverer. Refactor third — clean that code with the suite watching. Four rules keep the habit honest:
Write the test first and watch it fail. A test that never failed never proved it runs.
Keep tests typed end to end: unknown in, proven out. The suite checks the seams, not just the middles.
Give each test one behavior and a name that reads as a contract. “Loads pages through a mocked fetch” is a contract; “works” is not.
Run the suite on every edit. Green only means something when it is fresh.
Deliberate red is part of the practice, not a failure mode. After going green, break one guard on purpose — accept "3", drop a role from the union — and confirm exactly its test reddens. That red is the proof the test guards something rather than decorating the file.
New here: red-green-refactor, deliberate red. Docs: TypeScript Handbook — Narrowing. Watch: Video 142 — Test Driven Development with Vitest (the Net Ninja’s centerpiece: “the tests are driving the development of the code” — the suite written first as specification, then the implementation earning green, with edge cases considered “right from the start”).
Production cut: the repo’s own gate
This course ships the same discipline it teaches. Every lab pairs npm test with npm run typecheck — behavior plus types, the half-suite rule from this chapter’s first section. The deploy script guards against an empty build before any destructive sync, failing closed instead of shipping nothing loudly. And the course’s own pre-commit hook runs the formatter check on staged files, so unformatted code never even becomes a commit. Retune a bound without updating the suite and the build fails; bounds are decisions, and decisions get tests — the same sentence as the TDD rules above, wearing production clothes.
DIY simulator drills
You are the simulator. Cover the answers, predict, then verify. Log each miss in one sentence. One drill per file — Chapter 03 showed why.
Drill 1 — Be the null spotter
Which line errors, and with which code?
interface Scout {
name: string;
badge: number;
}
declare function findByBadge(patrol: Scout[], badge: number): Scout | null;
declare const patrol: Scout[];
const found = findByBadge(patrol, 3);
const label: string = found.name;
Verify with npx tsc --noEmit --strict --target es2020.
(The finder returns Scout | null, and the union carries
its warning label — reading .name through a possibly-null
value errors with TS18047. One null check first; that check
is the test the compiler writes for you.)
Drill 2 — Be the await checker
Which line errors, and with which code?
declare function loadNames(): Promise<string[]>;
const names: string[] = loadNames();
Verify with npx tsc --noEmit --strict --target es2020.
(A Promise<string[]> is a receipt, not the names — the
assignment errors with TS2740, listing the array properties
the receipt lacks. Async tests await first for exactly
this reason.)
Drill 3 — Be the mock
What prints on each line?
function makeMock() {
const calls = [];
const fn = (...args) => {
calls.push(args);
return fn.next();
};
fn.calls = calls;
let queue = [];
fn.next = () => queue.shift();
fn.queue = (v) => queue.push(v);
return fn;
}
const fetchPage = makeMock();
fetchPage.queue(["a"]);
fetchPage.queue([]);
const first = fetchPage(1);
const second = fetchPage(2);
console.log(JSON.stringify(fetchPage.calls));
console.log(JSON.stringify([first, second]));
Verify with node. (The mock records every call and
replays queued answers in order: [[1],[2]], then
[["a"],[]]. Observability plus scripted answers — that is
the whole double. Say both lines before running.)
Drill 4 — Be the guard
What prints?
function isScout(v) {
return typeof v === "object" && v !== null && typeof v.badge === "number" && v.badge >= 0;
}
console.log(JSON.stringify([{ badge: 3 }, { badge: "3" }, null].map(isScout)));
Verify with node. ([true,false,false] — the real badge
passes, the string badge fails its typeof check, and
null fails the null check before property access. The
near miss and the stranger both stay out; say the triple
before running.)
Lab: the typed suite
Open static/labs/typed-suite/ and run setup.sh. Read src/patrol.test.ts; do not edit it. Implement isScout, findByBadge, countByRole, and loadPatrol in src/patrol.ts, guards first, with the SAMPLE_PATROL fixture standing in for data and a vi.fn() mock standing in for the network. Six tests wait: find by badge, null for strangers, counts by role with zero for strangers, caller narrowing, paged loads through the mock, and survival of a stranger page. Go green with npm test && npm run typecheck, then break one guard on purpose and watch its test redden before restoring green. Ten minutes, then the room compares solutions and names the stranger cases.