API Testing in Practice: Contract Tests with JSON Schema + Postman/Newman in CI
API bugs rarely come from “the endpoint is down.” More often it’s a small breaking change: a field renamed, a number becomes a string, a new required property sneaks in, or an error payload changes shape. If your frontend (or another service) relies on a stable response format, you need contract tests: automated checks that verify your API responses match an agreed structure.
In this hands-on guide, you’ll build contract tests using JSON Schema, run them locally with Node, and wire them into a CI run using Postman + Newman. This works great for junior/mid devs because it’s simple, fast, and catches breaking changes early.
What you’ll build
- A minimal API to test (example uses
httpbinso you can run this immediately) - JSON Schemas that describe the expected response shapes
- A Node script that:
- calls endpoints
- validates status codes
- validates the response body with JSON Schema
- A Postman collection with schema checks, runnable via Newman in CI
You can pick either approach (Node-only or Postman/Newman). Many teams use both: Node tests for dev ergonomics, Newman for CI standardization.
Step 1: Define a contract (JSON Schema)
JSON Schema is a standard way to describe JSON shapes: required fields, types, nested objects, and constraints. Create a folder called schemas and add schemas/user.schema.json:
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/schemas/user.schema.json", "type": "object", "required": ["id", "email", "role", "createdAt"], "properties": { "id": { "type": "string", "minLength": 6 }, "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["admin", "member"] }, "createdAt": { "type": "string", "format": "date-time" }, "profile": { "type": "object", "required": ["displayName"], "properties": { "displayName": { "type": "string", "minLength": 1 }, "avatarUrl": { "type": ["string", "null"], "format": "uri" } }, "additionalProperties": false } }, "additionalProperties": false }
Two practical tips:
"additionalProperties": falseis strict. It forces you to intentionally add fields (great for contracts, but can be annoying if your API adds “harmless” fields). Decide as a team.- Prefer
enumfor values like roles/statuses so you catch unexpected states early.
Step 2: A tiny “API” you can test immediately
If you already have an API, point the tests to your base URL and skip this section. To keep this article runnable for anyone, we’ll use https://httpbin.org which echoes JSON back.
We’ll send a POST to /anything and validate that the response includes a json object matching our “user” contract. That’s enough to demonstrate the technique.
Step 3: Contract tests with Node + Ajv (fast, friendly)
Create a project:
# initialize npm init -y # dependencies npm i ajv ajv-formats node-fetch
Now create contract-test.js:
import fs from "node:fs"; import path from "node:path"; import fetch from "node-fetch"; import Ajv from "ajv"; import addFormats from "ajv-formats"; const BASE_URL = process.env.BASE_URL || "https://httpbin.org"; function loadSchema(fileName) { const p = path.join(process.cwd(), "schemas", fileName); return JSON.parse(fs.readFileSync(p, "utf-8")); } function assert(condition, message) { if (!condition) { throw new Error(message); } } async function requestJson(method, url, body) { const res = await fetch(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }); const text = await res.text(); let json; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { res, json, raw: text }; } async function run() { const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); const userSchema = loadSchema("user.schema.json"); const validateUser = ajv.compile(userSchema); // Example payload we want our API to return (or include) const expectedUser = { id: "usr_123456", email: "[email protected]", role: "member", createdAt: new Date().toISOString(), profile: { displayName: "Dev User", avatarUrl: null } }; // httpbin returns an object that includes "json": <your posted json> const { res, json } = await requestJson( "POST", `${BASE_URL}/anything`, expectedUser ); assert(res.status === 200, `Expected 200, got ${res.status}`); // Validate the nested user object against our contract const ok = validateUser(json?.json); if (!ok) { const details = validateUser.errors?.map(e => `${e.instancePath} ${e.message}`).join("\n"); throw new Error(`Contract failed for user:\n${details}`); } console.log("✅ Contract test passed"); } run().catch((err) => { console.error("❌ Contract test failed"); console.error(err.message); process.exit(1); });
Run it:
node contract-test.js
You should see:
✅ Contract test passed
Step 4: Make it realistic (multiple endpoints, error contracts)
Real APIs have different contracts for success and error responses. Add an error schema like schemas/error.schema.json:
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["error", "message", "requestId"], "properties": { "error": { "type": "string" }, "message": { "type": "string" }, "requestId": { "type": "string", "minLength": 8 }, "details": { "type": ["array", "null"], "items": { "type": "string" } } }, "additionalProperties": false }
Then you can add another test block to validate error responses. In your real API, you might validate that:
401always returns the same error shape422includes validation messages in a predictable listrequestIdis always present to help trace logs
This is where contract tests shine: your API can evolve, but it shouldn’t “randomly” change how it talks to clients.
Step 5: Do the same in Postman (then run it with Newman)
Postman is common in teams and easy for non-Node folks. Newman runs Postman collections from the CLI and works nicely in CI.
Install Newman:
npm i -g newman
Create a Postman collection with a request:
POST {{baseUrl}}/anything- Body (raw JSON): a sample user payload (same as earlier)
In the request’s Tests tab, paste:
// Minimal JSON Schema validator using Ajv bundled via a CDN is not available in Newman by default. // Instead, keep schema checks simple or use a lightweight validator pattern. // For full Ajv support in Newman, prefer running Node tests (previous section) or use a Newman runtime plugin. // // Here we do practical checks (types/required fields). It's still very effective. pm.test("Status is 200", function () { pm.response.to.have.status(200); }); const body = pm.response.json(); pm.test("Echoed JSON exists", function () { pm.expect(body).to.have.property("json"); }); pm.test("User contract: required fields + types", function () { const u = body.json; pm.expect(u).to.have.property("id").that.is.a("string"); pm.expect(u.id.length).to.be.greaterThan(5); pm.expect(u).to.have.property("email").that.is.a("string"); pm.expect(u.email).to.match(/.+@.+\..+/); pm.expect(u).to.have.property("role"); pm.expect(["admin", "member"]).to.include(u.role); pm.expect(u).to.have.property("createdAt").that.is.a("string"); pm.expect(u).to.have.nested.property("profile.displayName").that.is.a("string"); });
Export the collection as collection.json and create an environment file env.json with:
{ "id": "local-env", "name": "local-env", "values": [ { "key": "baseUrl", "value": "https://httpbin.org", "enabled": true } ] }
Run it:
newman run collection.json -e env.json
Why not Ajv directly in Postman/Newman? Postman scripts don’t ship with Ajv out of the box. You can still do strong contract checks by validating required fields/types and key constraints, or you can keep JSON Schema validation in your Node test suite (recommended for strict schema enforcement).
Step 6: Add to CI (example: GitHub Actions)
Create .github/workflows/api-contract.yml:
name: API Contract Tests on: pull_request: push: branches: [ "main" ] jobs: contract: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node uses: actions/setup-node@v4 with: node-version: "20" - name: Install deps run: npm ci || npm i - name: Run contract tests env: BASE_URL: https://httpbin.org run: node contract-test.js # Optional: run Newman if you also maintain Postman collections - name: Run Postman collection run: | npm i -g newman newman run collection.json -e env.json
Now every pull request can fail fast if someone breaks the API contract.
Common pitfalls (and how to avoid them)
-
Testing against unstable data: If IDs/timestamps change each run, don’t assert exact values. Assert shape, type, and basic constraints (min length, format, enum, etc.).
-
Too strict too early: If your API is young, start with a “core contract” (must-have fields) before locking down
additionalProperties. -
Ignoring error contracts: Most outages are felt through error handling. Make sure your
4xx/5xxpayloads are consistent and tested. -
Only testing happy paths: Add a couple of negative tests: missing auth, invalid payload, not found. You’ll catch breaking changes faster than with happy-path-only tests.
A practical workflow you can adopt this week
- Define schemas per endpoint (or per resource) in a
/schemasfolder - Write Node contract tests with Ajv for strict validation
- Keep Postman collections for quick manual exploration and smoke validation
- Run both in CI so breaking changes never reach main unnoticed
Contract tests are one of those “small effort, big payoff” practices. Once your team gets used to them, you’ll wonder how you ever shipped APIs without a safety net.
Leave a Reply