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 production
Why 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.