Testing & Type Tests
Testing a typed codebase — type-checking test files, type-safe mocks and fixtures, alternatives to as any, asserting that a call is rejected at compile time with tsd or expect-type, and typing a custom Jest matcher.
6 questions
JuniorTheoryCommonWhat do tsd and expect-type verify, and why can a runtime test not do it?
What do tsd and expect-type verify, and why can a runtime test not do it?
They assert facts about types: that parse('x') is exactly string and not any, that a wrong argument is rejected, that a generic infers what you expect. The compiler evaluates them, so they fail the build. A runtime test only ever sees values.
Common mistakes
- ✗Thinking a type-level assertion fails at test time rather than at compile time
- ✗Expecting a runtime test to observe types, which are erased before execution
- ✗Assuming these tools inspect real runtime values rather than static types
Follow-up questions
- →How would you assert that a particular call is a compile error and stays one?
- →Why must these files sit inside the type-check run for the assertions to have any effect?
JuniorTheoryCommonWhat does type-checking your test files catch that running the tests cannot?
What does type-checking your test files catch that running the tests cannot?
The compiler checks that the tests still call the code correctly: a renamed field or a changed signature fails the build even on a line the test never executes. Running the tests checks behaviour, and only along the paths they actually take.
Common mistakes
- ✗Excluding test files from the type-check, so they silently drift from the code they test
- ✗Believing a passing test run implies the tests still call the API correctly
- ✗Expecting the compiler to validate what a test asserts rather than how it calls
Follow-up questions
- →A test calls only one overload. Why can a change to a different signature still break the build?
- →What do you configure so that the runner and
tsc --noEmitsee exactly the same files?
MiddleCodeCommonTyping a test double so it cannot drift from the interface it replaces
Typing a test double so it cannot drift from the interface it replaces
Type the double by the contract instead of beside it: const gateway: jest.Mocked<PaymentGateway> = { charge: jest.fn(), refund: jest.fn() }, or add satisfies PaymentGateway. The compiler then checks the double against the interface, so a changed signature breaks the build.
Common mistakes
- ✗Asserting the literal with
as, which lets the double diverge from the interface silently - ✗Reaching for
as anyon the double, which decouples it from the thing it replaces - ✗Typing the double as
Partial<T>, so a method missing from it is never an error
Follow-up questions
- →When is a
Partial<T>double legitimate, and what has to change in the code under test? - →How would you keep a fixture factory in sync with the type it builds?
MiddleTheoryOccasionalWhy is as any in a test double an anti-pattern, and what replaces it?
Why is as any in a test double an anti-pattern, and what replaces it?
as any decouples the double from the thing it doubles: the mock keeps compiling after the real signature changes, so the suite stays green while production is broken. Check the double against the contract instead — satisfies T or jest.Mocked<T>.
Common mistakes
- ✗Treating test code as exempt from type safety because it is never shipped
- ✗Assuming a stale double will be caught by the test run rather than by the compiler
- ✗Swapping
as anyforas unknown as T, which hides the same decoupling behind two assertions
Follow-up questions
- →You need only two of a dependency's twelve methods. What do you change so a partial double is legal?
- →How would you make a stale double fail CI rather than pass quietly?
MiddleCodeOccasionalAssert that an invalid call is rejected at compile time — and stays rejected
Assert that an invalid call is rejected at compile time — and stays rejected
Put @ts-expect-error above the invalid call. It compiles only while that line still errors, and becomes an error itself the day the call is accepted — so the assertion cannot rot, unlike @ts-ignore, which stays quiet forever. The file must sit inside the type-check run.
Common mistakes
- ✗Trying to assert a compile error with a runtime
toThrow— the code never compiles, so nothing runs - ✗Using
@ts-ignore, which keeps passing after the error disappears and rots into a dead directive - ✗Leaving the type-test file out of the
tsconfigCI checks, so the assertion is never evaluated
Follow-up questions
- →Why does
@ts-expect-errorfail the build once the error underneath it is fixed? - →How would you assert an exact inferred return type rather than a rejected call?
MiddleCodeOccasionalTyping a custom Jest matcher so that expect(x).toBeIsoDate() compiles
Typing a custom Jest matcher so that expect(x).toBeIsoDate() compiles
expect.extend only registers the implementation; nothing about it reaches the type level. You add the method by declaration merging into Jest's matcher interface — declare global { namespace jest { interface Matchers<R> { toBeIsoDate(): R } } }. The merged member is what compiles.
Common mistakes
- ✗Expecting
expect.extendto change anything at the type level — it is a runtime registration only - ✗Asserting at every call site instead of merging the member into the matcher interface once
- ✗Omitting
declare global, so the augmentation stays local to the file and the call site still errors
Follow-up questions
- →How does the generic parameter
RonMatchers<R>make the matcher work with.notand with async? - →Where must the declaration file live for the augmentation to be picked up by the type-check?