level 05 / contract-and-schema

Contract Testing & Schema Validation

Validate API response shapes with JSON Schema and understand consumer-driven contract testing.

What Is Contract Testing?

A contract is the agreed-upon interface between a consumer (frontend, mobile app) and a provider (API). Contract testing verifies both sides honour it — before integration, in CI.

Consumer                Provider
────────                ────────
Frontend app  ←──────→  REST API
               contract
               (shape of
               requests &
               responses)

Two levels:

  1. Schema validation — assert response matches expected structure (fields, types)
  2. Consumer-driven contract testing (CDCT) — consumer publishes expectations; provider verifies them (Pact, PactFlow)

JSON Schema Validation with Ajv

import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { test, expect } from '@playwright/test';

const ajv = new Ajv();
addFormats(ajv);

const bookingSchema = {
  type: 'object',
  required: ['firstname', 'lastname', 'totalprice', 'depositpaid', 'bookingdates'],
  properties: {
    firstname:   { type: 'string' },
    lastname:    { type: 'string' },
    totalprice:  { type: 'number' },
    depositpaid: { type: 'boolean' },
    bookingdates: {
      type: 'object',
      required: ['checkin', 'checkout'],
      properties: {
        checkin:  { type: 'string', format: 'date' },
        checkout: { type: 'string', format: 'date' },
      },
    },
  },
};

test('booking response matches schema', async ({ request }) => {
  const res = await request.get('https://restful-booker.herokuapp.com/booking/1');
  expect(res.status()).toBe(200);
  const body = await res.json();
  const valid = ajv.validate(bookingSchema, body);
  if (!valid) throw new Error(ajv.errorsText());
});

TypeScript Interfaces as Living Contracts

Use TypeScript to enforce shape at compile time:

interface Booking {
  firstname: string;
  lastname: string;
  totalprice: number;
  depositpaid: boolean;
  bookingdates: { checkin: string; checkout: string };
  additionalneeds?: string;
}

test('cast and assert booking shape', async ({ request }) => {
  const res = await request.get('/booking/1');
  const body = (await res.json()) as Booking;
  expect(typeof body.firstname).toBe('string');
  expect(typeof body.totalprice).toBe('number');
});

Consumer-Driven Contract Testing (CDCT)

Problem: Integration tests catch contract breaks late (deployed environment). CDCT catches them in CI, per service.

Flow with Pact:

  1. Consumer writes a test describing what it expects from the provider
  2. Pact generates a contract file (*.json)
  3. Provider verifies its implementation against the contract
  4. Pact Broker stores and versions contracts
// consumer pact test (simplified)
await provider.addInteraction({
  state: 'booking exists',
  uponReceiving: 'a request for booking 1',
  withRequest: { method: 'GET', path: '/booking/1' },
  willRespondWith: {
    status: 200,
    body: like({ firstname: 'Jane', totalprice: 150 }),
  },
});

Schema Drift Detection

Run schema validation in CI against production snapshots:

// record snapshot
const snapshot = await (await request.get('/api/schema')).json();
fs.writeFileSync('schema-snapshot.json', JSON.stringify(snapshot));

// compare in next CI run
const current = await (await request.get('/api/schema')).json();
const previous = JSON.parse(fs.readFileSync('schema-snapshot.json', 'utf8'));
expect(current).toMatchObject(previous); // new fields ok; removed fields fail