ESC

Type to search the knowledge base.

Contract Testing APIs

Keep frontend and backend agreements honest — schema contracts, Pact-style consumer tests, and OpenAPI-driven checks without brittle E2E.

advanced3 min read
  • 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.

Docs: Pact, OpenAPI, Zod.

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

  1. Contracts only on happy path.
  2. Consumer tests that never run provider verification.
  3. Duplicated hand-written types + schemas that drift.
  4. 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.

Further reading

Related guides