Contract Testing APIs
Keep frontend and backend agreements honest — schema contracts, Pact-style consumer tests, and OpenAPI-driven checks without brittle E2E.
- testing
- contract-testing
UI tests that hit a shared staging API flake and fail for reasons that aren’t the frontend. Contract tests verify that the provider still speaks the shape the consumer expects — status codes, headers, JSON fields — without a full browser.
The problem
Frontend assumes { user: { id, name } }
Backend renames to { data: { userId, fullName } }
// Unit tests green (mocked)
// E2E red or prod red
Mocks freeze an old contract. Contract tests make the agreement explicit.
Approaches (pick by maturity)
| Approach | How |
|---|---|
| Shared schema package | Zod/JSON Schema published for FE+BE |
| OpenAPI as source | Generate types + validate responses |
| Consumer-driven (Pact) | Consumer defines expected interactions; provider verifies |
| Runtime validation in FE | Parse every response (defense in depth) |
Schema package (lightweight)
// packages/contracts/user.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
export type User = z.infer<typeof UserSchema>;
Frontend parses with the same schema used in provider tests:
// provider test
expect(() => UserSchema.parse(responseBody)).not.toThrow();
Pact-style consumer test (sketch)
// consumer
await provider.addInteraction({
state: 'user 1 exists',
uponReceiving: 'get user 1',
withRequest: { method: 'GET', path: '/users/1' },
willRespondWith: {
status: 200,
body: { id: '1', name: 'Ada', email: 'ada@example.com' },
},
});
const user = await api.getUser('1');
expect(user.name).toBe('Ada');
Provider CI replays interactions against the real service.
OpenAPI validation
# example idea — validate recorded responses or live staging against spec
npx openapi-response-validator ...
Generate TypeScript types from OpenAPI for compile-time hints — still validate at runtime for safety (typing fetch).
What to put in the contract
- URL + method
- Required query/headers
- Status codes you handle
- Critical body fields and types
- Error shape for 4xx
Avoid over-specifying pixel-perfect payloads for unused fields — contracts should enable evolution with versioning or optional fields.
Relation to MSW
MSW handlers should mirror the contract, not invent a third shape. Generate fixtures from schemas:
const user = UserSchema.parse({
id: '1',
name: 'Ada',
email: 'ada@example.com',
});
See MSW.
Footguns
- Contracts only on happy path.
- Consumer tests that never run provider verification.
- Duplicated hand-written types + schemas that drift.
- Using full E2E as the only contract — slow and flaky.
Interview out-loud answer
“Contract tests verify the API shape between consumer and provider. I prefer shared schemas or Pact so mocks don’t lie. Frontend still parses unknown JSON. E2E stays for journeys, not for field-level schema coverage.”
Breaking change process
When the provider must break a field, version the endpoint or introduce the new field alongside the old, migrate consumers via shared schema version bumps, then remove. Contract failures in CI should block provider merge the same way unit tests do.
Extra practice
Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.
Related on this site
- Typing fetch responses
- Runtime validation with schemas
- MSW mock service worker
- Mocking fetch in tests
- Integration testing UI
Further reading
Related guides
- Accessibility Testing in CIWire axe and lint rules into CI without false confidence — what automation catches, what it misses, and a practical pipeline.
- Component Testing StorybookUse Storybook as a component workshop and optional test runner — stories as living specs, interaction tests, and a11y checks.
- Coverage Metrics PitfallsWhy 100% line coverage can still ship bugs — gaming metrics, useless tests, and what coverage is actually good for.
- End-to-End Testing TradeoffsWhen E2E tests earn their keep, why they flake, and how to keep a small critical-path suite instead of a slow second frontend.
- Fake Timers for DebounceTest debounced and throttled UI with Vitest/Jest fake timers — advance time deterministically without real sleeps.