api-testing

Contract testing with Pact, API mocking with MSW, load testing with k6/Locust, OpenAPI validation, and Postman/Insomnia automation. Code-first patterns for catching regressions before they reach production.

Contract Testing with Pact

Consumer Side: Define the Contract

import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'frontend',
  provider: 'user-api',
});

describe('User API', () => {
  it('returns user by id', async () => {
    await provider
      .given('user 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({ method: 'GET', path: '/users/1' })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: { id: 1, name: 'Alice', email: '[email protected]' },
      })
      .executeTest(async (mockServer) => {
        const res = await fetch(`${mockServer.url}/users/1`);
        const data = await res.json();
        expect(data.name).toBe('Alice');
      });
  });
});

Provider Side: Verify Against Published Pacts

import { Verifier } from '@pact-foundation/pact';

new Verifier({
  providerBaseUrl: 'http://localhost:3000',
  pactBrokerUrl: 'https://pact-broker.company.com',
  provider: 'user-api',
  providerVersion: process.env.GIT_SHA,
  stateHandlers: {
    'user 1 exists': async () => {
      await db.users.create({ id: 1, name: 'Alice' });
    },
  },
}).verifyProvider();

BAD: Testing the provider implementation in isolation without consumer contracts. Changes break consumers silently.

GOOD: Consumers define expected request/response pairs. Provider CI verifies against all published pacts. Breaking changes fail the build.

Pact Broker Workflow

  1. Consumer runs tests, publishes pact to broker with pact-broker publish.
  2. Provider CI pulls latest pacts with pact-broker can-i-deploy --to=production.
  3. If verification passes, deploy. If it fails, coordinate with consumer team before fixing.

API Mocking Strategies

MSW for Network-Level Mocking

BAD: Mocking fetch at the module level. Tests diverge from production behavior.

jest.mock('node-fetch');
fetch.mockResolvedValue({ json: () => ({ id: 1 }) });

GOOD: Intercept at the network boundary with MSW. Tests hit real HTTP stack.

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Alice' });
  }),
];

const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Schema-Based Mocking with Prism

When the backend is not ready, generate a mock server from the OpenAPI spec.

prism mock openapi.yaml --port 4010

Frontend developers hit http://localhost:4010 and get spec-compliant responses. Requests that violate the schema return validation errors.

Record and Replay for Integration Tests

BAD: Hitting production APIs in tests. Flaky, slow, couples tests to external availability.

GOOD: Record real responses once, replay in tests. Refresh recordings monthly.

import { Polly } from '@pollyjs/core';

const polly = new Polly('user-api', {
  recordIfMissing: true,
  adapters: ['fetch'],
  persister: 'fs',
});

await fetch('/api/users/1'); // First run records, subsequent runs replay

Load Testing with k6

Smoke Test: Verify Endpoint Works

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  vus: 1,
  duration: '30s',
};

export default function () {
  const res = http.get('http://localhost:3000/api/health');
  check(res, { 'status is 200': (r) => r.status === 200 });
}

Load Test: Find Baseline Performance

BAD: Running load tests without thresholds. No automated pass/fail criteria.

export const options = { vus: 50, duration: '2m' };

GOOD: Define SLA thresholds. Fail CI if p95 latency exceeds target.

export const options = {
  vus: 50,
  duration: '2m',
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
    http_req_failed: ['rate<0.01'],   // Error rate under 1%
  },
};

export default function () {
  const res = http.get('http://localhost:3000/api/users');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(1);
}

Stress Test: Find Breaking Point

Ramp VUs beyond expected load to find where the system degrades.

export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 200 },
    { duration: '2m', target: 300 },
    { duration: '5m', target: 0 },
  ],
};

CI Integration

# Run smoke test on every PR
k6 run --quiet smoke.js

# Export results to Grafana Cloud
k6 run --out cloud load.js

Fail pipeline if thresholds are not met. Store results as build artifacts for trending.

Load Testing with Locust

Basic User Behavior

from locust import HttpUser, task, between

class ApiUser(HttpUser):
    wait_time = between(1, 3)

    @task(3)
    def get_users(self):
        self.client.get("/api/users")

    @task(1)
    def get_user_by_id(self):
        self.client.get("/api/users/1")

Weight tasks with @task(n). Higher values mean more frequent execution.

Headless Mode for CI

locust -f locustfile.py --headless -u 100 -r 10 -t 2m --host=http://localhost:3000
  • -u 100: 100 concurrent users
  • -r 10: Spawn 10 users per second
  • -t 2m: Run for 2 minutes

When to use Locust vs k6:

  • k6: JavaScript ecosystem, lower resource usage, built-in thresholds
  • Locust: Python ecosystem, web UI, complex user flows

OpenAPI Schema Validation

Runtime Validation in Express

BAD: Manual request validation. Diverges from spec over time.

app.post('/users', (req, res) => {
  if (!req.body.name) return res.status(400).send('Missing name');
});

GOOD: Validate against OpenAPI spec at runtime. Spec is the source of truth.

import { OpenApiValidator } from 'express-openapi-validator';

app.use(OpenApiValidator.middleware({
  apiSpec: './openapi.yaml',
  validateRequests: true,
  validateResponses: true,
}));

Requests that do not match the spec return 400. Responses that do not match log warnings (dev) or errors (production).

Test-Time Validation

Validate every test response against the schema. Catch drift immediately.

import Ajv from 'ajv';
import schema from './openapi.json';

const ajv = new Ajv();
const validate = ajv.compile(schema.paths['/users/{id}'].get.responses['200']);

const res = await fetch('/api/users/1');
const data = await res.json();
expect(validate(data)).toBe(true);

Detect Breaking Changes with oasdiff

oasdiff changelog openapi-v1.yaml openapi-v2.yaml

Output shows breaking changes (removed endpoint, changed type) vs non-breaking (added optional field).

Run in CI on spec changes. Flag breaking changes for review before merge.

Postman/Insomnia Collections

Collection Structure

BAD: Organizing by HTTP method (GET folder, POST folder). Hard to find related requests.

GOOD: Organize by resource. CRUD operations grouped together.

Users/
  Create User (POST)
  Get User (GET)
  Update User (PUT)
  Delete User (DELETE)
Orders/
  Create Order (POST)
  List Orders (GET)

Environment Variables

BAD: Hardcoding base URL and tokens in requests. Cannot switch environments.

{
  "method": "GET",
  "url": "https://api.production.com/users/1",
  "headers": { "Authorization": "Bearer abc123" }
}

GOOD: Use environment variables. Switch between local, staging, production with one click.

{
  "method": "GET",
  "url": "{{baseUrl}}/users/{{userId}}",
  "headers": { "Authorization": "Bearer {{authToken}}" }
}

Automation with Newman

Export collections to JSON, commit to repo, run in CI.

newman run collection.json -e staging.json --reporters cli,json

Generate collections from OpenAPI specs to stay in sync.

openapi2postmanv2 -s openapi.yaml -o collection.json

Test Organization

BAD: Mixing contract tests, integration tests, and load tests in one suite. Different cadences require different runs.

GOOD: Separate test suites by type and run on different schedules.

tests/
  contract/       # Run on every commit
  integration/    # Run on merge to main
  load/           # Run nightly

Tag tests for selective execution.

describe('User API', () => {
  it('returns 200 @smoke', async () => { /* ... */ });
  it('handles pagination @regression', async () => { /* ... */ });
});

Run smoke tests on every PR, regression tests on merge.

Troubleshooting

Flaky API tests: Add retry logic for network errors, not assertion failures. Use exponential backoff.

const res = await retryOnNetworkError(() => fetch('/api/users'), { maxRetries: 3 });

Contract verification failing: Check provider state setup. Missing stateHandlers is the most common cause.

k6 results inconsistent: Ensure test target is isolated. Shared staging environments skew results. Run against dedicated test instances.

Mock server not intercepting: Check URL patterns. MSW matches exact paths. Trailing slashes and query params matter.

// BAD: Does not match /api/users?page=1
http.get('/api/users', handler);

// GOOD: Matches all query params
http.get('/api/users', handler);