API Testing with Contract-First Checks: Build a Fast “Spec Linter” + Runtime Validator (Node.js + OpenAPI)

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 /health must return { "status": "ok" }
  • GET /users/{id} returns either a valid User or an Error

Step 1: Lint the OpenAPI Spec (Static Contract Tests)

Static checks catch issues before you even run the app:

  • Broken $ref pointers
  • 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 integer to string
  • A response body missing required keys
  • Returning text/html by 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-type contains application/json so you catch it early.

  • Nullable vs optional confusion. In OpenAPI, “optional” means the field is not in required. “Nullable” means it can be null. 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 ci
  • npm run lint:spec
  • Start API (for example docker compose up -d or npm run dev in 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

Your email address will not be published. Required fields are marked *