API Testing with Contract-First Checks: Build a Fast “Spec Linter” + Runtime Validator (Node.js + OpenAPI)
API tests usually start as “does endpoint X return 200?” and grow into a pile of brittle cases. A practical upgrade is contract-first testing: treat your OpenAPI spec as the source of truth and automatically verify two things:
- The spec itself is valid and consistent (no broken refs, missing schemas, accidental loosening).
- Your running API responses match the spec (status codes, content-types, and response bodies).
In this hands-on guide, you’ll build a tiny toolchain that junior/mid developers can drop into any project:
- A spec linter (static checks) using
@redocly/cli - A runtime contract test that hits real endpoints and validates responses using
openapi-enforcer - A CI-friendly workflow that fails fast with useful output
We’ll use Node.js because it’s common in web stacks and plays well with OpenAPI tooling, but the approach is language-agnostic.
Project Setup
Create a small workspace:
mkdir api-contract-tests cd api-contract-tests npm init -y npm i -D @redocly/cli vitest undici openapi-enforcer
Add scripts to package.json:
{ "scripts": { "lint:spec": "redocly lint openapi.yaml", "test:contract": "vitest run", "test": "npm run lint:spec && npm run test:contract" } }
You’ll also need an OpenAPI file. For demo purposes, here’s a minimal openapi.yaml you can paste into the project root:
openapi: 3.0.3 info: title: Demo API version: 1.0.0 servers: - url: http://localhost:3000 paths: /health: get: summary: Health check responses: "200": description: OK content: application/json: schema: type: object required: [status] properties: status: type: string enum: [ok] /users/{id}: get: summary: Fetch a user parameters: - in: path name: id required: true schema: type: integer responses: "200": description: User found content: application/json: schema: $ref: "#/components/schemas/User" "404": description: Not found content: application/json: schema: $ref: "#/components/schemas/Error" components: schemas: User: type: object required: [id, email] properties: id: type: integer email: type: string format: email name: type: string nullable: true Error: type: object required: [error] properties: error: type: string
This spec says:
GET /healthmust return{ "status": "ok" }GET /users/{id}returns either a validUseror anError
Step 1: Lint the OpenAPI Spec (Static Contract Tests)
Static checks catch issues before you even run the app:
- Broken
$refpointers - Duplicate operationIds (if you use them)
- Missing response bodies or content-types
- Inconsistent schema definitions
Run:
npm run lint:spec
If you want stricter rules, create redocly.yaml:
extends: - recommended rules: operation-operationId: off no-ambiguous-paths: error no-unused-components: warn security-defined: off
This is already valuable in CI because it prevents shipping a spec that tools (SDK generation, docs, gateways) can’t consume.
Step 2: Runtime Contract Tests (Validate Real Responses Against the Spec)
Now the fun part: hit your real API and verify responses match the schema. This catches subtle regressions like:
- A field changed from
integertostring - A response body missing
requiredkeys - Returning
text/htmlby accident (framework error pages) - Returning a different status code than promised
Create tests/contract.test.js:
import { describe, it, expect } from "vitest"; import { request } from "undici"; import fs from "node:fs"; import path from "node:path"; import Enforcer from "openapi-enforcer"; const SPEC_PATH = path.join(process.cwd(), "openapi.yaml"); // Load and parse the OpenAPI spec once for the test suite. async function loadSpec() { const raw = fs.readFileSync(SPEC_PATH, "utf-8"); // Enforcer can accept YAML as a string. const [openapi, error] = await Enforcer(raw); if (error) throw error; return openapi; } // Small helper to call the API. async function httpGet(url) { const res = await request(url, { method: "GET" }); const contentType = res.headers["content-type"] || ""; const bodyText = await res.body.text(); let json = null; if (contentType.includes("application/json")) { try { json = JSON.parse(bodyText); } catch (e) { // Keep json null; the validator will fail anyway. } } return { status: res.statusCode, headers: res.headers, contentType, bodyText, json, }; } describe("OpenAPI contract tests", async () => { const openapi = await loadSpec(); const baseUrl = openapi.servers?.[0]?.url || "http://localhost:3000"; it("GET /health matches the spec", async () => { const url = `${baseUrl}/health`; const result = await httpGet(url); expect(result.status).toBe(200); expect(result.contentType).toContain("application/json"); // Validate response against OpenAPI for this operation + status. const [operation] = openapi.path("/health").get(); const validation = operation.response(result.status).validate(result.json); expect(validation.error).toBeFalsy(); }); it("GET /users/{id} returns a valid 200 User or 404 Error", async () => { // Adjust IDs based on your API’s data. const candidateIds = [1, 999999]; for (const id of candidateIds) { const url = `${baseUrl}/users/${id}`; const result = await httpGet(url); expect([200, 404]).toContain(result.status); expect(result.contentType).toContain("application/json"); const [operation] = openapi.path("/users/{id}").get(); const validation = operation.response(result.status).validate(result.json); expect(validation.error).toBeFalsy(); } }); });
How it works: we load the OpenAPI spec, pick the operation definition (like /health GET), then validate the JSON body for the response’s status code.
Run the Contract Tests
Start your API server locally (whatever command your app uses), then run:
npm run test:contract
If your API returns something unexpected (e.g., { "status": "OK" } instead of ok), the test fails with a schema validation error. That’s exactly what you want.
Make Failures Actionable: Print Validation Errors
Junior developers often struggle when tests fail with unclear messages. Add a helper that prints error details.
Update the validation section like this:
function assertValid(validation) { if (validation?.error) { // openapi-enforcer provides structured error info console.error("Contract validation error:", validation.error); } expect(validation.error).toBeFalsy(); }
Then replace:
expect(validation.error).toBeFalsy();
With:
assertValid(validation);
This gives you “what field failed and why” directly in CI logs.
Cover More Endpoints Without Writing 50 Tests
A practical scaling trick is to define a list of “contract probes” and loop them. Create tests/probes.js:
export const probes = [ { method: "get", path: "/health", expectedStatuses: [200] }, { method: "get", path: "/users/1", expectedStatuses: [200, 404] }, { method: "get", path: "/users/999999", expectedStatuses: [200, 404] } ];
Then in your test file, iterate probes and validate each response based on the spec path template:
import { probes } from "./probes.js"; // Map a concrete URL like /users/123 to an OpenAPI template /users/{id} function toTemplatePath(p) { if (p.startsWith("/users/")) return "/users/{id}"; return p; } it("probe endpoints match the OpenAPI contract", async () => { for (const probe of probes) { const url = `${baseUrl}${probe.path}`; const result = await httpGet(url); expect(probe.expectedStatuses).toContain(result.status); expect(result.contentType).toContain("application/json"); const specPath = toTemplatePath(probe.path); const [operation] = openapi.path(specPath)[probe.method](); const validation = operation.response(result.status).validate(result.json); assertValid(validation); } });
This pattern keeps tests short while still validating real responses.
Common Gotchas (and How to Avoid Them)
-
Your API returns HTML on errors. Many frameworks send an HTML error page for 500s. Your contract test should explicitly check
content-typecontainsapplication/jsonso you catch it early. -
Nullable vs optional confusion. In OpenAPI, “optional” means the field is not in
required. “Nullable” means it can benull. Decide which you mean and encode it in the schema. -
Dates and formats. If you use
format: date-time, ensure you return ISO 8601 strings consistently. Contract tests will keep you honest. -
Spec drift. The contract test suite should run on every PR. If a dev changes an endpoint response, they must update the spec or fix the code.
Drop It Into CI
This approach is CI-agnostic. The key is: (1) run spec linting, (2) start the API, (3) run contract tests.
Example pseudo-steps:
npm cinpm run lint:spec- Start API (for example
docker compose up -dornpm run devin the background) npm run test:contract
If your API needs a database, run migrations and seed minimal data so IDs like /users/1 behave predictably.
Where to Go Next
Once this is working, you can level up in small steps:
- Request validation: validate request bodies too (ensure you send what the spec expects).
- Auth flows: generate tokens in test setup and probe protected endpoints.
- Coverage tracking: compare spec operations to your probe list and warn if endpoints aren’t tested.
- Consumer-driven contracts: if you have multiple clients, add contract tests per consumer scenario.
Contract-first testing is one of the highest ROI testing habits for web APIs: it keeps documentation real, reduces breaking changes, and gives developers fast feedback with concrete error messages. Start with a few probes, enforce them in CI, and your API quality will steadily improve.
Leave a Reply