Developer tools

API Testing Strategies: From Unit to Contract Testing

API quality starts with testing strategy. Learn how unit tests, integration tests, contract tests, and E2E tests work together to catch bugs early and prevent production failures.

3/11/20268 min readDev tools
API Testing Strategies: From Unit to Contract Testing

Executive summary

API quality starts with testing strategy. Learn how unit tests, integration tests, contract tests, and E2E tests work together to catch bugs early and prevent production failures.

Last updated: 3/11/2026

The testing problem

APIs fail in production for predictable reasons: breaking changes slip through, integration issues emerge only in real environments, and contract mismatches between services cause cascading failures. Traditional testing approaches that rely heavily on end-to-end tests are slow, flaky, and provide late feedback.

The fundamental challenge is building confidence that your API works correctly without spending days running tests. You need fast feedback for developers, confidence that services integrate correctly, and assurance that production deployments won't break downstream consumers.

A strategic testing approach combines multiple test types in a pyramid: fast unit tests at the base, targeted integration tests in the middle, and fewer but critical E2E tests at the top. Contract testing bridges the gap between services, catching integration issues before deployment.

The test pyramid for APIs

Understanding the optimal distribution of test types prevents common anti-patterns like "ice cream cone" architectures with too many slow E2E tests.

                E2E Tests
               /          \
            /                 \
         /                       \
      /    Integration Tests      \
    /                               \
   /      Contract Tests               \
  /_____________________________________\
            Unit Tests

Unit tests: 70% of tests

Focus: Individual functions, classes, and modules in isolation.

Characteristics:

  • Fast (milliseconds)
  • Deterministic (no external dependencies)
  • Mocked dependencies (databases, external APIs)
  • Run on every commit and PR
typescript// Unit test: API endpoint handler in isolation
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { request } from 'express';
import { UserController } from './UserController';
import { UserService } from './UserService';

describe('UserController.createUser', () => {
  let controller: UserController;
  let mockUserService: any;

  beforeEach(() => {
    // Mock the service dependency
    mockUserService = {
      create: vi.fn(),
      findByEmail: vi.fn()
    };
    controller = new UserController(mockUserService);
  });

  it('should create user with valid data', async () => {
    const mockUser = { id: 1, email: 'test@example.com', name: 'Test User' };
    mockUserService.create.mockResolvedValue(mockUser);
    mockUserService.findByEmail.mockResolvedValue(null);

    const req = {
      body: { email: 'test@example.com', name: 'Test User' }
    } as any;
    const res = {
      status: vi.fn().mockReturnThis(),
      json: vi.fn()
    } as any;

    await controller.createUser(req, res);

    expect(mockUserService.create).toHaveBeenCalledWith({
      email: 'test@example.com',
      name: 'Test User'
    });
    expect(res.status).toHaveBeenCalledWith(201);
    expect(res.json).toHaveBeenCalledWith({
      data: mockUser
    });
  });

  it('should return 400 for invalid email format', async () => {
    const req = {
      body: { email: 'invalid-email', name: 'Test User' }
    } as any;
    const res = {
      status: vi.fn().mockReturnThis(),
      json: vi.fn()
    } as any;

    await controller.createUser(req, res);

    expect(res.status).toHaveBeenCalledWith(400);
    expect(res.json).toHaveBeenCalledWith(
      expect.objectContaining({
        error: expect.stringContaining('email')
      })
    );
  });

  it('should return 409 for duplicate email', async () => {
    const existingUser = { id: 1, email: 'test@example.com' };
    mockUserService.findByEmail.mockResolvedValue(existingUser);

    const req = {
      body: { email: 'test@example.com', name: 'Test User' }
    } as any;
    const res = {
      status: vi.fn().mockReturnThis(),
      json: vi.fn()
    } as any;

    await controller.createUser(req, res);

    expect(res.status).toHaveBeenCalledWith(409);
    expect(res.json).toHaveBeenCalledWith({
      error: 'Email already exists'
    });
  });
});

Integration tests: 20% of tests

Focus: Interaction between components and external dependencies (database, cache, message queue).

Characteristics:

  • Slower (seconds to minutes)
  • Real external dependencies (test database)
  • Multiple components working together
  • Run on PR merge and pre-production
typescript// Integration test: API with real database
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupDatabase, teardownDatabase } from './test-helpers';
import { createTestApp } from './test-app';
import request from 'supertest';

describe('POST /api/users (integration)', () => {
  let app: Express;
  let db: Database;

  beforeAll(async () => {
    // Set up test database with schema
    db = await setupDatabase();
    app = createTestApp({ database: db });
  });

  afterAll(async () => {
    await teardownDatabase(db);
  });

  it('should create user and persist to database', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        email: 'integration@example.com',
        name: 'Integration Test User'
      })
      .expect(201);

    expect(response.body).toMatchObject({
      data: {
        id: expect.any(Number),
        email: 'integration@example.com',
        name: 'Integration Test User',
        createdAt: expect.any(String)
      }
    });

    // Verify persistence in database
    const user = await db.query('SELECT * FROM users WHERE email = $1', ['integration@example.com']);
    expect(user).not.toBeNull();
    expect(user.name).toBe('Integration Test User');
  });

  it('should handle concurrent requests correctly', async () => {
    const requests = Array(10).fill(null).map((_, i) =>
      request(app)
        .post('/api/users')
        .send({
          email: `concurrent${i}@example.com`,
          name: `User ${i}`
        })
    );

    const responses = await Promise.all(requests);

    // All requests should succeed
    responses.forEach((response, i) => {
      expect(response.status).toBe(201);
      expect(response.body.data.email).toBe(`concurrent${i}@example.com`);
    });

    // Verify all users exist in database
    const count = await db.query('SELECT COUNT(*) FROM users WHERE email LIKE $1', ['concurrent%']);
    expect(parseInt(count.count)).toBe(10);
  });
});

E2E tests: 10% of tests

Focus: Complete user workflows across the entire system.

Characteristics:

  • Slowest (minutes)
  • Real system (or staging environment)
  • Critical user journeys
  • Run before production deployment
typescript// E2E test: Complete user flow
import { test, expect } from '@playwright/test';

test.describe('User registration and login flow', () => {
  test('complete user registration and login', async ({ page }) => {
    await page.goto('https://staging.example.com');

    // Navigate to registration
    await page.click('text=Register');
    await expect(page).toHaveURL(/.*register/);

    // Fill registration form
    await page.fill('input[name="email"]', 'e2e@example.com');
    await page.fill('input[name="name"]', 'E2E Test User');
    await page.fill('input[name="password"]', 'SecurePassword123!');
    await page.fill('input[name="confirmPassword"]', 'SecurePassword123!');

    // Submit form
    await page.click('button[type="submit"]');

    // Verify success
    await expect(page.locator('.success-message')).toBeVisible();
    await expect(page.locator('text=Welcome, E2E Test User')).toBeVisible();

    // Logout
    await page.click('text=Logout');

    // Login
    await page.fill('input[name="email"]', 'e2e@example.com');
    await page.fill('input[name="password"]', 'SecurePassword123!');
    await page.click('button[type="submit"]');

    // Verify logged in state
    await expect(page.locator('text=Welcome, E2E Test User')).toBeVisible();
    await expect(page).toHaveURL(/.*dashboard/);
  });

  test('handle invalid login attempts', async ({ page }) => {
    await page.goto('https://staging.example.com/login');

    // Attempt login with invalid credentials
    await page.fill('input[name="email"]', 'nonexistent@example.com');
    await page.fill('input[name="password"]', 'WrongPassword123!');
    await page.click('button[type="submit"]');

    // Verify error message
    await expect(page.locator('.error-message')).toContainText('Invalid credentials');
  });
});

Contract testing

Contract testing verifies that services agree on their interaction interface without running both services simultaneously. This catches breaking changes before deployment.

Pact contract testing

typescript// Consumer test: Define expected contract
import { Pact } from '@pact-foundation/pact';
import { like } from '@pact-foundation/dsl';

describe('User API Consumer Contract', () => {
  const provider = new Pact({
    consumer: 'OrderService',
    provider: 'UserService',
    port: 1234
  });

  beforeAll(async () => await provider.setup());
  afterAll(async () => await provider.finalize());
  afterEach(async () => await provider.verify());

  it('should fetch user by ID', async () => {
    await provider.addInteraction({
      state: 'user with ID 1 exists',
      uponReceiving: 'a request for user by ID',
      withRequest: {
        method: 'GET',
        path: '/api/users/1',
        headers: { Accept: 'application/json' }
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          data: {
            id: like(1),
            email: like('test@example.com'),
            name: like('Test User'),
            createdAt: like('2024-01-01T00:00:00.000Z')
          }
        }
      }
    });

    // Make actual call to Pact mock server
    const response = await fetch('http://localhost:1234/api/users/1', {
      headers: { Accept: 'application/json' }
    });

    const data = await response.json();
    expect(response.status).toBe(200);
    expect(data.data.id).toBe(1);
  });
});

Provider verification

typescript// Provider test: Verify service meets contract
import { Verifier } from '@pact-foundation/pact';
import { createApp } from './app';

describe('User API Provider Contract', () => {
  let app: Express;

  beforeAll(async () => {
    app = await createApp({ database: testDatabase });
    app.listen(3001);
  });

  it('should satisfy consumer contract', async () => {
    const verifier = new Verifier({
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: ['./pacts/OrderService-UserService.json'],
      providerStatesSetupUrl: 'http://localhost:3001/api/_pact/provider-states'
    });

    const result = await verifier.verify();
    expect(result).toEqual({
      ok: true,
      version: '1.0.0'
    });
  });
});

OpenAPI schema validation

Using OpenAPI specifications provides both documentation and automated contract validation.

typescript// Test: Validate API responses against OpenAPI schema
import { OpenAPIV3 } from 'openapi-types';
import { validateResponse } from 'openapi-response-validator';

describe('OpenAPI contract validation', () => {
  const apiSpec: OpenAPIV3.Document = loadOpenAPISpec('./openapi.yaml');
  const validator = validateResponse(apiSpec);

  describe('GET /api/users/:id', () => {
    it('should return valid 200 response', async () => {
      const response = await request(app)
        .get('/api/users/1')
        .expect(200);

      const validation = validator('/api/users/{id}', 'get', 200, response.body);
      expect(validation.valid).toBe(true);
    });

    it('should return valid 404 response', async () => {
      const response = await request(app)
        .get('/api/users/999999')
        .expect(404);

      const validation = validator('/api/users/{id}', 'get', 404, response.body);
      expect(validation.valid).toBe(true);
    });
  });
});

Performance testing

APIs must handle expected load without degrading. Performance tests catch issues before production.

typescript// Load test with k6
import http from 'k6/http';
import { check, sleep } from 'k6';

export let options = {
  stages: [
    { duration: '2m', target: 100 },  // Ramp up to 100 users
    { duration: '5m', target: 100 },  // Stay at 100 users
    { duration: '2m', target: 200 },  // Ramp up to 200 users
    { duration: '5m', target: 200 },  // Stay at 200 users
    { duration: '2m', target: 0 },    // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95% of requests < 500ms
    http_req_failed: ['rate<0.01'],    // Error rate < 1%
  },
};

export default function () {
  // Create user
  const payload = JSON.stringify({
    email: `user${__VU}@example.com`,
    name: `User ${__VU}`
  });

  const params = {
    headers: { 'Content-Type': 'application/json' },
  };

  const res = http.post('https://api.example.com/users', payload, params);

  check(res, {
    'status was 201': (r) => r.status === 201,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  sleep(1);
}

Security testing

Security tests verify that APIs protect against common vulnerabilities.

typescript// Security test examples
import { test, expect } from '@playwright/test';

test.describe('API Security Tests', () => {
  test('should reject SQL injection attempts', async ({ request }) => {
    const response = await request.post('https://api.example.com/users', {
      data: {
        email: "'; DROP TABLE users; --",
        name: 'Attack Attempt'
      }
    });

    expect(response.status()).toBe(400);
    expect(await response.json()).toMatchObject({
      error: expect.stringContaining('validation')
    });
  });

  test('should require authentication for protected endpoints', async ({ request }) => {
    const response = await request.get('https://api.example.com/api/admin/users');

    expect(response.status()).toBe(401);
  });

  test('should reject oversized payloads', async ({ request }) => {
    const largePayload = 'x'.repeat(10 * 1024 * 1024); // 10MB

    const response = await request.post('https://api.example.com/users', {
      data: { email: largePayload, name: 'Test' }
    });

    expect(response.status()).toBe(413);
  });

  test('should enforce rate limiting', async ({ request }) => {
    const requests = Array(100).fill(null).map(() =>
      request.get('https://api.example.com/api/users')
    );

    const responses = await Promise.all(requests);
    const rateLimitedResponses = responses.filter(r => r.status() === 429);

    expect(rateLimitedResponses.length).toBeGreaterThan(0);
  });
});

Test organization and structure

Organizing tests effectively maintains clarity and reduces duplication.

typescript// Test helpers and fixtures
import { TestFactory } from './TestFactory';

describe('User API', () => {
  const factory = new TestFactory();

  beforeAll(async () => {
    await factory.setup();
  });

  afterAll(async () => {
    await factory.teardown();
  });

  describe('POST /api/users', () => {
    it('should create user', async () => {
      const userData = factory.userData();
      const response = await factory.request()
        .post('/api/users')
        .send(userData)
        .expect(201);

      expect(response.body.data).toMatchObject({
        id: expect.any(Number),
        ...userData
      });
    });
  });

  describe('GET /api/users/:id', () => {
    it('should return user', async () => {
      const user = await factory.createUser();
      const response = await factory.request()
        .get(`/api/users/${user.id}`)
        .expect(200);

      expect(response.body.data).toMatchObject({
        id: user.id,
        email: user.email
      });
    });
  });
});

Common testing anti-patterns

Anti-pattern 1: Testing implementation details

typescript// BAD: Testing implementation
it('should call database.query', () => {
  expect(mockDatabase.query).toHaveBeenCalledWith(
    'SELECT * FROM users WHERE id = $1',
    [1]
  );
});

// GOOD: Testing behavior
it('should return user when ID exists', () => {
  const user = await controller.getUser(1);
  expect(user).toMatchObject({
    id: 1,
    email: expect.any(String)
  });
});

Anti-pattern 2: Over-mocking in integration tests

typescript// BAD: Mocking database in integration test
beforeEach(() => {
  mockDatabase.query.mockResolvedValue(testUser);
});

// GOOD: Using test database in integration test
beforeAll(async () => {
  db = await setupTestDatabase();
});

Anti-pattern 3: Brittle E2E tests

typescript// BAD: Selecting by implementation details
await page.click('.btn-primary:nth-child(2)');

// GOOD: Selecting by user-facing attributes
await page.click('button[type="submit"]');
await page.click('text=Create User');

Test automation pipeline

Integrating tests into CI/CD ensures quality gates.

yaml# GitHub Actions workflow
name: API Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run test:unit -- --coverage

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run test:integration

  contract-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run test:contract

  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run test:e2e

  performance-tests:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run test:performance

Conclusion

API testing strategy combines multiple test types in a pyramid: fast unit tests for quick feedback, integration tests for component interaction, contract tests for service compatibility, and selective E2E tests for critical workflows.

The key is balance. Too many E2E tests create slow, brittle test suites. Too few integration tests miss bugs that only appear with real dependencies. Contract testing bridges the gap between services without requiring both to run simultaneously.

Start with unit tests that focus on behavior, not implementation. Add integration tests for critical paths with real dependencies. Implement contract testing for service boundaries. Use E2E tests sparingly for complete user journeys. Run tests in CI/CD to catch issues early, and iterate on test effectiveness based on production incidents.


Your API testing strategy needs refinement to prevent production failures? Talk to Imperialis engineering specialists about comprehensive API testing strategies, contract testing implementation, and CI/CD integration for your API development lifecycle.

Sources

Related reading