OpenAPI Contract Testing with Supertest

OpenAPI Contract Testing with Supertest

An OpenAPI spec is a contract. When your API response drifts from the spec — a field renamed, a type changed, a required property missing — clients break silently. Supertest combined with schema validation lets you assert that every API response matches the contract, automatically, on every test run.

Key Takeaways

Validate response bodies against your OpenAPI spec in every test. Tools like openapi-response-validator and ajv can check the actual response against the schema definition automatically — no manual assertions for every field.

Generate tests from your OpenAPI spec. Tools like openapi-backend can auto-generate test cases for all defined operations, catching routes you forgot to test.

Test the schema, then test the business logic. Schema tests verify the contract; business logic tests verify the behavior. Both are necessary; neither replaces the other.

Fail loudly on schema drift. A CI step that validates your implementation against your spec catches breaking changes before they ship — not after.

Keep the spec as source of truth. Generate TypeScript types, validation middleware, and mock servers from the spec — don't maintain them separately.

What Contract Testing Means for APIs

Consumer-driven contract testing (Pact, etc.) is about verifying that a consumer's expectations match a provider's implementation. For HTTP APIs with an OpenAPI spec, you have a different but equally important problem: ensuring your implementation matches your own published spec.

When a field is renamed in code but not in the spec, or a new required field is added to responses without a spec update, consumers who rely on the spec break — even if you didn't change the spec intentionally.

Supertest + OpenAPI validation closes this gap by checking every response against the spec schema automatically.

Tools

We'll use:

  • supertest — HTTP test client
  • openapi-response-validator or ajv — response schema validation
  • swagger-parser or @apidevtools/swagger-parser — load and dereference the spec
  • openapi-backend — request/response validation against the spec
npm install --save-dev supertest jest \
  @apidevtools/swagger-parser \
  ajv ajv-formats \
  openapi-backend

Your OpenAPI Spec

# api/openapi.yaml
openapi: 3.0.3
info:
  title: Users API
  version: 1.0.0

paths:
  /users:
    get:
      operationId: listUsers
      summary: List all users
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
    post:
      operationId: createUser
      summary: Create a user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserInput'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Email already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

components:
  schemas:
    User:
      type: object
      required: [id, email, name, createdAt]
      properties:
        id:
          type: integer
        email:
          type: string
          format: email
        name:
          type: string
        role:
          type: string
          enum: [user, admin]
          default: user
        createdAt:
          type: string
          format: date-time

    CreateUserInput:
      type: object
      required: [email, name]
      properties:
        email:
          type: string
          format: email
        name:
          type: string
          minLength: 1
          maxLength: 100

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        code:
          type: string

Building a Schema Validator Helper

// tests/helpers/openapi-validator.js
const SwaggerParser = require('@apidevtools/swagger-parser');
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const path = require('path');

let api;
let ajv;

async function loadSpec() {
  if (api) return;
  api = await SwaggerParser.dereference(
    path.resolve(__dirname, '../../api/openapi.yaml')
  );
  ajv = new Ajv({ allErrors: true });
  addFormats(ajv);
}

function getResponseSchema(operationId, statusCode) {
  for (const [, pathItem] of Object.entries(api.paths)) {
    for (const [, operation] of Object.entries(pathItem)) {
      if (operation.operationId === operationId) {
        const response = operation.responses[statusCode];
        if (!response?.content?.['application/json']?.schema) return null;
        return response.content['application/json'].schema;
      }
    }
  }
  throw new Error(`Operation '${operationId}' with status ${statusCode} not found in spec`);
}

function validateResponse(operationId, statusCode, body) {
  const schema = getResponseSchema(operationId, statusCode);
  if (!schema) return { valid: true, errors: [] };

  const validate = ajv.compile(schema);
  const valid = validate(body);
  return {
    valid,
    errors: validate.errors || [],
  };
}

function expectValidResponse(operationId, statusCode, body) {
  const result = validateResponse(operationId, statusCode, body);
  if (!result.valid) {
    throw new Error(
      `Response does not match OpenAPI spec for ${operationId} (${statusCode}):\n` +
      result.errors.map(e => `  ${e.instancePath} ${e.message}`).join('\n')
    );
  }
}

module.exports = { loadSpec, validateResponse, expectValidResponse };

Writing Contract Tests

// tests/contract/users.test.js
const request = require('supertest');
const { app, db, users: userStore } = require('../../src/app');
const { loadSpec, expectValidResponse } = require('../helpers/openapi-validator');

describe('Users API — contract tests', () => {
  beforeAll(async () => {
    await loadSpec();
  });

  afterEach(() => {
    userStore.clear();
  });

  describe('GET /users', () => {
    it('returns 200 matching User[] schema when empty', async () => {
      const res = await request(app).get('/users');

      expect(res.status).toBe(200);
      expect(res.headers['content-type']).toMatch(/application\/json/);
      expectValidResponse('listUsers', '200', res.body);
    });

    it('returns 200 matching User[] schema with data', async () => {
      // Seed some data
      await request(app).post('/users').send({ email: 'alice@example.com', name: 'Alice' });
      await request(app).post('/users').send({ email: 'bob@example.com', name: 'Bob' });

      const res = await request(app).get('/users');

      expect(res.status).toBe(200);
      expectValidResponse('listUsers', '200', res.body);

      // Business logic: check we get the right count
      expect(res.body).toHaveLength(2);
    });

    it('returns array even when there are no users', async () => {
      const res = await request(app).get('/users');
      expect(Array.isArray(res.body)).toBe(true);
    });
  });

  describe('POST /users', () => {
    it('returns 201 matching User schema on success', async () => {
      const res = await request(app)
        .post('/users')
        .send({ email: 'charlie@example.com', name: 'Charlie' });

      expect(res.status).toBe(201);
      expectValidResponse('createUser', '201', res.body);
    });

    it('response includes all required User fields', async () => {
      const res = await request(app)
        .post('/users')
        .send({ email: 'diana@example.com', name: 'Diana' });

      // Schema validation catches missing required fields
      expectValidResponse('createUser', '201', res.body);

      // Explicit checks for business-logic values
      expect(res.body.email).toBe('diana@example.com');
      expect(res.body.name).toBe('Diana');
    });

    it('returns 400 matching Error schema for invalid input', async () => {
      const res = await request(app)
        .post('/users')
        .send({ email: 'not-an-email', name: 'Bad' });

      expect(res.status).toBe(400);
      expectValidResponse('createUser', '400', res.body);
    });

    it('returns 409 matching Error schema for duplicate email', async () => {
      await request(app).post('/users').send({ email: 'dup@example.com', name: 'Dup' });

      const res = await request(app)
        .post('/users')
        .send({ email: 'dup@example.com', name: 'Dup 2' });

      expect(res.status).toBe(409);
      expectValidResponse('createUser', '409', res.body);
    });
  });

  describe('GET /users/:id', () => {
    it('returns 200 matching User schema for existing user', async () => {
      const createRes = await request(app)
        .post('/users')
        .send({ email: 'eve@example.com', name: 'Eve' });
      const userId = createRes.body.id;

      const res = await request(app).get(`/users/${userId}`);

      expect(res.status).toBe(200);
      expectValidResponse('getUser', '200', res.body);
    });

    it('returns 404 matching Error schema for missing user', async () => {
      const res = await request(app).get('/users/99999');

      expect(res.status).toBe(404);
      expectValidResponse('getUser', '404', res.body);
    });
  });
});

Detecting Schema Drift in CI

Create a separate test that validates the spec itself is consistent with what your implementation returns:

// tests/contract/schema-drift.test.js
const request = require('supertest');
const SwaggerParser = require('@apidevtools/swagger-parser');
const path = require('path');
const { app } = require('../../src/app');

describe('Schema drift detection', () => {
  it('spec file is valid OpenAPI 3.0', async () => {
    await expect(
      SwaggerParser.validate(path.resolve(__dirname, '../../api/openapi.yaml'))
    ).resolves.toBeDefined();
  });

  it('all spec-defined paths respond with documented status codes', async () => {
    const api = await SwaggerParser.parse(
      path.resolve(__dirname, '../../api/openapi.yaml')
    );

    const testCases = [
      { path: '/users', method: 'get', expectedStatuses: [200] },
      { path: '/users', method: 'post', payload: { email: 'test@example.com', name: 'Test' }, expectedStatuses: [201] },
      { path: '/users/99999', method: 'get', expectedStatuses: [404] },
    ];

    for (const tc of testCases) {
      let req = request(app)[tc.method](tc.path);
      if (tc.payload) req = req.send(tc.payload);

      const res = await req;

      expect(tc.expectedStatuses).toContain(res.status);

      // Verify the status is documented in the spec
      const specPath = tc.path.replace(/\/\d+/, '/{id}');
      const operation = api.paths[specPath]?.[tc.method];
      expect(operation?.responses?.[res.status]).toBeDefined();
    }
  });
});

Auto-Generating Tests from the Spec

Instead of writing tests manually, you can generate them from the spec using openapi-backend:

// tests/contract/auto-generated.test.js
const OpenAPIBackend = require('openapi-backend').default;
const request = require('supertest');
const path = require('path');
const { app } = require('../../src/app');

describe('Auto-generated contract tests from OpenAPI spec', () => {
  let api;

  beforeAll(async () => {
    api = new OpenAPIBackend({
      definition: path.resolve(__dirname, '../../api/openapi.yaml'),
    });
    await api.init();
  });

  it('validates GET /users response against spec', async () => {
    const res = await request(app).get('/users');

    const validationResult = api.validateResponse(res.body, {
      operationId: 'listUsers',
      status: res.status,
    });

    expect(validationResult.errors).toBeNull();
  });

  it('validates POST /users 201 response against spec', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'generated@example.com', name: 'Generated' });

    const validationResult = api.validateResponse(res.body, {
      operationId: 'createUser',
      status: res.status,
    });

    expect(validationResult.errors).toBeNull();
  });
});

Catching Real Drift: Examples

Here's what contract tests catch that regular tests miss:

Missing required field:

// Bug: handler returns { id, email } but spec requires { id, email, name, createdAt }
// Your business logic test: expect(res.body.email).toBe('...')  ← passes
// Contract test: expectValidResponse('createUser', '201', res.body)  ← fails
//   "/name is required"
//   "/createdAt is required"

Wrong type:

// Bug: handler returns { id: "42" } (string) instead of { id: 42 } (integer)
// Your business logic test: expect(res.body.id).toBeTruthy()  ← passes
// Contract test: ← fails
//   "/id must be integer"

Undocumented status code:

// Bug: handler returns 422 for validation errors but spec documents 400
// Your test: expect(res.status).toBe(422)  ← passes
// Spec drift detection: ← fails
//   "422 is not documented in spec"

CI Integration

Add a contract script to package.json:

{
  "scripts": {
    "test:contract": "jest tests/contract/ --forceExit",
    "test:schema": "node -e \"require('@apidevtools/swagger-parser').validate('./api/openapi.yaml').then(() => console.log('Spec valid')).catch(e => { console.error(e); process.exit(1); })\""
  }
}

In your GitHub Actions workflow:

- name: Validate OpenAPI spec
  run: npm run test:schema

- name: Run contract tests
  run: npm run test:contract
  env:
    DATABASE_URL: postgresql://test:test@localhost:5432/testdb

Summary

OpenAPI contract testing with Supertest works in layers:

  1. Spec validation — swagger-parser validates the spec itself is well-formed
  2. Response validation — ajv or openapi-backend validates every response body matches the schema
  3. Drift detection — automated tests verify that status codes and paths are all documented
  4. Auto-generation — generate basic test scaffolding from the spec to catch undocumented routes

The payoff: any time a field is renamed, a required property is added, or a type changes — in code or in the spec — a contract test fails in CI before the change reaches consumers. Schema drift becomes a CI failure, not a support ticket.

Start now free