Contract testing has a reputation for being complicated. Part of that is the tooling, part of it is the vocabulary. The underlying idea is simple, and you can get most of the benefit without adopting a framework at all.

The problem it solves

Your frontend expects the user endpoint to return displayName. A backend developer renames it to display_name because it matches the database column. Unit tests on both sides pass, because each side tests its own assumptions. Nothing catches the mismatch until someone opens the app.

Integration tests catch it, but only if you have a full environment with both services deployed, and only for the paths you happen to cover. Contract testing catches it in the backend's own pull request, in seconds.

The core idea

The consumer writes down what it needs. The provider proves it still delivers that. That written-down expectation is the contract.

Consumer side, expressed as a mock expectation:

// "When I GET /api/users/1, I need an id, a displayName and an email."
await provider.addInteraction({
  state: 'a user with id 1 exists',
  uponReceiving: 'a request for user 1',
  withRequest: { method: 'GET', path: '/api/users/1' },
  willRespondWith: {
    status: 200,
    body: {
      id: like(1),
      displayName: like('Pavel'),
      email: like('someone@example.com'),
    },
  },
});

Provider side, that same contract gets replayed against the real service. If displayName disappears, the backend's build goes red, in the pull request that caused it.

Note what the contract does not say. It doesn't assert the name is "Pavel", and it doesn't care about the other twelve fields the endpoint returns. It pins down structure, not values, and only the parts the consumer uses. That's what keeps contracts from becoming a maintenance burden.

When it's worth it

Contract testing pays off when you have separate services owned by separate teams, deploying independently. That's the situation it was designed for.

If you have one repository with a frontend and backend that always deploy together, you probably don't need it. A handful of end-to-end tests covers the same risk with less machinery.

A lighter version to start with

If a full contract testing tool feels like too much, schema validation gets you a surprising distance. Define the response shape once and assert against it in your API tests:

import { z } from 'zod';

const UserResponse = z.object({
  id: z.number(),
  displayName: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

test('user endpoint matches the expected shape', async ({ request }) => {
  const res = await request.get('/api/users/1');
  expect(res.status()).toBe(200);
  UserResponse.parse(await res.json()); // throws on any mismatch
});

This catches renamed fields, changed types, and nulls where you expected values. It won't catch a change made in a repository that doesn't run these tests, which is exactly the gap real contract testing fills. But it takes an afternoon, and it will find something.

What I'd tell my past self

Start with the schema assertions. Live with them for a few months. If you find yourself repeatedly discovering breakages in a different team's service after they've already deployed, that's your signal to graduate to proper consumer-driven contracts. Adopting the heavy tooling before you feel that specific pain mostly buys you configuration files.