OpenAPI Contract Testing with Supertest
An OpenAPI spec is a contract. When your API response drifts from the spec — a field renamed, a type changed, a required property missing — clients break silently. Supertest combined with schema validation lets you assert that every API response matches the contract, automatically, on every test run.
Key Takeaways
Validate response bodies against your OpenAPI spec in every test. Tools like openapi-response-validator and ajv can check the actual response against the schema definition automatically — no manual assertions for every field.
Generate tests from your OpenAPI spec. Tools like openapi-backend can auto-generate test cases for all defined operations, catching routes you forgot to test.
Test the schema, then test the business logic. Schema tests verify the contract; business logic tests verify the behavior. Both are necessary; neither replaces the other.
Fail loudly on schema drift. A CI step that validates your implementation against your spec catches breaking changes before they ship — not after.
Keep the spec as source of truth. Generate TypeScript types, validation middleware, and mock servers from the spec — don't maintain them separately.
What Contract Testing Means for APIs
Consumer-driven contract testing (Pact, etc.) is about verifying that a consumer's expectations match a provider's implementation. For HTTP APIs with an OpenAPI spec, you have a different but equally important problem: ensuring your implementation matches your own published spec.
When a field is renamed in code but not in the spec, or a new required field is added to responses without a spec update, consumers who rely on the spec break — even if you didn't change the spec intentionally.
Supertest + OpenAPI validation closes this gap by checking every response against the spec schema automatically.
Tools
We'll use:
supertest— HTTP test clientopenapi-response-validatororajv— response schema validationswagger-parseror@apidevtools/swagger-parser— load and dereference the specopenapi-backend— request/response validation against the spec
npm install --save-dev supertest jest \
@apidevtools/swagger-parser \
ajv ajv-formats \
openapi-backendYour OpenAPI Spec
# api/openapi.yaml
openapi: 3.0.3
info:
title: Users API
version: 1.0.0
paths:
/users:
get:
operationId: listUsers
summary: List all users
parameters:
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
post:
operationId: createUser
summary: Create a user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserInput'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Email already exists
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
User:
type: object
required: [id, email, name, createdAt]
properties:
id:
type: integer
email:
type: string
format: email
name:
type: string
role:
type: string
enum: [user, admin]
default: user
createdAt:
type: string
format: date-time
CreateUserInput:
type: object
required: [email, name]
properties:
email:
type: string
format: email
name:
type: string
minLength: 1
maxLength: 100
Error:
type: object
required: [error]
properties:
error:
type: string
code:
type: stringBuilding a Schema Validator Helper
// tests/helpers/openapi-validator.js
const SwaggerParser = require('@apidevtools/swagger-parser');
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const path = require('path');
let api;
let ajv;
async function loadSpec() {
if (api) return;
api = await SwaggerParser.dereference(
path.resolve(__dirname, '../../api/openapi.yaml')
);
ajv = new Ajv({ allErrors: true });
addFormats(ajv);
}
function getResponseSchema(operationId, statusCode) {
for (const [, pathItem] of Object.entries(api.paths)) {
for (const [, operation] of Object.entries(pathItem)) {
if (operation.operationId === operationId) {
const response = operation.responses[statusCode];
if (!response?.content?.['application/json']?.schema) return null;
return response.content['application/json'].schema;
}
}
}
throw new Error(`Operation '${operationId}' with status ${statusCode} not found in spec`);
}
function validateResponse(operationId, statusCode, body) {
const schema = getResponseSchema(operationId, statusCode);
if (!schema) return { valid: true, errors: [] };
const validate = ajv.compile(schema);
const valid = validate(body);
return {
valid,
errors: validate.errors || [],
};
}
function expectValidResponse(operationId, statusCode, body) {
const result = validateResponse(operationId, statusCode, body);
if (!result.valid) {
throw new Error(
`Response does not match OpenAPI spec for ${operationId} (${statusCode}):\n` +
result.errors.map(e => ` ${e.instancePath} ${e.message}`).join('\n')
);
}
}
module.exports = { loadSpec, validateResponse, expectValidResponse };Writing Contract Tests
// tests/contract/users.test.js
const request = require('supertest');
const { app, db, users: userStore } = require('../../src/app');
const { loadSpec, expectValidResponse } = require('../helpers/openapi-validator');
describe('Users API — contract tests', () => {
beforeAll(async () => {
await loadSpec();
});
afterEach(() => {
userStore.clear();
});
describe('GET /users', () => {
it('returns 200 matching User[] schema when empty', async () => {
const res = await request(app).get('/users');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expectValidResponse('listUsers', '200', res.body);
});
it('returns 200 matching User[] schema with data', async () => {
// Seed some data
await request(app).post('/users').send({ email: 'alice@example.com', name: 'Alice' });
await request(app).post('/users').send({ email: 'bob@example.com', name: 'Bob' });
const res = await request(app).get('/users');
expect(res.status).toBe(200);
expectValidResponse('listUsers', '200', res.body);
// Business logic: check we get the right count
expect(res.body).toHaveLength(2);
});
it('returns array even when there are no users', async () => {
const res = await request(app).get('/users');
expect(Array.isArray(res.body)).toBe(true);
});
});
describe('POST /users', () => {
it('returns 201 matching User schema on success', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'charlie@example.com', name: 'Charlie' });
expect(res.status).toBe(201);
expectValidResponse('createUser', '201', res.body);
});
it('response includes all required User fields', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'diana@example.com', name: 'Diana' });
// Schema validation catches missing required fields
expectValidResponse('createUser', '201', res.body);
// Explicit checks for business-logic values
expect(res.body.email).toBe('diana@example.com');
expect(res.body.name).toBe('Diana');
});
it('returns 400 matching Error schema for invalid input', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'not-an-email', name: 'Bad' });
expect(res.status).toBe(400);
expectValidResponse('createUser', '400', res.body);
});
it('returns 409 matching Error schema for duplicate email', async () => {
await request(app).post('/users').send({ email: 'dup@example.com', name: 'Dup' });
const res = await request(app)
.post('/users')
.send({ email: 'dup@example.com', name: 'Dup 2' });
expect(res.status).toBe(409);
expectValidResponse('createUser', '409', res.body);
});
});
describe('GET /users/:id', () => {
it('returns 200 matching User schema for existing user', async () => {
const createRes = await request(app)
.post('/users')
.send({ email: 'eve@example.com', name: 'Eve' });
const userId = createRes.body.id;
const res = await request(app).get(`/users/${userId}`);
expect(res.status).toBe(200);
expectValidResponse('getUser', '200', res.body);
});
it('returns 404 matching Error schema for missing user', async () => {
const res = await request(app).get('/users/99999');
expect(res.status).toBe(404);
expectValidResponse('getUser', '404', res.body);
});
});
});Detecting Schema Drift in CI
Create a separate test that validates the spec itself is consistent with what your implementation returns:
// tests/contract/schema-drift.test.js
const request = require('supertest');
const SwaggerParser = require('@apidevtools/swagger-parser');
const path = require('path');
const { app } = require('../../src/app');
describe('Schema drift detection', () => {
it('spec file is valid OpenAPI 3.0', async () => {
await expect(
SwaggerParser.validate(path.resolve(__dirname, '../../api/openapi.yaml'))
).resolves.toBeDefined();
});
it('all spec-defined paths respond with documented status codes', async () => {
const api = await SwaggerParser.parse(
path.resolve(__dirname, '../../api/openapi.yaml')
);
const testCases = [
{ path: '/users', method: 'get', expectedStatuses: [200] },
{ path: '/users', method: 'post', payload: { email: 'test@example.com', name: 'Test' }, expectedStatuses: [201] },
{ path: '/users/99999', method: 'get', expectedStatuses: [404] },
];
for (const tc of testCases) {
let req = request(app)[tc.method](tc.path);
if (tc.payload) req = req.send(tc.payload);
const res = await req;
expect(tc.expectedStatuses).toContain(res.status);
// Verify the status is documented in the spec
const specPath = tc.path.replace(/\/\d+/, '/{id}');
const operation = api.paths[specPath]?.[tc.method];
expect(operation?.responses?.[res.status]).toBeDefined();
}
});
});Auto-Generating Tests from the Spec
Instead of writing tests manually, you can generate them from the spec using openapi-backend:
// tests/contract/auto-generated.test.js
const OpenAPIBackend = require('openapi-backend').default;
const request = require('supertest');
const path = require('path');
const { app } = require('../../src/app');
describe('Auto-generated contract tests from OpenAPI spec', () => {
let api;
beforeAll(async () => {
api = new OpenAPIBackend({
definition: path.resolve(__dirname, '../../api/openapi.yaml'),
});
await api.init();
});
it('validates GET /users response against spec', async () => {
const res = await request(app).get('/users');
const validationResult = api.validateResponse(res.body, {
operationId: 'listUsers',
status: res.status,
});
expect(validationResult.errors).toBeNull();
});
it('validates POST /users 201 response against spec', async () => {
const res = await request(app)
.post('/users')
.send({ email: 'generated@example.com', name: 'Generated' });
const validationResult = api.validateResponse(res.body, {
operationId: 'createUser',
status: res.status,
});
expect(validationResult.errors).toBeNull();
});
});Catching Real Drift: Examples
Here's what contract tests catch that regular tests miss:
Missing required field:
// Bug: handler returns { id, email } but spec requires { id, email, name, createdAt }
// Your business logic test: expect(res.body.email).toBe('...') ← passes
// Contract test: expectValidResponse('createUser', '201', res.body) ← fails
// "/name is required"
// "/createdAt is required"Wrong type:
// Bug: handler returns { id: "42" } (string) instead of { id: 42 } (integer)
// Your business logic test: expect(res.body.id).toBeTruthy() ← passes
// Contract test: ← fails
// "/id must be integer"Undocumented status code:
// Bug: handler returns 422 for validation errors but spec documents 400
// Your test: expect(res.status).toBe(422) ← passes
// Spec drift detection: ← fails
// "422 is not documented in spec"CI Integration
Add a contract script to package.json:
{
"scripts": {
"test:contract": "jest tests/contract/ --forceExit",
"test:schema": "node -e \"require('@apidevtools/swagger-parser').validate('./api/openapi.yaml').then(() => console.log('Spec valid')).catch(e => { console.error(e); process.exit(1); })\""
}
}In your GitHub Actions workflow:
- name: Validate OpenAPI spec
run: npm run test:schema
- name: Run contract tests
run: npm run test:contract
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdbSummary
OpenAPI contract testing with Supertest works in layers:
- Spec validation —
swagger-parservalidates the spec itself is well-formed - Response validation —
ajvoropenapi-backendvalidates every response body matches the schema - Drift detection — automated tests verify that status codes and paths are all documented
- Auto-generation — generate basic test scaffolding from the spec to catch undocumented routes
The payoff: any time a field is renamed, a required property is added, or a type changes — in code or in the spec — a contract test fails in CI before the change reaches consumers. Schema drift becomes a CI failure, not a support ticket.