Validating Mock Responses Against OpenAPI
Your MSW handler returns { id: "42" } as a string, but the spec declares id an integer. Your WireMock stub omits the required createdAt field entirely. Both stubs pass their tests, because the tests assert against the same wrong shape the stub returns — the spec is never consulted. This page shows how to validate the responses your mock server produces against the OpenAPI schema at test time, so a non-conforming stub fails loudly.
Why this scenario arises
A mock and its test are usually written by the same person in the same sitting, so they agree with each other by construction — and drift away from the spec together. Three habits make it worse:
- Hand-authored stubs. A WireMock mapping or an inline MSW handler is typed by hand, so a
"42"where an integer belongs, or a missing required field, slips in unnoticed. - Copy-paste evolution. A new endpoint’s stub is cloned from an old one and lightly edited, inheriting fields the new spec never declared.
- Spec changes the mock never hears about. The contract adds a required property; the stub, written against the old shape, keeps passing.
The fix is to make the spec the arbiter: after the mock produces a response, validate that response against the OpenAPI schema for that exact operation and status. This is the fixture-and-stub edge of the contract testing and drift detection loop, complementing the spec-to-spec diffing in detecting OpenAPI contract drift in CI.
Solution
1. Compile response-schema validators with ajv
Install the dependencies:
npm i -D ajv ajv-formats yaml
The helper loads the OpenAPI document, registers components/schemas so $ref pointers resolve, and compiles one validator per (method, path, status). The strict: false option lets ajv tolerate OpenAPI-only keywords like nullable and example.
// contract/openapi-validator.ts
import Ajv, { type ValidateFunction } from 'ajv';
import addFormats from 'ajv-formats';
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
export interface OpenApiValidator {
/** Look up the validator for an operation + status, or undefined if none. */
forResponse(method: string, path: string, status: number): ValidateFunction | undefined;
}
export function buildOpenApiValidator(specPath: string): OpenApiValidator {
const raw = readFileSync(specPath, 'utf8');
const spec = specPath.endsWith('.json') ? JSON.parse(raw) : parse(raw);
const ajv = new Ajv({ strict: false, allErrors: true });
addFormats(ajv);
for (const [name, schema] of Object.entries<any>(spec.components?.schemas ?? {})) {
ajv.addSchema(schema, `#/components/schemas/${name}`);
}
const validators = new Map<string, ValidateFunction>();
for (const [path, methods] of Object.entries<any>(spec.paths ?? {})) {
for (const [method, op] of Object.entries<any>(methods)) {
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue;
for (const [status, response] of Object.entries<any>(op.responses ?? {})) {
const schema = response?.content?.['application/json']?.schema;
if (!schema) continue;
validators.set(
`${method.toUpperCase()} ${path} ${status}`,
ajv.compile(schema as object),
);
}
}
}
return {
forResponse(method, path, status) {
return validators.get(`${method.toUpperCase()} ${path} ${status}`);
},
};
}
2. Write a readable assertion helper
Wrap the validator so a violation throws with the offending field and message, not just false. This is the single function your tests call.
// contract/assert-response.ts
import { buildOpenApiValidator, type OpenApiValidator } from './openapi-validator';
let validator: OpenApiValidator | null = null;
function getValidator(): OpenApiValidator {
if (!validator) {
validator = buildOpenApiValidator(process.env.OPENAPI_SPEC ?? './specs/api.yaml');
}
return validator;
}
export interface FetchedResponse {
status: number;
body: unknown;
}
/**
* Fetch a mock response and assert it conforms to the OpenAPI schema for the
* matching operation + status. `specPath` is the templated path from the spec
* (e.g. "/api/v1/users/{id}"), not the concrete request URL.
*/
export async function assertResponseMatchesSpec(
method: string,
specPath: string,
url: string,
init?: RequestInit,
): Promise<FetchedResponse> {
const res = await fetch(url, init);
const body = await res.json().catch(() => undefined);
const validate = getValidator().forResponse(method, specPath, res.status);
if (!validate) {
throw new Error(
`no OpenAPI schema for ${method.toUpperCase()} ${specPath} ${res.status} — ` +
`the mock returned an undocumented status or operation`,
);
}
if (!validate(body)) {
const detail = (validate.errors ?? [])
.map((e) => ` ${e.instancePath || '/'} ${e.message}`)
.join('\n');
throw new Error(
`${method.toUpperCase()} ${specPath} ${res.status} response violates the spec:\n${detail}\n` +
`received: ${JSON.stringify(body)}`,
);
}
return { status: res.status, body };
}
3. Assert MSW handler output
Point the helper at an MSW server running in Node. Because MSW intercepts fetch, the helper’s own fetch call hits the handler and validates whatever shape it returns.
// contract/msw-users.test.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { beforeAll, afterAll, afterEach, describe, it, expect } from 'vitest';
import { assertResponseMatchesSpec } from './assert-response';
const server = setupServer(
http.get('http://localhost/api/v1/users/:id', ({ params }) => {
return HttpResponse.json({
id: Number(params.id), // integer, per the spec
username: 'ada.lovelace',
email: '[email protected]',
role: 'editor',
});
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('users MSW stub conforms to OpenAPI', () => {
it('GET /api/v1/users/{id} matches the 200 schema', async () => {
const { status } = await assertResponseMatchesSpec(
'get',
'/api/v1/users/{id}',
'http://localhost/api/v1/users/42',
);
expect(status).toBe(200);
});
});
Flip id to a string in the handler and the test fails with /id must be integer — the drift is caught at the stub, exactly where it was introduced. Building the conforming shape is easiest when handlers are driven by schema-driven data generation rather than hand-typed, and the assertion pairs naturally with writing custom MSW response resolvers that compute responses dynamically.
4. Assert WireMock stub output
The same helper validates a language-agnostic WireMock stub — just point it at the WireMock port instead of the MSW handler:
// contract/wiremock-users.test.ts
import { describe, it, expect } from 'vitest';
import { assertResponseMatchesSpec } from './assert-response';
const WIREMOCK = process.env.WIREMOCK_URL ?? 'http://localhost:8080';
describe('WireMock stub conforms to OpenAPI', () => {
it('GET /api/v1/users/{id} matches the 200 schema', async () => {
const { body } = await assertResponseMatchesSpec(
'get',
'/api/v1/users/{id}',
`${WIREMOCK}/api/v1/users/42`,
);
expect(body).toHaveProperty('id');
});
});
5. Alternative — express-openapi-validator for a blanket check
If your mock is an Express app, express-openapi-validator validates every response automatically against the spec, failing any route that deviates without a per-endpoint assertion:
// mock-server/app.ts
import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
export function buildMockApp(): express.Express {
const app = express();
app.use(express.json());
app.use(
OpenApiValidator.middleware({
apiSpec: process.env.OPENAPI_SPEC ?? './specs/api.yaml',
validateRequests: true,
validateResponses: true, // reject any response the routes emit that breaks the spec
}),
);
app.get('/api/v1/users/:id', (req, res) => {
res.json({
id: Number(req.params.id),
username: 'ada.lovelace',
email: '[email protected]',
role: 'editor',
});
});
// Any thrown validation error surfaces as a 500 with the offending path.
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err.status ?? 500).json({ message: err.message, errors: err.errors });
});
return app;
}
With validateResponses: true, a handler that returns a non-conforming shape produces a 500 whose body names the field that broke the contract — a blanket guarantee across every route, at the cost of only working for Express-based mocks.
Verification
Run the mock validation tests and confirm a deliberately broken stub fails:
# Passing run
npx vitest run contract/msw-users.test.ts
# Expected: 1 passed
# Break the shape and confirm the assertion catches it
sed -i 's/Number(params.id)/String(params.id)/' contract/msw-users.test.ts
npx vitest run contract/msw-users.test.ts
echo "exit code: $?"
# Expected: failure with "/id must be integer" and a non-zero exit code
Gotchas and edge cases
-
Match the spec’s templated path, not the request URL. The helper looks up
/api/v1/users/{id}, but you fetch/api/v1/users/42. Pass the templated path (with{id}) asspecPathand the concrete URL separately, as the helper signature does. Passing the concrete URL as the lookup key finds no schema and throws a misleading “undocumented operation” error. -
nullableandoneOfneed care. OpenAPI 3.0’snullable: trueis not a JSON Schema keyword, sostrict: falseis mandatory — the same mismatch that appears when detecting OpenAPI contract drift in CI. ForoneOf/anyOfresponse schemas, setallErrors: true(as the helper does) so ajv reports which branch failed rather than a single opaque “does not match”. -
Validate the status the mock actually returned. A handler that returns 404 must be validated against the 404 schema, not the 200 one. The helper selects the schema by
res.status, so an error response with the wrong shape is caught too — but only if your spec documents that error status. Undocumented statuses throw the “no schema” error by design, which is itself useful drift signal. Structuring those error and pagination payloads correctly is covered in response shaping techniques.
FAQ
Should I validate the response body only, or the status and headers too?
Start with the body, since that is where most drift hides, but validate the status code and Content-Type too. OpenAPI defines a schema per status code, so the helper must select the schema for the status the mock actually returned; a 200 body validated against the 404 schema is a false pass. Header validation matters mainly for pagination and rate-limit headers your client reads.
ajv or express-openapi-validator?
Use ajv directly when you want a lightweight assertion inside existing unit tests with no server in the loop. Use express-openapi-validator when your mock is an Express app and you want responses validated automatically on every route, failing the request if it deviates. ajv gives you a scalpel; express-openapi-validator gives you a blanket.
Related
- Detecting OpenAPI Contract Drift in CI — catch spec-level breaking changes that this fixture check cannot see
- Consumer-Driven Contract Testing with Pact — prove the real provider matches what your validated mocks promise
- Writing Custom MSW Response Resolvers — build the dynamic handlers whose output you validate here
- Contract Testing & Drift Detection — parent overview of the spec ⇄ mocks ⇄ provider loop
← Back to Contract Testing & Drift Detection