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
- Five consumers and an API that changes
- The contract as an executable artefact
- Mock servers: Prism on top of
openapi.yaml - Static versus dynamic mocks, and their limits
- Doubles on the consumer side:
mswin the SPA - Doubles on the provider side:
nockfor SwiftShip - Provider contract tests: validating the responses
- Validating the requests too
- Detecting breaking changes with
oasdiff oasdiffas a gate in continuous integration- Consumer-driven contract testing: Pact
- When Pact pays off and when it is over-engineering
- The table of test types
- End-to-end tests: the purchase journey
- Ephemeral environment, seeded data and isolation
- The Postman collection in CI with Newman
- Load and security: where they fit
- What runs when
- 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
refactorof the mappers makestastingNotesstop 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 receiving400. - An
enumgains 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.
- 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.
- Mock servers: Prism on top of
openapi.yaml
openapi.yamlA 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.
- 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:
- 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_stockunless you ask for it withPrefer. - There is no state. You create an order with
POSTandGET /ordersstill returns the same old example. Complete journeys cannot be tested this way. - There is no real authentication. The mock validates neither tokens nor permissions.
- 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.
- Doubles on the consumer side:
msw in the SPA
msw in the SPAPrism 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.
- Doubles on the provider side:
nock for SwiftShip
nock for SwiftShipThe 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.
- 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
};
}
- 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.
- Detecting breaking changes with
oasdiff
oasdiffThe 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.yamlTypical 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 20The 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.
oasdiff as a gate in continuous integration
oasdiff as a gate in continuous integrationTo compare you need a reference. Two strategies:
- Against the main branch, with
git show main:openapi.yaml. Simple and good enough for most teams. - 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 onmain— 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
fiWith 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.
- 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".
- 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.
- 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.
- 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.
- 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 aliases4. 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.
- 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.xmlNewman 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.
- 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.
- 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
400caught 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 andoasdiff. oasdiffwith no stable reference. Comparing against a moving branch produces noise. Compare againstmainor, 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:
- Adding the optional
roastingCountryfield to theGET /coffeesresponse. - Changing
limitfrom a maximum of 100 to a maximum of 50. - Adding the value
decafto theroastenumin the response. - Renaming
tastingNotestonotes. - Making the
methodfield mandatory in the body ofPOST /orders/{id}/payment. - Adding the
429response to an operation that did not document it. - Changing
totalEurosfromnumbertostringto 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', ...)andmatchesContract('/orders', 'post', 409, ...): the first validates against the generic schema, the second additionally checks thatopenapi.yamldocuments that409. 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 the403, the window409and the duplicate409.- Before writing the code: add the operation to
openapi.yaml, including the new error codes (return_window_expired) in theErrorschema'senum. If it is not in the contract,matchesContractfails, and that is the point: contract first. oasdiff breaking: adding an endpoint and a value to the errorenumis 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-window409(a specific message, not a generic one) and with403. - A handler with
Prefer: code=409in 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
mswor 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
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
