Testing TypeScript
Running tests against typed code, typing test helpers and fixtures without fighting them, and asserting on types themselves so a refactor cannot quietly widen an API.
Running tests against typed code, typing test helpers and fixtures without fighting them, and asserting on types themselves so a refactor cannot quietly widen an API.
The refactor widened a return type from Photo to
Photo | undefined. Every test still passes, because every test
asserts on values and none of them asserts on the shape of the
API. The break surfaces in a consumer's build a week later.
The checker covers a category of bug that tests used to. What remains is different, and so is how you write the tests. By the end of this lesson you will know what to test once types are doing part of the work, how to type fixtures without fighting them, and how to assert on a type so a refactor cannot quietly change your public surface.
You no longer need tests for these:
it("throws when given a number", () => {
expect(() => makeCaption(42 as any)).toThrow();
});That test only compiles because of the as any, and it proves
nothing about how anyone will call the function — the checker
prevents that call. Deleting it removes noise.
What tests are still for:
Behaviour
Does it produce the right answer. The checker confirms the shape of the answer, never its value.
Edge cases
Empty, one, maximum, boundary values.
Integration
Do these pieces work together once they are wired up.
Runtime data
What happens when the API returns nonsense. Types describe the code you wrote, not the data you received — so the boundary is the most valuable place to test, precisely because the checker cannot reach it.
Regressions
Every bug, once, so it stays fixed.
Bad — a partial object asserted into place.
const photo = { name: "dawn.jpg" } as Photo;
expect(caption(photo)).toBe("Dawn");Good — a builder with defaults.
function makePhoto(overrides: Partial<Photo> = {}): Photo {
return {
id: "photo-1",
name: "dawn.jpg",
size: 1450,
createdAt: new Date("2026-01-01"),
...overrides,
};
}
expect(caption(makePhoto({ name: "tram.jpg" }))).toBe("Tram");The assertion in the first version is a lie the checker was told
to accept. When Photo gains a required field, that test keeps
compiling and the function under test now receives an object
missing it — so the test passes while the real code path would
fail. Every as in a fixture is a place a type change silently
stops being tested.
The builder gets a compile error when Photo changes, in one
place, and every test says only what is relevant to it. That
Partial<Photo> parameter is the utility type from the previous
lesson doing real work.
For deeply nested fixtures, DeepPartial overrides get awkward;
several small builders that compose are easier to read than one
large one with a complicated override type.
When a test substitutes a dependency, the substitute should be held to the real interface:
const store: PhotoStore = {
get: vi.fn(async () => makePhoto()),
save: vi.fn(async () => {}),
};Annotating store as PhotoStore means renaming a method on
the real interface breaks this file. Without the annotation, the
object is a shape of its own and the test happily verifies calls
to a method that no longer exists — the failure mode from the
Python mocking lesson, arriving here through an untyped double.
satisfies is better still when you also want the precise
type:
const store = {
get: vi.fn(async () => makePhoto()),
save: vi.fn(async () => {}),
} satisfies PhotoStore;
store.get.mock.calls.length; // still typed as a mocksatisfies checks the object against the type without widening
it, so you keep the mock-specific properties. An annotation
would hide them behind PhotoStore.
For module mocks, keep the real module's type:
vi.mock("./storage.js", () => ({
loadPhoto: vi.fn<typeof loadPhoto>(),
}));typeof loadPhoto ties the mock to the real signature, so a
changed parameter list fails the test file rather than passing
against a stale shape.
For anything with a public API, the shape is part of what you ship — so assert on it.
The simplest form needs no library:
import { expectTypeOf } from "vitest";
it("returns Photo, not Photo | undefined", () => {
expectTypeOf(loadPhoto).returns.resolves.toEqualTypeOf<Photo>();
});
it("rejects an unknown option", () => {
// @ts-expect-error unknown option
configure({ nosuch: true });
});@ts-expect-error is doing something clever there: it fails
when there is no error. So if a change makes that invalid call
legal — an index signature added, a parameter widened — the test
file stops compiling. It is a negative assertion, checked by the
compiler.
expect-type and tsd do the same job outside Vitest.
Two things worth asserting this way:
Public return types, which is the opening scenario. A widened return is a breaking change that no value-based test detects.
Inference, when a function is generic. That it works is
one thing; that a caller gets Photo[] rather than unknown[]
is what makes it usable, and only a type assertion checks it.
The place types cannot help is the place worth the most tests.
it("rejects a response missing a required field", () => {
const result = PhotoSchema.safeParse({ id: "a", name: "dawn.jpg" });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.path).toEqual(["size"]);
}
});That tests the validation from the boundary lesson — that a
malformed response is rejected, with a message naming the field.
No amount of type checking covers it, because the input is
unknown by definition.
The pattern that catches real breakage is asserting your schema against a recorded real response:
const recorded = JSON.parse(
readFileSync("tests/fixtures/photo-response.json", "utf8"),
);
it("parses a real API response", () => {
expect(() => PhotoSchema.parse(recorded)).not.toThrow();
});Re-record it periodically. When the upstream service changes a field, that test fails with a clear message instead of the change reaching production.
Two configuration details worth getting right.
Tests must be type-checked, as the callout said, and it is worth being explicit because the common setup excludes them from the build and then never checks them anywhere.
Test runners transpile without checking. Vitest, Jest with
swc, and anything using esbuild strip types and run —
identical to the tsx behaviour from the foundations course. So
a passing test suite says nothing about type errors:
{
"scripts": {
"check": "tsc --noEmit -p tsconfig.test.json",
"test": "vitest run"
}
}Both in CI, separately, so the log says which failed.
And the coverage caveat from every language: low coverage reliably means untested; high coverage does not mean well tested. A test that calls a function and asserts nothing about its output achieves full coverage of it.
WHAT THE CHECKER ALREADY COVERS
delete tests that only compile because of `as any`
wrong-type arguments are not a runtime concern any more
WHAT TESTS ARE STILL FOR
behaviour, edge cases, integration, regressions
and above all: DATA FROM OUTSIDE - the checker cannot reach it
FIXTURES
{ name: "a" } as Photo a lie; survives a type change and
stops testing the real shape
a builder with defaults + Partial<T> overrides
-> one compile error when the type changes, not silent drift
DOUBLES
const store: PhotoStore = { ... } renaming breaks the file
const store = { ... } satisfies PhotoStore
checked AND keeps mock properties
vi.fn<typeof realFunction>() ties the mock to the signature
TYPE TESTS
expectTypeOf(f).returns.resolves.toEqualTypeOf<Photo>()
// @ts-expect-error FAILS when there is no error
- a negative assertion the compiler checks
assert on public return types: a widened return is a breaking
change no value test detects
assert on inference for generics
type tests must be COMPILED or they are comments
THE BOUNDARY
test that a malformed response is rejected, and which field
parse a RECORDED real response; re-record periodically
that test fails when upstream changes, before production does
CONFIGURATION
test runners STRIP types and run - passing tests say nothing
about type errors
tsc --noEmit -p tsconfig.test.json, and vitest run, both in CI
low coverage means untested; high coverage means littleYou can now write tests that cover what the checker cannot,
without fixtures that quietly stop matching reality. The two to
act on: replace as in fixtures with builders, and add a type
assertion on any public return type — that second one costs a
line and catches a breaking change nothing else does.
Next is Linting and Formatting, which automates the
conventions this course has been describing. A surprising number
of the rules from earlier lessons — the floating promise, the
unsafe any, the missing await — are lint rules somebody
already wrote.
Before you move on, find a fixture using as to satisfy a type
and replace it with a builder. Then add a required field to that
type and watch the difference: one compile error in the builder,
versus a test that keeps passing against an object that no
longer matches.