In 05-02 we finished openapi.yaml: a complete contract, validated, linted and published at /docs. It is an excellent document. And there is absolutely nothing guaranteeing that the server complies with it.

That sentence is worth pausing on. Right now somebody could add a mandatory field to POST /orders, rename priceEuros to price, or make a 404 return a different body, and it would all go through: the tests from 03-08 would stay green because they check what the code does, not what the contract promises; Spectral would stay green because the YAML is syntactically correct; and the SPA, Aroma Mobile and SwiftShip would find out in production.

That is the problem for this lesson: closing the loop between the contract and reality, in both directions. That real responses satisfy the schema. That a breaking change in the contract is detected before it is merged. And, along the way, that the SPA can be developed against a mock of the contract without waiting for the endpoint to exist.

We are going to add to the project a mock with Prism, schema validation inside the Supertest tests we already have, a breaking-change gate with oasdiff, and an end-to-end purchase journey. And we will see when Pact solves a real problem and when it is expensive over-engineering.

Contents

  1. Five consumers and an API that changes
  2. The contract as an executable artefact
  3. Mock servers: Prism on top of openapi.yaml
  4. Static versus dynamic mocks, and their limits
  5. Doubles on the consumer side: msw in the SPA
  6. Doubles on the provider side: nock for SwiftShip
  7. Provider contract tests: validating the responses
  8. Validating the requests too
  9. Detecting breaking changes with oasdiff
  10. oasdiff as a gate in continuous integration
  11. Consumer-driven contract testing: Pact
  12. When Pact pays off and when it is over-engineering
  13. The table of test types
  14. End-to-end tests: the purchase journey
  15. Ephemeral environment, seeded data and isolation
  16. The Postman collection in CI with Newman
  17. Load and security: where they fit
  18. What runs when

  1. Five consumers and an API that changes

The inventory of who depends on the Aroma Store API, and what happens if something breaks:

Consumer Who develops it How it is deployed If you break the contract
SPA aromastore.example Front-end team Continuously; it reloads by itself Fixed in hours
Aroma Mobile Mobile team App stores, with review Days or weeks, and some users stay on old versions forever
Admin panel panel.aromastore.example Internal team Continuously Hours, but it blocks operations
SwiftShip External company Its own cycle A meeting, emails, a commercial incident
CataBox Unknown third party You do not control it You find out from a tweet

The Aroma Mobile row is what makes everything in this lesson unavoidable: you cannot deploy to mobile clients. A version of the app from eight months ago is still calling your API, and it will carry on doing so. Any breaking change is permanent for somebody.

And a clarification about "breaking the contract": it takes neither bad faith nor carelessness. The typical breakages are unintentional and subtle:

  • A refactor of the mappers makes tastingNotes stop appearing when empty, instead of returning [].
  • An optimisation changes the order of the results and a consumer was relying on it.
  • Somebody adds .strict() to an input schema and an old app that was sending an extra field starts receiving 400.
  • An enum gains a new value (roast: "very_dark") and the generated TypeScript client from 05-02 does not account for it.

None of these is caught by reading the diff. They are caught with tools.

  1. The contract as an executable artefact

The idea that organises the lesson: openapi.yaml is not documentation, it is code. Everything that can be derived from it:

graph LR
    O[openapi.yaml] --> M[Mock with Prism<br/>the SPA moves without a backend]
    O --> V[Response validation<br/>in the Supertest tests]
    O --> D[oasdiff<br/>breaking-change gate]
    O --> C[Generated clients<br/>SPA and Aroma Mobile - 05-02]
    O --> P[Postman collection<br/>imported - 05-01]
    O --> G[Gateway configuration<br/>routes and schemas - 05-06]
    O --> R[Developer portal<br/>public documentation - 05-06]

Seven uses of one file. Each of them makes maintaining it badly more expensive and maintaining it well more profitable, which is exactly the incentive you want.

  1. Mock servers: Prism on top of openapi.yaml

A concrete situation: the SPA team has to build the "my orders with shipment detail" screen, which needs a GET /v1/orders/{id}/shipment that does not exist yet. Without a mock, they wait two weeks or invent data that later does not match.

Prism (from Stoplight) brings up an HTTP server that implements your specification:

npm install --save-dev @stoplight/prism-cli

# Mock on port 4010, with request validation
npx prism mock openapi.yaml --port 4010 --errors
# The SPA points at http://localhost:4010 and calls it exactly the same way
curl -s http://localhost:4010/coffees?roast=light | jq
{
  "data": [
    {
      "id": "cof_001",
      "name": "Ethiopia Yirgacheffe",
      "origin": "Ethiopia",
      "roast": "light",
      "priceEuros": 14.5,
      "stock": 120,
      "tastingNotes": ["citrus", "floral", "black tea"],
      "createdAt": "2026-01-15T08:30:00Z",
      "version": 3
    }
  ],
  "total": 137
}

That body comes from the firstPage example we wrote in 05-02. Here you see why we insisted that the examples be consistent: they are what the front-end team consumes for two weeks, and examples with absurd data produce an interface designed for absurd data.

Important Prism options:

Option Effect
--errors Returns 422 if the request does not satisfy the specification: invalid parameter, malformed body, missing mandatory header
--dynamic Generates random data that satisfies the schema, instead of repeating the example
-h 0.0.0.0 Listens on every interface: needed in Docker or for the mobile team
Prefer: example=name Header asking for a specific one of the defined examples
Prefer: code=404 Header asking for a specific response: this is how you test the errors

That last one is what turns the mock into something serious:

# Force the insufficient-stock 409 to lay out the error message
curl -i -X POST http://localhost:4010/orders \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55" \
  -H "Prefer: code=409, example=insufficientStock" \
  -d '{"customerId":"cus_842","items":[{"coffeeId":"cof_001","quantity":200}]}'

Without this, the front end lays out the happy path and discovers in production that it never thought about the insufficient-stock screen. With this, every error state can be tried in the browser on day one.

And --errors gives an unexpected gift: it validates the consumer. If the SPA forgets the Idempotency-Key, the mock answers 422 in development, not in production.

We add the scripts to the project:

{
  "scripts": {
    "mock": "prism mock openapi.yaml --port 4010 --errors",
    "mock:dynamic": "prism mock openapi.yaml --port 4010 --errors --dynamic"
  }
}

Prism has a second mode worth mentioning, prism proxy, which forwards requests to the real API and validates both directions against the specification, reporting every deviation. It is a cheap way to audit an entire staging environment.

  1. Static versus dynamic mocks, and their limits

Static (examples) Dynamic (--dynamic)
Where the data comes from The contract's examples Randomly generated, satisfying the schema
Realism High: a person wrote them Low: "name": "string", absurd dates
Stability Total: always the same response It changes on every call
Good for Layout, screenshots, demos, front-end tests Discovering the client's hidden assumptions
Bad for Detecting that the front end assumes a fixed order Any test that compares values

Dynamic mocks have one very specific and valuable use: breaking assumptions. If the SPA fails with --dynamic, it was assuming something the contract does not guarantee —that tastingNotes is never empty, that total is less than 1000, that names are short. That failure in development is a failure avoided in production.

The limits of any mock, and they must be kept firmly in mind:

  1. There is no business logic. The mock accepts an order for 500 units of a coffee with 120 in stock. It will never return insufficient_stock unless you ask for it with Prefer.
  2. There is no state. You create an order with POST and GET /orders still returns the same old example. Complete journeys cannot be tested this way.
  3. There is no real authentication. The mock validates neither tokens nor permissions.
  4. A passing mock proves nothing about the real API. It is the most dangerous trap: the front end is all green against the mock and fails against the real server.

Hence the rule: the mock unblocks parallel development; it replaces no integration test. And the moment of connecting the SPA to the real API should come as early as possible.

  1. Doubles on the consumer side: msw in the SPA

Prism is a separate process, useful while developing. For the front end's automated tests you need something that runs inside the test process: Mock Service Worker (msw), which intercepts requests at the network level without the application code noticing.

// aroma-spa/tests/mock-server.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const BASE_URL = 'https://api.aromastore.example/v1';

export const handlers = [
  // Catalogue with two coffees, genuinely filterable: the mock DOES implement the filter,
  // because the test wants to check that the SPA sends it correctly.
  http.get(`${BASE_URL}/coffees`, ({ request }) => {
    const url = new URL(request.url);
    const roast = url.searchParams.get('roast');

    const catalogue = [
      { id: 'cof_001', name: 'Ethiopia Yirgacheffe', roast: 'light', priceEuros: 14.5, stock: 120 },
      { id: 'cof_002', name: 'Colombia Huila', roast: 'medium', priceEuros: 12.9, stock: 80 },
    ];
    const data = roast ? catalogue.filter((c) => c.roast === roast) : catalogue;

    return HttpResponse.json({ data, total: data.length });
  }),

  // Business error: the SPA must show a specific message, not a generic one
  http.post(`${BASE_URL}/orders`, async ({ request }) => {
    if (!request.headers.get('Idempotency-Key')) {
      return HttpResponse.json({
        error: { code: 'idempotency_key_required', message: '...', details: [] },
      }, { status: 428 });
    }

    const body = await request.json();
    if (body.items.some((i) => i.quantity > 100)) {
      return HttpResponse.json({
        error: {
          code: 'insufficient_stock',
          message: 'There is not enough stock of "Ethiopia Yirgacheffe".',
          details: [{ field: 'items[0].quantity', requested: 200, available: 120 }],
        },
      }, { status: 409 });
    }

    return HttpResponse.json(
      { id: 'ord_5001', status: 'pending_payment', totalEuros: 29.0 },
      { status: 201, headers: { Location: '/v1/orders/ord_5001' } },
    );
  }),
];

export const mockServer = setupServer(...handlers);
// aroma-spa/tests/catalogue.test.js
import { mockServer } from './mock-server.js';
import { http, HttpResponse } from 'msw';

before(() => mockServer.listen({ onUnhandledRequest: 'error' }));
afterEach(() => mockServer.resetHandlers());
after(() => mockServer.close());

test('shows the rate limit notice when the API answers 429', async () => {
  // Overrides the handler for THIS test only
  mockServer.use(
    http.get('*/coffees', () => HttpResponse.json(
      { error: { code: 'rate_limit_exceeded', message: '...', details: [] } },
      { status: 429, headers: { 'Retry-After': '30' } },
    )),
  );

  const screen = render(<Catalogue />);
  await screen.findByText(/too many requests/i);
  // And we check it respects the Retry-After instead of retrying in a loop (04-04)
});

onUnhandledRequest: 'error' is the key option: any request the SPA makes that is not declared fails the test. That is how you discover unexpected calls —analytics, a forgotten endpoint— instead of letting them slip through silently.

The structural risk of msw, and it must be said plainly: these handlers are a third description of the API, written by the front-end team, which can drift from the real one. If the backend changes priceEuros to price, the front-end tests stay green. Two mitigations: generate the handlers from openapi.yaml with tools such as msw-auto-mock, or —what solves the problem at the root— the contract testing in section 11.

  1. Doubles on the provider side: nock for SwiftShip

The symmetrical problem: our API is also a consumer. It calls SwiftShip to create a shipment and sends it signed webhooks. The tests from 03-08 cannot really call an external service: it would be slow, fragile and would create real shipments.

nock intercepts Node's outbound HTTP requests:

// tests/integration/shipments.test.js
import './../helpers/test-environment.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import nock from 'nock';
import request from 'supertest';
import { migrate, seed } from '../helpers/test-database.js';
import { tokenFor } from '../helpers/token.js';

const SWIFTSHIP_API = 'https://api.swiftship.example';

test.before(() => {
  // No real request leaves the tests. If any code tries to call
  // a host that is not intercepted, nock throws and the test fails loudly.
  nock.disableNetConnect();
  nock.enableNetConnect('127.0.0.1');   // except Supertest, which calls itself
});

test.after(() => {
  nock.cleanAll();
  nock.enableNetConnect();
});

test('paying an order requests the shipment from SwiftShip', async (t) => {
  const { app } = await import('../../src/app.js');
  migrate(); seed();

  // We declare what we expect our API to send, and what the double will answer
  const scope = nock(SWIFTSHIP_API)
    .post('/v2/shipments', (body) => {
      // The assertion on the OUTBOUND request is what makes this test valuable:
      assert.equal(body.reference, 'ord_5001');
      assert.equal(body.weightGrams, 500);
      assert.ok(body.destination.postcode, 'the postcode must be sent');
      return true;
    })
    .matchHeader('authorization', /^Bearer /)
    .matchHeader('idempotency-key', /^[0-9a-f-]{36}$/)   // we also retry safely
    .reply(201, { shipmentId: 'shp_9001', tracking: 'SS-4471-XA' });

  const response = await request(app)
    .post('/v1/orders/ord_5001/payment')
    .set('Authorization', `Bearer ${tokenFor('customer', 'cus_842')}`)
    .set('Idempotency-Key', crypto.randomUUID())
    .send({ method: 'card', cardToken: 'tok_fictional_test' })
    .expect(200);

  assert.equal(response.body.status, 'paid');
  assert.equal(response.body.tracking, 'SS-4471-XA');
  assert.ok(scope.isDone(), 'SwiftShip was not called');
});

test('if SwiftShip fails, the payment still completes and the shipment stays pending', async () => {
  const { app } = await import('../../src/app.js');
  migrate(); seed();

  nock(SWIFTSHIP_API).post('/v2/shipments').reply(503, { message: 'maintenance' });

  const response = await request(app)
    .post('/v1/orders/ord_5001/payment')
    .set('Authorization', `Bearer ${tokenFor('customer', 'cus_842')}`)
    .set('Idempotency-Key', crypto.randomUUID())
    .send({ method: 'card', cardToken: 'tok_fictional_test' })
    .expect(200);

  // Business rule: the charge is not reversed because the carrier is down.
  // The shipment is queued and retried. This test documents that decision.
  assert.equal(response.body.status, 'paid');
  assert.equal(response.body.shipment.status, 'pending_request');
});

nock.disableNetConnect() is a practice worth adopting always: it guarantees that no test calls the internet. A suite that depends on the network is a suite that fails on Friday afternoons for reasons that have nothing to do with you.

The second test illustrates something only doubles can test: behaviour when a dependency fails. Provoking a real 503 from SwiftShip is impossible; simulating it is trivial.

  1. Provider contract tests: validating the responses

Now for the core. The question is: do our API's real responses satisfy openapi.yaml?

The technique: extract the contract's schemas, compile them with AJV and validate the response bodies against them inside the Supertest tests that already exist.

New file tests/helpers/contract.js:

// tests/helpers/contract.js
// Validates response bodies against the schemas in openapi.yaml.
// It is the piece that stops the contract and the implementation drifting apart.
import { readFileSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';       // OpenAPI 3.1 uses JSON Schema 2020-12
import addFormats from 'ajv-formats';
import YAML from 'yaml';
import assert from 'node:assert/strict';

const specification = YAML.parse(readFileSync('openapi.yaml', 'utf8'));

const ajv = new Ajv2020({
  strict: false,        // OpenAPI adds keywords AJV does not know (example, xml...)
  allErrors: true,      // we want ALL the errors, not just the first
  validateFormats: true,
});
addFormats(ajv);        // enables date-time, uuid, email, uri-reference...

// We register every components schema so that the internal $refs resolve.
for (const [name, schema] of Object.entries(specification.components.schemas)) {
  ajv.addSchema(schema, `#/components/schemas/${name}`);
}

/**
 * Checks that a body satisfies a schema from components.schemas.
 * @param {string} schemaName  e.g. 'CoffeeCollection'
 * @param {unknown} body       the response body
 */
export function matchesSchema(schemaName, body) {
  const schema = specification.components.schemas[schemaName];
  assert.ok(schema, `The schema "${schemaName}" does not exist in openapi.yaml`);

  const validate = ajv.compile(schema);
  const valid = validate(body);

  if (!valid) {
    const problems = validate.errors
      .map((e) => `  · ${e.instancePath || '(root)'} ${e.message}`)
      .join('\n');
    assert.fail(
      `The response does not satisfy the schema "${schemaName}":\n${problems}\n` +
      `Body received:\n${JSON.stringify(body, null, 2)}`,
    );
  }
}

/**
 * Finds in openapi.yaml the schema declared for an operation and a code,
 * and validates against it. Saves you from naming the schema by hand in every test.
 */
export function matchesContract(path, method, code, body) {
  const operation = specification.paths?.[path]?.[method.toLowerCase()];
  assert.ok(operation, `openapi.yaml does not describe ${method.toUpperCase()} ${path}`);

  const response = operation.responses?.[String(code)]
    ?? operation.responses?.[`${String(code)[0]}XX`];
  assert.ok(response, `openapi.yaml does not document the ${code} of ${method} ${path}`);

  // We resolve the components.responses $ref if there is one
  const resolved = response.$ref
    ? specification.components.responses[response.$ref.split('/').pop()]
    : response;

  const schema = resolved.content?.['application/json']?.schema;
  if (!schema) return;   // responses with no body, such as 204 or 304

  const name = schema.$ref?.split('/').pop();
  if (name) return matchesSchema(name, body);

  const validate = ajv.compile(schema);
  assert.ok(validate(body), JSON.stringify(validate.errors, null, 2));
}

And its use in the integration tests, which barely change:

// tests/integration/coffees.test.js — extending the tests from 03-08
import './../helpers/test-environment.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { migrate, seed } from '../helpers/test-database.js';
import { tokenFor } from '../helpers/token.js';
import { matchesContract, matchesSchema } from '../helpers/contract.js';

test('GET /v1/coffees satisfies the published contract', async () => {
  const { app } = await import('../../src/app.js');
  migrate(); seed();

  const response = await request(app)
    .get('/v1/coffees?roast=light&limit=10')
    .set('Authorization', `Bearer ${tokenFor('customer', 'cus_842')}`)
    .expect(200);

  // Behaviour assertions (the ones from 03-08, still necessary)
  assert.ok(response.body.data.every((c) => c.roast === 'light'));

  // CONTRACT assertion: the exact shape, against openapi.yaml
  matchesContract('/coffees', 'get', 200, response.body);
});

test('404 errors satisfy the catalogue Error schema', async () => {
  const { app } = await import('../../src/app.js');
  migrate(); seed();

  const response = await request(app)
    .get('/v1/coffees/cof_nonexistent')
    .set('Authorization', `Bearer ${tokenFor('customer', 'cus_842')}`)
    .expect(404);

  matchesSchema('Error', response.body);
  // The Error schema's enum guarantees the code is in the catalogue:
  // if somebody invents 'coffee_does_not_exist', this line fails.
  assert.equal(response.body.error.code, 'coffee_not_found');
});

test('POST /v1/orders returns an Order that conforms to the contract', async () => {
  const { app } = await import('../../src/app.js');
  migrate(); seed();

  const response = await request(app)
    .post('/v1/orders')
    .set('Authorization', `Bearer ${tokenFor('customer', 'cus_842')}`)
    .set('Idempotency-Key', crypto.randomUUID())
    .send({ customerId: 'cus_842', items: [{ coffeeId: 'cof_001', quantity: 2 }] })
    .expect(201);

  matchesContract('/orders', 'post', 201, response.body);
  assert.match(response.headers.location, /^\/v1\/orders\/ord_/);
});

What this catches that the 03-08 tests did not:

Change Did 03-08 see it? Does the contract test see it?
Renaming priceEuros to price Yes, if there was an assertion on that field Yes, always: it is required
No longer returning version No, unless explicitly asserted Yes: it is required in the schema
Returning priceEuros: 1450 (cents) Only if the value was asserted Yes, if the schema has multipleOf: 0.01... and above all the pattern/range would catch it
A new error code outside the catalogue No Yes: the Error schema's enum rejects it
createdAt without the trailing Z No Yes: format: date-time
Adding a new field No No, and that is correct: it is a compatible change (02-07)

The last row matters as much as the others. Remember that in 05-02 we deliberately left the output schemas without additionalProperties: false. If we closed them, every new field would break these tests and the team would end up disabling them.

A warning about coverage: these tests only validate the endpoints and codes you have actually exercised. If you never write a test that provokes the 429, nobody checks that its body satisfies the contract. A cheap way to raise coverage is a wrapper that validates every response automatically:

// tests/helpers/request.js
// Supertest wrapper that validates the contract on EVERY response, without remembering to.
import request from 'supertest';
import { matchesContract } from './contract.js';

export function checkedRequest(app, pathTemplate) {
  const agent = request(app);
  const original = agent.get.bind(agent);

  return {
    get(url) {
      return original(url).expect((res) => {
        matchesContract(pathTemplate, 'get', res.status, res.body);
      });
    },
    // ... equivalent post, patch, delete
  };
}

  1. Validating the requests too

The contract has two sides. It is also worth checking that the bodies we document as valid really are valid for the server, and that invalid ones are rejected:

// tests/integration/input-contract.test.js
import { readFileSync } from 'node:fs';
import YAML from 'yaml';
import test from 'node:test';
import request from 'supertest';
import { tokenFor } from '../helpers/token.js';

const specification = YAML.parse(readFileSync('openapi.yaml', 'utf8'));

test('every requestBody example in the contract is accepted by the API', async () => {
  const { app } = await import('../../src/app.js');
  migrate(); seed();

  const examples = specification.paths['/orders'].post
    .requestBody.content['application/json'].examples;

  for (const [name, example] of Object.entries(examples)) {
    const response = await request(app)
      .post('/v1/orders')
      .set('Authorization', `Bearer ${tokenFor('customer', 'cus_842')}`)
      .set('Idempotency-Key', crypto.randomUUID())
      .send(example.value);

    // An example from the contract that produces 400 is a CONTRACT bug:
    // you are publishing in the documentation a body your API rejects.
    assert.notEqual(response.status, 400,
      `The contract example "${name}" is rejected by the API: ` +
      JSON.stringify(response.body));
  }
});

This test looks minor and catches a very common, very damaging failure: the documentation example that does not work. It is the first thing anyone integrating with you copies and pastes, and if it fails, your API loses credibility in the first five minutes.

  1. Detecting breaking changes with oasdiff

The previous tests check that the server satisfies the current contract. The other question is still open: does the new contract break anyone relative to the previous one?

oasdiff compares two versions of a specification and classifies the differences:

# Installation (Go, or a binary, or a Docker image)
go install github.com/tufin/oasdiff@latest

# Summary of differences between the published version and this branch's
oasdiff diff openapi-production.yaml openapi.yaml --format text

# Only the BREAKING changes: this is what matters in CI
oasdiff breaking openapi-production.yaml openapi.yaml

Typical output when somebody slips up:

2 breaking changes: 2 error, 0 warning

error, in components/schemas/Coffee property/tastingNotes request property became required
    in API GET /coffees
error, in API POST /orders request property 'items/items/quantity' max was decreased
    from 99 to 20

The classification oasdiff makes coincides, not by chance, with the rules from 02-07:

Change Breaking? Reason
Adding an endpoint No Nobody was calling it
Adding an optional field to a request No Old clients do not send it
Adding a field to a response No Clients must ignore what they do not know
Making a request field mandatory Yes Old clients do not send it → 400
Removing a field from a response Yes Somebody was reading it
Removing a value from a response enum Yes A client may have had it mapped
Adding a value to a response enum "Soft" breaking The generated client may not account for it
Adding a value to a request enum No It widens what is accepted
Narrowing a range (smaller maximum) Yes Previously valid requests now fail
Widening a range (larger maximum) No It accepts more
Changing a field's type Yes The classic breakage
Removing an endpoint Yes Obvious
Marking as deprecated No It only warns; retirement is the change
Adding a new error code It depends If the client does an exhaustive switch, it affects them

That table is the operational translation of backwards compatibility. And the important thing is that it no longer depends on the pull request reviewer remembering it: a tool checks it.

Note the nuance in the two enum rows: the direction matters. Widening what you accept is safe; widening what you return can break a client that did a switch over the known values. It is exactly the roast: "very_dark" case from section 1, and that is why the contract in 05-02 warns in the field's description that new values may be added.

  1. oasdiff as a gate in continuous integration

To compare you need a reference. Two strategies:

  1. Against the main branch, with git show main:openapi.yaml. Simple and good enough for most teams.
  2. Against the specification published in production, downloaded from https://api.aromastore.example/docs/openapi.json. More correct —what matters is what is deployed, not what is on main— and somewhat more fragile.

A script that works locally and in CI:

#!/usr/bin/env bash
# tools/check-contract.sh
# Fails if this branch introduces breaking changes relative to the main branch.
set -euo pipefail

BASE="${1:-main}"
TEMP="$(mktemp -d)"

git show "${BASE}:openapi.yaml" > "${TEMP}/base.yaml" 2>/dev/null || {
  echo "There is no openapi.yaml on ${BASE}: first version, nothing to compare."
  exit 0
}

echo "== Differences relative to ${BASE} =="
oasdiff diff "${TEMP}/base.yaml" openapi.yaml --format text || true

echo "== Breaking-change check =="
if oasdiff breaking "${TEMP}/base.yaml" openapi.yaml --fail-on ERR; then
  echo "No breaking changes."
else
  cat <<'NOTICE'

BREAKING CHANGE DETECTED.

Under the versioning policy (02-07), a breaking change requires one of these three routes:

  1. Reformulate the change in a compatible way (optional field, default value,
     a new field instead of renaming the existing one).
  2. Start the deprecation cycle: keep the old thing, mark it `deprecated`,
     emit `Deprecation` and `Sunset`, and notify the consumers.
  3. Open /v2, with at least 6 months of overlap.

If the change is intentional and agreed, add the "breaking-change" label
to the pull request and document the decision in an ADR under docs/decisions/.
NOTICE
  exit 1
fi

With oasdiff in the pipeline, the conversation changes in nature: instead of arguing in review over whether a change breaks something, the tool says so and the discussion becomes what to do about it. This is the gate 05-05 will integrate into ci.yml.

  1. Consumer-driven contract testing: Pact

So far the contract is defined by the provider (us) and consumers adapt. It is the correct model for a public API. But there is another model that solves a different problem.

Imagine Aroma Store grows and inventory-service, payments-service and recommendations-service appear, calling each other. Uncomfortable questions: which fields of inventory-service's response does each consumer actually use? Can I remove one? The honest answer is usually "I do not know, so just in case I will not touch anything", and that is how services fossilise.

Pact reverses the direction: each consumer declares what it needs, and the provider verifies that it delivers.

sequenceDiagram
    participant C as Consumer SPA
    participant B as Pact Broker
    participant P as Aroma Store API
    C->>C: Consumer test against a local mock
    Note over C: The PACT is generated<br/>I call GET /v1/coffees with roast light<br/>and I need the id and priceEuros fields
    C->>B: Publishes the pact with its version and branch
    P->>B: Downloads all its consumers' pacts
    P->>P: Replays each interaction against the real API
    P->>B: Publishes the verification result
    B-->>C: can-i-deploy: may I deploy?
    B-->>P: can-i-deploy: may I deploy?

The consumer side:

// aroma-spa/tests/contract/coffees.pact.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, string, integer, decimal } = MatchersV3;

const provider = new PactV3({
  consumer: 'aroma-spa',
  provider: 'aroma-store-api',
});

test('the SPA gets the catalogue filtered by roast', async () => {
  await provider
    .given('there are light roast coffees')     // STATE the provider must set up
    .uponReceiving('a request for the light roast catalogue')
    .withRequest({
      method: 'GET',
      path: '/v1/coffees',
      query: { roast: 'light', limit: '20' },
      headers: { Authorization: like('Bearer fictional-token') },
    })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body: {
        // The matchers describe the SHAPE, not the specific values.
        // This is how the SPA declares exactly which fields it consumes.
        data: eachLike({
          id: string('cof_001'),
          name: string('Ethiopia Yirgacheffe'),
          roast: string('light'),
          priceEuros: decimal(14.5),
          stock: integer(120),
        }),
        total: integer(137),
      },
    })
    .executeTest(async (mockServer) => {
      const api = new CoffeesClient(mockServer.url);
      const result = await api.list({ roast: 'light', limit: 20 });
      assert.equal(result.data[0].name, 'Ethiopia Yirgacheffe');
    });
});

The provider side:

// tests/contract/verify-pacts.js
import { Verifier } from '@pact-foundation/pact';
import { server } from '../../src/server.js';
import { migrate, seed, seedLightRoastsOnly } from '../helpers/test-database.js';

await new Verifier({
  provider: 'aroma-store-api',
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: process.env.PACT_BROKER_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  providerVersion: process.env.GIT_COMMIT,
  publishVerificationResult: true,

  // The consumer's "states" are translated here into real data.
  // This is the part that is expensive to maintain and where Pact gets complicated.
  stateHandlers: {
    'there are light roast coffees': async () => {
      migrate(); seedLightRoastsOnly();
    },
    'order ord_5001 is pending payment': async () => {
      migrate(); seed();
    },
  },

  // The tokens in the pacts are fictional: here they are replaced with valid ones.
  requestFilter: (req, res, next) => {
    req.headers.authorization = `Bearer ${tokenFor('customer', 'cus_842')}`;
    next();
  },
}).verifyProvider();

What Pact gives that OpenAPI cannot:

  • Knowing which fields are really used. If no pact mentions tastingNotes, you can remove it with confidence. It is the only reliable way to know.
  • can-i-deploy. Before deploying, the broker tells you whether the version you want to deploy is compatible with the deployed versions of all its consumers. It is a deployment gate with real information, not with guesses.
  • Concrete interactions, not abstract shapes. The pact says "when I ask for this, with these parameters and in this state, I need this response".

  1. When Pact pays off and when it is over-engineering

Pact has a high cost and it is worth being honest about it: you have to operate a broker, write and maintain the stateHandlers —the part that hurts most—, coordinate two repositories and train two teams.

Situation Pact? Reason
Internal microservices, several teams Yes It is its exact use case: known, controllable consumers
Two teams in the same company (front and back) Maybe It pays off if deployment is independent and breakages are frequent
Public API with unknown consumers No You cannot force CataBox to publish a pact. The provider's OpenAPI contract rules
Mobile app with old versions still alive No The pact reflects the app's current version, not the one from eight months ago
One team, one consumer, a monolith No Integration tests cover the same thing for far less
External provider (SwiftShip) No You do not control their cycle. Use nock and integration tests against their sandbox

For Aroma Store today the answer is no, and it is worth reasoning it out: two of the five consumers are outside our control (SwiftShip and CataBox), one has permanently old versions (Aroma Mobile), and the API is essentially public. In that scenario, the provider's contract rules and openapi.yaml with oasdiff covers 90 % of the value for 10 % of the cost.

The answer would change the day Aroma Store splits into internal microservices with separate teams. Pact between orders-service and inventory-service would then be exactly the right tool.

And an idea that sums the choice up: Pact answers "what do my consumers need?"; OpenAPI answers "what do I promise?". With known consumers, the first question is more useful. With unknown consumers, it is impossible to answer, and only the second remains.

  1. The table of test types

Unit Integration Contract (provider) Contract (Pact) Mock (consumer) End-to-end
What it tests A function or service The whole API in process That the responses satisfy OpenAPI That what each consumer asks for is delivered That the client handles the responses well The real deployed system
What it brings up Nothing app + temporary SQLite Same as integration The API + the broker Nothing, it intercepts Everything: API, Redis, database
Speed Milliseconds Tenths of a second The same Seconds Milliseconds Minutes
Fragility Very low Low Low Medium Low High
When it runs On save On save / PR PR PR on both sides Front-end PR After deploying
What it does NOT detect Anything about integration Contract drift That the consumer uses it correctly Non-participating consumers That the real API complies Nothing, but it fails for unrelated causes
How many to have Many Quite a few One per endpoint and code One per real interaction The front end's Few
In Aroma Store Yes (03-08) Yes (03-08) Yes, this lesson No, for now Yes, in the SPA Yes, a handful

The "how many to have" row is the testing pyramid from 03-08 seen from the contract's point of view. The rule has not changed: many fast ones at the bottom, few slow ones at the top. What this lesson adds is a new layer —the contract— that is almost as cheap as the integration layer because it sits on top of it: they are not new tests, they are extra assertions in the ones that already exist.

  1. End-to-end tests: the purchase journey

End-to-end tests run against the system as actually deployed: a real process, a real database, real Redis, a real network. They are the only ones that catch a wrong environment variable in staging, a load balancer eating a header, or a migration that was not applied.

And they are expensive and fragile. Hence: few, and well chosen. The criterion is to cover the journeys whose breakage would stop the business. In Aroma Store there is one: the purchase.

// tests/e2e/purchase-journey.test.js
// Runs against a DEPLOYED environment, not against `app` in process.
// API_URL is injected from CI: staging, or local with docker-compose.
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';

const URL = process.env.API_URL ?? 'http://localhost:3000/v1';

async function call(path, options = {}) {
  const response = await fetch(`${URL}${path}`, {
    ...options,
    headers: { 'Content-Type': 'application/json', ...options.headers },
  });
  const body = response.status === 204 ? null : await response.json();
  return { status: response.status, body, headers: response.headers };
}

test('complete purchase journey', async (t) => {
  let token;
  let coffeeId;
  let orderId;

  await t.test('1. the customer logs in', async () => {
    const { status, body } = await call('/sessions', {
      method: 'POST',
      body: JSON.stringify({
        email: process.env.TEST_EMAIL,       // fictional seeded account
        password: process.env.TEST_PASSWORD, // comes from the secret manager
      }),
    });
    assert.equal(status, 200);
    assert.ok(body.token, 'no token was returned');
    token = body.token;
  });

  await t.test('2. browses the catalogue and picks an available coffee', async () => {
    const { status, body, headers } = await call('/coffees?available=true&limit=5', {
      headers: { Authorization: `Bearer ${token}` },
    });
    assert.equal(status, 200);
    assert.ok(body.data.length > 0, 'the catalogue is empty: was the database seeded?');

    // Checks that ONLY make sense in a real environment:
    assert.ok(headers.get('etag'), 'ETag missing: is the cache middleware active?');
    assert.ok(headers.get('aroma-ratelimit-remaining'), 'rate limiting missing');
    assert.ok(headers.get('aroma-trace-id'), 'trace correlation missing');

    coffeeId = body.data.find((c) => c.stock >= 2).id;
  });

  await t.test('3. creates an order with an idempotency key', async () => {
    const key = randomUUID();
    const requestBody = JSON.stringify({
      customerId: process.env.TEST_CUSTOMER_ID,
      items: [{ coffeeId, quantity: 2 }],
    });

    const first = await call('/orders', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': key },
      body: requestBody,
    });
    assert.equal(first.status, 201);
    assert.equal(first.body.status, 'pending_payment');
    orderId = first.body.id;

    // Retry with the SAME key: the idempotency from 02-03, really checked
    // (in production Redis and several instances are involved, not an in-memory table)
    const second = await call('/orders', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': key },
      body: requestBody,
    });
    assert.equal(second.status, 201);
    assert.equal(second.body.id, orderId, 'idempotency created a duplicate order');
  });

  await t.test('4. pays the order', async () => {
    const { status, body } = await call(`/orders/${orderId}/payment`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': randomUUID() },
      body: JSON.stringify({ method: 'card', cardToken: 'tok_fictional_sandbox' }),
    });
    assert.equal(status, 200);
    assert.equal(body.status, 'paid');
  });

  await t.test('5. fetches the order and checks its persisted status', async () => {
    const { status, body } = await call(`/orders/${orderId}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    assert.equal(status, 200);
    assert.equal(body.status, 'paid');
    assert.equal(body.totalEuros > 0, true);
    assert.ok(body._links.invoice, 'a paid order must link to its invoice');
  });

  t.after(async () => {
    // Clean-up: without it, every run leaves traces and the environment degrades.
    if (orderId) {
      await call(`/orders/${orderId}/cancellation`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': randomUUID() },
        body: JSON.stringify({ reason: 'automated test' }),
      });
    }
  });
});

Look at the three header assertions in step 2. They are the whole reason this test exists: no in-process test can check them, because in Supertest there is no reverse proxy, no Redis and none of the production environment variables. If the load balancer strips Aroma-Trace-Id or rate limiting is not configured in staging, this is the only thing that catches it.

  1. Ephemeral environment, seeded data and isolation

End-to-end tests have a reputation for fragility, and almost always for the same reason: shared state. Four rules that solve it.

1. An ephemeral environment. The environment is created at the start and destroyed at the end. With docker-compose (which we will see in 05-05) it is straightforward:

# The whole environment, brought up and destroyed within the run itself
docker compose -f docker-compose.test.yml up -d --wait
npm run migrate && npm run seed
API_URL=http://localhost:3000/v1 node --test tests/e2e/
docker compose -f docker-compose.test.yml down -v   # -v also deletes the volumes

--wait waits for the healthchecks to go green: without it, the tests start before the database and fail because of a race, not because of a real fault.

2. Seeded, known data, always fictional. The same npm run seed from 03-05: cof_001, cus_842, ord_5001. Never real customer data in a test environment, not even half-anonymised (04-02 and GDPR).

3. Isolation between runs. If the environment is shared —the usual case with staging— each run must use its own data:

// Unique prefix per run: two simultaneous pipelines do not collide
const tag = `e2e-${Date.now()}-${randomUUID().slice(0, 8)}`;
const email = `test+${tag}@example.test`;   // the "+" creates unique aliases

4. Test idempotency. It must be possible to run it twice in a row with the same result. That means not depending on state left behind by the previous run, and cleaning up at the end even on failure (t.after always runs).

Classic mistakes that break these four rules: tests that depend on the order they run in, one that creates a customer with a fixed email and fails the second time as a duplicate, and —the worst— the one that eats cof_001's stock until it runs out and makes every other test fail from that day on.

  1. The Postman collection in CI with Newman

The collection from 05-01 fits here naturally: it is an end-to-end test with a graphical interface for writing and debugging it.

npx newman run postman/aroma-store-v1.postman_collection.json \
  -e postman/test.postman_environment.json \
  --env-var "baseUrl=$STAGING_URL" \
  --env-var "customerPassword=$TEST_PASSWORD" \
  --delay-request 100 \
  --reporters cli,junit \
  --reporter-junit-export reports/newman.xml

Newman or end-to-end tests in code? They do not compete; they cover different needs:

Newman e2e tests in code
Who writes them QA and non-developers too Development
Debugging Excellent: graphical interface, step by step With the debugger
Complex logic Limited: loose scripts Everything the language offers
Pull request review Poor: a giant JSON that is unreadable in the diff Good: it is code
Reuse of utilities Scarce Total

The practical recommendation for Aroma Store: Newman for the post-deployment smoke tests —fast, broad, easy for anyone to extend— and code for the purchase journey, which has logic, clean-up and fine-grained assertions.

  1. Load and security: where they fit

Two more families, mentioned to place them on the map but not developed here.

Load tests. You already know them from 04-06 with autocannon. They run against staging, never on every pull request —they take time and need a stable environment— but on a schedule, weekly or before a launch. What matters is comparing against a baseline and watching the p99, not the average:

autocannon -c 50 -d 30 -H "Authorization: Bearer $TOKEN" \
  "$STAGING_URL/coffees?roast=light&limit=20"

Security tests. Scanners such as OWASP ZAP in baseline mode crawl the API looking for missing headers, insecure configurations and known vulnerabilities; they complement but do not replace the controls from 04-02 or a manual review of the authorisation logic, which is where an API's serious flaws live (no scanner detects BOLA from the OWASP API Top 10). Alongside them, npm audit covers the dependencies, and it is wired into CI in 05-05.

  1. What runs when

The table that orders everything above in time, and which is the direct lead-in to 05-05:

Moment What runs Target duration If it fails
On save (local) Lint, formatting, unit tests for the file you touched < 5 s You fix it instantly
Before the commit (hook) Lint, formatting, full unit tests < 30 s The commit is not created
On the pull request All of the above + integration + contract + spectral lint + swagger-cli validate + oasdiff breaking + npm audit + coverage < 5 min It cannot be merged
On merge into main Everything from the PR + build the image + push it to the registry < 10 min No deployable artefact is produced
On deploying to staging Migrations + e2e + Newman + check /health/ready < 10 min It is not promoted to production
On deploying to production Migrations + smoke tests (minimal subset) < 2 min Automatic rollback
In production, continuously Synthetic monitoring: one journey every 5 minutes from several regions Alert to the on-call team
Weekly Load with autocannon, ZAP scan, dependency audit A task, not a block

Two principles hold it up:

  • The later a failure is detected, the more expensive it is. A malformed 400 caught on save costs a minute; in production it costs an incident, a rollback and a call from SwiftShip.
  • The later in the pipeline, the fewer and slower the tests. Thousands of unit tests on save; five smoke tests in production. Inverting that ratio produces forty-minute pipelines that people learn to skip.

Common Mistakes and Tips

  • Trusting the mock as if it were the API. A front end that is all green against Prism can fail entirely against the real server. Connect to the real API as soon as possible.
  • Unrealistic examples in the contract. They feed the mock, the documentation and the input tests. An example with "name": "string" produces an interface designed for rubbish.
  • Closing output schemas with additionalProperties: false. Every new field breaks the contract tests and the team ends up disabling them. Close them on inputs only.
  • Writing the contract tests separately. They duplicate work and get abandoned. Build them as extra assertions inside the integration tests you already have.
  • Too many end-to-end tests. A half-hour pipeline, intermittent failures and, three weeks later, somebody marks them as optional. Few and critical.
  • Not cleaning up after the end-to-end test. The environment degrades until the tests fail on junk data and nobody knows why.
  • Adopting Pact because it sounds good. Without controllable consumers and without discipline in the stateHandlers, it is constant maintenance with no return. Start with OpenAPI and oasdiff.
  • oasdiff with no stable reference. Comparing against a moving branch produces noise. Compare against main or, better, against the specification published in production.
  • Ignoring the direction of the enum. Adding a value to what you accept is safe; adding it to what you return can break generated clients. Warn about it in the field's description.
  • Tip: validate the contract on the error response, not just the success one. Errors are the part of the contract that breaks most, because almost nobody tests them.
  • Tip: nock.disableNetConnect() in every suite. A test that calls the internet is a test that will fail some Friday for reasons that are not yours.
  • Tip: if a breaking change is unavoidable, make it deliberate. Label the pull request, write the ADR, notify the consumers and apply the deprecation cycle from 02-07. The tool detects; the process decides.

Exercises

Exercise 1: classifying contract changes

For each proposed change to openapi.yaml, state whether oasdiff breaking would flag it as breaking, justify it from the point of view of a specific Aroma Store consumer, and propose a compatible alternative where it is:

  1. Adding the optional roastingCountry field to the GET /coffees response.
  2. Changing limit from a maximum of 100 to a maximum of 50.
  3. Adding the value decaf to the roast enum in the response.
  4. Renaming tastingNotes to notes.
  5. Making the method field mandatory in the body of POST /orders/{id}/payment.
  6. Adding the 429 response to an operation that did not document it.
  7. Changing totalEuros from number to string to avoid floating-point problems.

Exercise 2: contract test for an endpoint's error path

Write the integration test that verifies that POST /v1/orders satisfies the contract on its error path: when more stock is requested than is available it must answer 409 with code: insufficient_stock, a body satisfying the Error schema and details naming the affected field. Use the matchesSchema and matchesContract helpers from section 7, and add a second assertion checking that the stock has not been modified.

Exercise 3: designing the testing strategy for a new endpoint

Aroma Store is adding POST /v1/orders/{id}/return: a customer requests the return of a shipped order within the following 14 days; the API validates the window, creates the request, notifies SwiftShip for collection and sends the customer an email.

Design the complete strategy: which tests you would write of each type (unit, integration, contract, e2e, consumer mock), what each one checks, what is simulated at each level, and at which moment in the table in section 18 each one runs. Also state what you would not test and why.

Solutions

Solution 1

# Change Breaking? Analysis and alternative
1 Adding roastingCountry to the response No Consumers must ignore unknown fields (02-07), which is why the output schemas are not closed. A generated TypeScript client simply does not know about it. It can be deployed without ceremony.
2 limit from 100 to 50 Yes Aroma Mobile asks for limit=100 on the catalogue screen; after the change it gets 400 invalid_parameter and the screen is empty for every user with that version installed. Alternative: accept up to 100 but return at most 50 items, documenting it; or start the deprecation with notice and change the maximum in /v2.
3 decaf in the response enum Yes ("soft" breaking) The SPA's TypeScript client has RoastEnum with three values; on receiving a fourth, deserialisation may fail or fall into an unexpected default. Aroma Mobile, with strict validation, might discard the item. Alternative: announce it in advance, publish the widened enum in the contract first so clients regenerate, and only then start returning the value. It is a good example of the contract having to change before the behaviour.
4 Renaming tastingNotes to notes Yes It is the most classic breakage: removing a field from the response. Every part of the SPA that paints the tasting notes stops showing them. Alternative: add notes while keeping tastingNotes with the same value, mark tastingNotes as deprecated: true with Sunset, wait six months and remove it in /v2. Cost: duplicating a field for half a year. Benefit: nobody breaks.
5 Making method mandatory on payment Yes An old version of Aroma Mobile that sent {} relying on the default method starts receiving 400, and users cannot pay. Alternative: keep it optional with a documented default (card), and make it mandatory in /v2. If the default is dangerous, the correct route is to reject the ambiguous cases explicitly with a specific error code and a deprecation cycle.
6 Documenting the 429 that already existed No It does not change behaviour: the API could already return 429. It is an improvement to the contract, and one of the most useful: oasdiff does not flag it, but it does prevent a consumer being taken by surprise. It is proof that the contract was lying by omission.
7 totalEuros from number to string Yes, with a nuance A type change breaks any typed client. The intention is good —avoiding floating point— but the execution is breaking. Alternative: add totalCents (an exact integer) alongside the existing totalEuros, document it as the preferred field for arithmetic, deprecate totalEuros and remove it in /v2. The general principle: add the correct field, do not transform the existing one.

Solution 2

// tests/integration/orders-contract.test.js
import './../helpers/test-environment.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { migrate, seed } from '../helpers/test-database.js';
import { tokenFor } from '../helpers/token.js';
import { matchesSchema, matchesContract } from '../helpers/contract.js';

test('POST /v1/orders with insufficient stock satisfies the 409 contract', async () => {
  const { app } = await import('../../src/app.js');
  migrate();
  seed();   // cof_001 is left with stock 120

  const token = `Bearer ${tokenFor('customer', 'cus_842')}`;

  // 1. Initial state: we note the stock before trying anything
  const before = await request(app).get('/v1/coffees/cof_001').set('Authorization', token).expect(200);
  const initialStock = before.body.stock;
  assert.equal(initialStock, 120, 'the seed did not leave the expected stock');

  // 2. We ask for more than there is
  const response = await request(app)
    .post('/v1/orders')
    .set('Authorization', token)
    .set('Idempotency-Key', randomUUID())
    .send({ customerId: 'cus_842', items: [{ coffeeId: 'cof_001', quantity: 99 }, { coffeeId: 'cof_001', quantity: 99 }] })
    .expect(409);

  // 3. CONTRACT assertions
  matchesSchema('Error', response.body);
  matchesContract('/orders', 'post', 409, response.body);

  // 4. BEHAVIOUR assertions
  assert.equal(response.body.error.code, 'insufficient_stock');
  assert.ok(Array.isArray(response.body.error.details));
  assert.ok(response.body.error.details.length > 0,
    'a stock 409 must say WHICH item fails: with no details it is not actionable');
  assert.match(response.body.error.details[0].field, /^items\[\d+\]/);

  // 5. A 4xx carries NO traceId: only 5xx do (03-07)
  assert.equal(response.body.error.traceId, undefined);

  // 6. No resource was created
  assert.equal(response.headers.location, undefined);

  // 7. THE KEY ASSERTION: the transaction was rolled back completely.
  // Without this, a partial failure could have reserved the first item's stock
  // before detecting that the second did not fit. It is the guarantee from 03-05.
  const after = await request(app).get('/v1/coffees/cof_001').set('Authorization', token).expect(200);
  assert.equal(after.body.stock, initialStock,
    'the stock changed even though the order failed: the transaction is not atomic');
  assert.equal(after.body.version, before.body.version,
    'the resource version changed: there was a write that should not have happened');
});

Notes on the design of this test:

  • Two items for the same coffee with 99 units each is a better case than a single item of 200: it additionally tests that the service sums the quantities per coffee instead of validating them separately, a real and frequent bug.
  • Checking the version as well as the stock catches compensating writes (decrease then increase again), which would leave the stock the same but the version different.
  • The test uses matchesSchema('Error', ...) and matchesContract('/orders', 'post', 409, ...): the first validates against the generic schema, the second additionally checks that openapi.yaml documents that 409. If somebody implements the error but forgets to document it, the second fails. That is exactly the drift we want to catch.

Solution 3

Testing strategy for POST /v1/orders/{id}/return

Level 1 — Unit (service, with the in-memory repository; runs on save):

Test What it checks What is simulated
Valid window An order shipped 3 days ago can be returned Clock fixed to a known date
Window elapsed 15 days ago → return_window_expired Fixed clock
Exact boundary Day 14 at 23:59 yes, day 15 at 00:01 no Fixed clock
Wrong status An order that is pending_payment or paid but not shipped → 409 In-memory repository
Duplicate return A second request on the same order → 409 In-memory repository
Amount calculation With a partial return of items, the amount adds up to the cent Nothing

The clock is the key design decision: the window logic must receive the current date as an injected dependency, not call Date.now() internally. Without that, boundary tests are impossible or fragile.

Level 2 — Integration (Supertest over app + temporary SQLite + nock; on save and in the PR):

Test What it checks What is simulated
Happy path 201 with Location, the order moves to return_requested SwiftShip with nock; email with a double
Authorisation A customer cannot return another customer's order → 403
Idempotency Two requests with the same Idempotency-Key → a single return
SwiftShip down A 503 from the carrier: the return is created anyway and stays pickup_pending nock with 503
Email down The email failure does not roll back the return; it is requeued Double for the email service
Correct outbound request The body sent to SwiftShip carries reference, address and weight Assertion inside nock

The two dependency-failure tests are the most valuable and the ones nobody writes: they document that a third party's failure must not undo a customer's operation.

Level 3 — Contract (inside the integration tests; in the PR):

  • matchesContract('/orders/{id}/return', 'post', 201, body) on the happy path.
  • matchesSchema('Error', body) on the 403, the window 409 and the duplicate 409.
  • Before writing the code: add the operation to openapi.yaml, including the new error codes (return_window_expired) in the Error schema's enum. If it is not in the contract, matchesContract fails, and that is the point: contract first.
  • oasdiff breaking: adding an endpoint and a value to the error enum is not breaking, so the gate will pass green. It is worth verifying anyway.

Level 4 — Consumer mock (msw in the SPA; in the front-end PR):

  • The "request a return" screen with 201, with the expired-window 409 (a specific message, not a generic one) and with 403.
  • A handler with Prefer: code=409 in Prism to lay it out before the endpoint exists.

Level 5 — End-to-end (after deploying to staging):

No new test. And this is a decision, not an oversight: the purchase journey already covers login, catalogue, order and payment, which are the pieces that break the business if they fail. A return is a secondary flow, it requires an order in shipped status —which means simulating the carrier's step— and its test would be slow and fragile. It is covered by integration, which gives 95 % of the confidence for 5 % of the cost.

Exception: if the return actually moves money towards the gateway, it does deserve one smoke test against the gateway's sandbox, run weekly and not on every deployment, because payment integrations are where a failure costs most.

What I would NOT test, and why:

  • The email's content. That is the email service's responsibility. It is enough to check it is asked to send with the right data; verifying the template is testing somebody else's library.
  • That SwiftShip collects the parcel. It is outside our system. We test that we ask them correctly and that we know how to handle their failure; the rest is their contract with us.
  • Every combination of returned items. The amount calculation is unit-tested with three or four representative cases and the boundaries. Testing every combination in integration is slow and adds nothing new.
  • The SPA's interface in e2e. This is an API; interface tests belong to the front end's repository and are done there with msw or with an automated browser.

When each runs (according to the table in section 18): unit tests on save; integration and contract on save and in the pull request; msw in the front-end PR; no new e2e; the gateway smoke test, weekly.

Conclusion

The loop is closed. openapi.yaml has stopped being a document that describes intentions and become an artefact from which seven different things are derived and —most importantly— against which reality is verified. You have brought up a mock with Prism that lets the SPA lay out the catalogue, the insufficient-stock screen and every error state with Prefer: code=409 weeks before the endpoint exists, knowing exactly where its limits are: with no logic, no state and no authentication, a mock unblocks parallel development but proves nothing about the real API. You have seen doubles on both sides of the conversation: msw intercepting in the SPA's tests, with onUnhandledRequest: 'error' so that no call slips through, and nock with disableNetConnect() to test what would otherwise be impossible —that a 503 from SwiftShip does not reverse a charge that has already been made.

The core of the lesson is the provider contract tests: the new tests/helpers/contract.js helper compiles the OpenAPI 3.1 components.schemas with AJV and validates the real bodies inside the Supertest tests from 03-08, without writing a separate suite. That catches what no manual assertion caught: a required field that disappears, a date with no Z, an error code invented outside the catalogue. And in the other direction, oasdiff turns the compatibility rules from 02-07 into an automatic gate with tools/check-contract.sh, which distinguishes what breaks from what does not and —a detail that is expensive to learn— knows that widening an input enum is safe and widening an output one is not. On Pact you take away a criterion, not an implementation: it answers "what do my consumers need?", a superb question when the consumers are known internal services and an impossible one when they are Aroma Mobile with versions from eight months ago and CataBox. For Aroma Store today, the provider's contract rules.

The project's new artefacts: tests/helpers/contract.js, tests/e2e/purchase-journey.test.js with the full journey login → catalogue → order with idempotency genuinely verified → payment → lookup, tools/check-contract.sh, the mock and mock:dynamic scripts, and @stoplight/prism-cli, ajv, ajv-formats, yaml and nock in devDependencies. And a table that orders all the course's work in time: what runs on save, what in the pull request, what on deployment and what in production.

That last table is literally the script for the next lesson. In 05-05, Continuous integration and deployment, we turn it into a pipeline that runs by itself: we will package the API in a multi-stage Dockerfile with a non-root user, a HEALTHCHECK on /health and the graceful shutdown on SIGTERM we wrote in 03-07; we will bring up the whole environment with docker-compose.yml including Redis; we will write .github/workflows/ci.yml with the gates in order —npm ci, lint, Spectral, tests with coverage, npm audit, oasdiff, building and publishing the image, and Newman against staging—; we will see why migrations have to be backwards-compatible and what happens when a DROP COLUMN meets the previous version still alive; and we will compare recreate, rolling, blue-green and canary, with the exact role /health and /health/ready play in keeping traffic out before it is time.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved