Typing a test double so it cannot drift from the interface it replaces
A test replaces the real PaymentGateway with a double. Today the double is a plain object literal, so when the interface gains a method or changes a signature the test keeps compiling against the stale shape and stays green while production breaks.
Requirements: the double must be checked against PaymentGateway, every member must remain a spy the test can assert calls on, and adding a method to the interface must fail the test build rather than pass silently.
interface PaymentGateway {
charge(cents: number, currency: 'usd' | 'eur'): Promise<{ id: string }>;
refund(id: string): Promise<void>;
}
const gateway = { charge: jest.fn(), refund: jest.fn() }; // your code here
await gateway.charge(500, 'usd');Write the implementation.
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.
- ✗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
- →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?
The solution
Two working forms — both check the double against the contract.
import { jest } from '@jest/globals';
interface PaymentGateway {
charge(cents: number, currency: 'usd' | 'eur'): Promise<{ id: string }>;
refund(id: string): Promise<void>;
}
// Form 1 — annotate with the mocked type
const gateway: jest.Mocked<PaymentGateway> = {
charge: jest.fn(),
refund: jest.fn(),
};
// Form 2 — satisfies, which keeps each spy's precise type
const gateway2 = {
charge: jest.fn(),
refund: jest.fn(),
} satisfies PaymentGateway;// the interface grows:
// settle(id: string): Promise<void>;
// ❌ Property 'settle' is missing in type '{ charge: ...; refund: ... }'
// — the test build fails, and the stale double never reaches productionWhy it works
The idea is that the double is typed by the contract, not beside it. Both the jest.Mocked<PaymentGateway> annotation and satisfies PaymentGateway force the compiler to compare the object against the interface: a missing method or a drifted signature becomes a build error.
The difference between them is what you see afterwards: the annotation widens each member to the interface's type, while satisfies keeps the precise inferred type of every jest.fn(), which is handier for mockResolvedValue.
⚠️ as PaymentGateway does not buy this: an assertion merely asks the compiler to take your word for it, so the double quietly diverges from the interface. as any severs the link entirely.