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.
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 TestsUnit 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:performanceConclusion
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
- Testing Library: Best Practices — Testing best practices
- Pact: Consumer-Driven Contract Testing — Contract testing documentation
- OpenAPI Specification — API specification standard
- k6: Load Testing Tool — Performance testing documentation