Validating API Responses Against OpenAPI in CI
This guide is part of Mock Servers & Contract Testing from OpenAPI within OpenAPI & AsyncAPI Schema Authoring. It covers the cheapest contract check available: validating the responses your existing test suite already produces, rather than generating new requests. If you have integration tests, you are already exercising the API — this just starts checking the answers against the document.
Problem & Context
Most teams already have integration tests that call their API and assert on the results. Those assertions are hand-written and partial: they check the two or three fields the test cares about and ignore everything else in the response. A field that changed type, a status code that changed meaning, a header that disappeared — none of it is noticed, because no assertion mentions it.
Meanwhile the specification sits alongside, describing what all of those responses should look like, and nothing compares the two.
Response validation closes that gap for free. The requests already exist; the oracle already exists; the only missing piece is something that puts them together. Compared with generated contract testing, this covers less of the documented surface — only what the suite happens to exercise — but it covers exactly the paths your product actually depends on, and it costs one configuration change rather than a new test strategy.
Step-by-Step Solution
1. Route the existing test suite through a validating proxy
Prism’s proxy mode forwards traffic to the real API and validates every response on the way back:
npx @redocly/cli@2 bundle openapi/api.yaml -o dist/api.bundled.yaml
npx prism proxy dist/api.bundled.yaml http://127.0.0.1:3000 --port 4020
Point the suite at the proxy instead of the API:
API_BASE_URL=http://127.0.0.1:4020 npm test
No test changes. Every response the suite already produces is now checked.
2. Fail on violations rather than logging them
By default the proxy warns and passes the response through. --errors turns a violation into a 500 with a violation report, which makes the test fail:
npx prism proxy dist/api.bundled.yaml http://127.0.0.1:3000 --port 4020 --errors
A violation then surfaces in the test output like this:
GET /invoices → 500
{
"type": "https://stoplight.io/prism/errors#VIOLATIONS",
"title": "Request/Response not valid",
"validation": [
{ "location": ["response", "body", "next_cursor"],
"severity": "Error",
"message": "should be string" }
]
}
3. Add an in-process assertion for unit tests
Where tests call the handler directly rather than over HTTP, validate inside the process:
// test/helpers/contract.js
const { OpenAPIValidator } = require('express-openapi-validator');
const validator = new OpenAPIValidator({ apiSpec: 'dist/api.bundled.yaml' });
/** Assert a response conforms to the contract for its operation. */
async function expectConformsToContract(res, { method, path }) {
const errors = await validator.validateResponse(res, { method, path });
if (errors?.length) {
throw new Error(
`Response violates the contract for ${method} ${path}:\n` +
errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'),
);
}
}
module.exports = { expectConformsToContract };
// test/invoices.test.js
const { expectConformsToContract } = require('./helpers/contract');
test('GET /invoices returns a conforming page', async () => {
const res = await request(app).get('/invoices?limit=1');
expect(res.status).toBe(200); // the assertion you already had
await expectConformsToContract(res, { method: 'get', path: '/invoices' });
});
4. Measure documented-operation coverage
Validation only checks what the suite exercises, so measure what it does not. An operation the suite never calls is an operation nothing validates:
// test/helpers/coverage.js — record every operation the suite touched
const touched = new Set();
afterEach(() => { /* record from the request log */ });
after(() => {
const spec = require('../dist/api.bundled.yaml');
const documented = Object.entries(spec.paths).flatMap(([p, item]) =>
Object.keys(item)
.filter((m) => ['get','put','post','delete','patch'].includes(m))
.map((m) => `${m.toUpperCase()} ${p}`));
const untested = documented.filter((op) => !touched.has(op));
const pct = Math.round(((documented.length - untested.length) / documented.length) * 100);
console.log(`contract coverage: ${pct}% (${untested.length} untested)`);
untested.forEach((op) => console.log(` UNTESTED ${op}`));
if (pct < 80) {
console.error(`Contract coverage ${pct}% is below the 80% floor`);
process.exit(1);
}
});
contract coverage: 83% (4 untested)
UNTESTED DELETE /invoices/{id}
UNTESTED GET /invoices/{id}/pdf
UNTESTED POST /invoices/{id}/void
UNTESTED GET /health
5. Sample production traffic on a schedule
The shapes that only occur with real data never appear in a test fixture. Validate a sample of production responses so that drift shows up before a consumer reports it — sampling a small percentage is enough, and it costs nothing when nothing is wrong.
Complete Working Example
A CI job that starts the API, puts a validating proxy in front of it, runs the existing suite through the proxy, and enforces a coverage floor:
# .github/workflows/response-validation.yml
name: Response validation
on:
pull_request:
paths: ['src/**', 'openapi/**', 'test/**']
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Bundle the contract
run: npx @redocly/cli@2 bundle openapi/api.yaml -o dist/api.bundled.yaml
- name: Start the API under test
run: |
npm run start:test &
npx wait-on http://127.0.0.1:3000/health --timeout 60000
# --errors turns every contract violation into a failing request,
# which turns it into a failing test with no test changes at all.
- name: Start the validating proxy
run: |
npx prism proxy dist/api.bundled.yaml http://127.0.0.1:3000 \
--port 4020 --errors &
npx wait-on http-get://127.0.0.1:4020/health --timeout 30000
- name: Run the existing suite through the proxy
run: npm test
env:
API_BASE_URL: http://127.0.0.1:4020
CONTRACT_COVERAGE_FLOOR: '80'
- name: Publish the coverage summary
if: always()
run: cat contract-coverage.txt >> "$GITHUB_STEP_SUMMARY"
Expected summary on a healthy run:
contract coverage: 91% (2 untested)
UNTESTED DELETE /invoices/{id}
UNTESTED POST /invoices/{id}/void
0 contract violations across 148 validated responses
The two numbers answer different questions and you need both. Zero violations says everything the suite exercised conforms. Ninety-one percent coverage says how much of the documented surface that actually is — and the two untested operations are the ones where a contract violation could ship without anything noticing.
Gotchas & Edge Cases
Health and metrics endpoints are usually undocumented. They exist, the suite calls them, and the proxy reports every response as violating a contract that never mentions them. Either document them or exclude those paths from validation deliberately; do not lower the severity globally to make the noise go away.
The proxy changes the base URL the suite sees. Tests that assert on absolute URLs in response bodies — a Location header, a pagination next link — will see the origin they were generated with, not the proxy’s. Assert on paths rather than absolute URLs, which is more robust regardless.
A response can conform and still be wrong. Schema conformance says nothing about whether the numbers are right. Keep behavioural assertions; this replaces none of them, it only adds a check nobody was doing.
format keywords are validated inconsistently. format: date-time is enforced by most validators, format: email by some, and custom formats by none. A response that passes locally may fail under a different validator version, so pin the validator alongside the specification.
Coverage can be gamed into meaninglessness. A test that calls an operation and asserts nothing still counts as coverage. Treat the percentage as a prompt to look at the untested list, not as a target to raise.
Start with the proxy, add the rest only if it earns its place. Of everything in this guide, the proxy is the one piece worth adopting on day one: it requires no test changes, no new dependency in the application, and no decision about where assertions belong. Run the existing suite through it, look at what it reports, and you will learn more about the state of your contract in an afternoon than a week of reading the document would tell you. The in-process helper and the coverage gate are refinements — useful once the proxy is green and staying green, unnecessary noise before that.
Expect the first result to be uncomfortable and act on it in the right order. A suite that has never been validated will typically report violations in a handful of places and undocumented responses in many more. Fix the undocumented responses first: they are usually one line in the specification each, they clear most of the output, and they make the genuine schema violations visible instead of buried. Only then work through the remaining violations, deciding case by case whether the document or the implementation is the thing that is wrong. Doing it in the other order means fighting through noise to find the two findings that mattered.
FAQ
How is this different from contract testing with generated inputs?
Generated testing invents requests to probe the whole documented surface. Response validation reuses the requests your suite already makes, so it costs almost nothing to add and validates exactly the paths your product depends on. They complement each other: one is wide and shallow, the other narrow and deep.
Do I need a proxy, or can I validate in process?
Either works. A proxy needs no code change and catches everything crossing the wire, including responses from middleware your tests never call directly. An in-process assertion is easier where tests invoke the handler rather than HTTP. Most teams end up with both.
What should happen when a response is valid but undocumented?
Fail. An undocumented status code is drift regardless of whether the body is well-formed, and it is the single most common finding when teams first turn this on. Decide whether the specification or the implementation is wrong, and fix that one.
Related
- Mock Servers & Contract Testing from OpenAPI — the parent guide and how these approaches fit together.
- Contract testing OpenAPI with Schemathesis — generated coverage of the operations this misses.
- Generating mock servers from OpenAPI with Prism — the same tool in mock rather than proxy mode.
- Spec Linting & Governance — keeping the contract this validates against accurate.