API Testing: Complete Guide for Production APIs
Master API testing strategies from unit tests to production monitoring. Learn testing patterns used by top engineering teams.
API testing is the difference between shipping with confidence and debugging production incidents at 3 AM. A comprehensive testing strategy catches 95%+ of bugs before production, reduces deployment anxiety, and enables rapid iteration without breaking existing integrations.
This guide covers the complete API testing pyramid: unit tests, integration tests, contract testing, E2E testing, load testing, and monitoring third-party API dependencies. With complete TypeScript examples you can adapt for your stack.
Why API Testing Matters
Poor API testing has real costs:
- Production incidents: 70% of API outages stem from untested edge cases
- Breaking changes: Without contract testing, 40% of deployments break existing integrations
- Third-party failures: Untested API dependencies cause 60% of cascading failures
- Slow development: Fear of breaking things slows shipping velocity by 3-5x
- Support burden: Bugs in production generate 10x more support tickets than bugs caught in testing
The API Testing Pyramid
A balanced testing strategy follows the testing pyramid:
- 70% Unit Tests: Fast, isolated tests of individual functions and logic
- 20% Integration Tests: Tests covering API endpoints with database and external services
- 10% E2E Tests: Full user journey tests covering multiple APIs and services
Plus continuous monitoring for third-party API dependencies in production.
Unit Testing: Testing Business Logic
Unit tests verify individual functions in isolation. They're fast (milliseconds), reliable, and catch logic bugs early.
What to Unit Test
- Request validation logic
- Business logic (calculations, transformations)
- Error handling logic
- Authorization logic
- Data formatting and serialization
Example: Testing Validation Logic with Jest
// src/validation/user.ts
export interface CreateUserInput {
email: string;
password: string;
name: string;
}
export class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = 'ValidationError';
}
}
export function validateCreateUser(input: CreateUserInput): void {
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(input.email)) {
throw new ValidationError('email', 'Invalid email format');
}
// Password strength
if (input.password.length < 8) {
throw new ValidationError('password', 'Password must be at least 8 characters');
}
if (!/[A-Z]/.test(input.password)) {
throw new ValidationError('password', 'Password must contain an uppercase letter');
}
if (!/[0-9]/.test(input.password)) {
throw new ValidationError('password', 'Password must contain a number');
}
// Name validation
if (input.name.trim().length < 2) {
throw new ValidationError('name', 'Name must be at least 2 characters');
}
if (input.name.trim().length > 100) {
throw new ValidationError('name', 'Name must be less than 100 characters');
}
}
// src/validation/user.test.ts
import { validateCreateUser, ValidationError } from './user';
describe('validateCreateUser', () => {
const validInput = {
email: 'user@example.com',
password: 'Password123',
name: 'John Doe',
};
describe('email validation', () => {
it('accepts valid email addresses', () => {
expect(() => validateCreateUser(validInput)).not.toThrow();
});
it('rejects email without @', () => {
const input = { ...validInput, email: 'invalid.email.com' };
expect(() => validateCreateUser(input)).toThrow(ValidationError);
expect(() => validateCreateUser(input)).toThrow('Invalid email format');
});
it('rejects email without domain', () => {
const input = { ...validInput, email: 'user@' };
expect(() => validateCreateUser(input)).toThrow(ValidationError);
});
it('rejects email with spaces', () => {
const input = { ...validInput, email: 'user @example.com' };
expect(() => validateCreateUser(input)).toThrow(ValidationError);
});
});
describe('password validation', () => {
it('accepts strong passwords', () => {
expect(() => validateCreateUser(validInput)).not.toThrow();
});
it('rejects passwords shorter than 8 characters', () => {
const input = { ...validInput, password: 'Pass1' };
expect(() => validateCreateUser(input)).toThrow('at least 8 characters');
});
it('rejects passwords without uppercase letters', () => {
const input = { ...validInput, password: 'password123' };
expect(() => validateCreateUser(input)).toThrow('uppercase letter');
});
it('rejects passwords without numbers', () => {
const input = { ...validInput, password: 'Password' };
expect(() => validateCreateUser(input)).toThrow('contain a number');
});
});
describe('name validation', () => {
it('accepts valid names', () => {
expect(() => validateCreateUser(validInput)).not.toThrow();
});
it('rejects names shorter than 2 characters', () => {
const input = { ...validInput, name: 'J' };
expect(() => validateCreateUser(input)).toThrow('at least 2 characters');
});
it('rejects names longer than 100 characters', () => {
const input = { ...validInput, name: 'a'.repeat(101) };
expect(() => validateCreateUser(input)).toThrow('less than 100 characters');
});
it('trims whitespace before validation', () => {
const input = { ...validInput, name: ' Jo ' };
expect(() => validateCreateUser(input)).not.toThrow();
});
});
});Why this works:
- Tests each validation rule independently
- Covers edge cases (empty strings, boundary values, special characters)
- Fast execution (entire suite runs in <100ms)
- Clear error messages make debugging easy
Testing Business Logic
// src/services/pricing.ts
export interface PricingTier {
name: string;
basePrice: number;
perUnitPrice: number;
includedUnits: number;
}
export function calculatePrice(tier: PricingTier, units: number): number {
if (units <= tier.includedUnits) {
return tier.basePrice;
}
const additionalUnits = units - tier.includedUnits;
return tier.basePrice + (additionalUnits * tier.perUnitPrice);
}
export function findBestTier(tiers: PricingTier[], units: number): PricingTier {
return tiers.reduce((best, current) => {
const bestPrice = calculatePrice(best, units);
const currentPrice = calculatePrice(current, units);
return currentPrice < bestPrice ? current : best;
});
}
// src/services/pricing.test.ts
import { calculatePrice, findBestTier, PricingTier } from './pricing';
describe('pricing calculations', () => {
const starterTier: PricingTier = {
name: 'Starter',
basePrice: 29,
perUnitPrice: 2,
includedUnits: 10,
};
const proTier: PricingTier = {
name: 'Pro',
basePrice: 99,
perUnitPrice: 1,
includedUnits: 50,
};
describe('calculatePrice', () => {
it('returns base price when usage is within included units', () => {
expect(calculatePrice(starterTier, 5)).toBe(29);
expect(calculatePrice(starterTier, 10)).toBe(29);
});
it('adds per-unit charges for usage above included units', () => {
// 11 units = $29 base + (1 extra unit * $2) = $31
expect(calculatePrice(starterTier, 11)).toBe(31);
// 20 units = $29 base + (10 extra units * $2) = $49
expect(calculatePrice(starterTier, 20)).toBe(49);
});
it('handles zero usage', () => {
expect(calculatePrice(starterTier, 0)).toBe(29);
});
it('calculates correctly for higher tiers', () => {
// 60 units = $99 base + (10 extra * $1) = $109
expect(calculatePrice(proTier, 60)).toBe(109);
});
});
describe('findBestTier', () => {
const tiers = [starterTier, proTier];
it('chooses Starter for low usage', () => {
expect(findBestTier(tiers, 5).name).toBe('Starter');
expect(findBestTier(tiers, 20).name).toBe('Starter'); // $49 vs $99
});
it('chooses Pro for high usage', () => {
// At 100 units:
// Starter: $29 + (90 * $2) = $209
// Pro: $99 + (50 * $1) = $149
expect(findBestTier(tiers, 100).name).toBe('Pro');
});
it('finds breakeven point correctly', () => {
// Find the usage level where Pro becomes cheaper than Starter
// Starter: $29 + (x - 10) * $2
// Pro: $99 + (x - 50) * $1
// Breakeven: x ≈ 80 units
expect(findBestTier(tiers, 79).name).toBe('Starter');
expect(findBestTier(tiers, 81).name).toBe('Pro');
});
});
});Integration Testing: Testing API Endpoints
Integration tests verify your API endpoints work correctly with real database connections and external services. They're slower than unit tests but catch integration bugs.
Setting Up Test Environment
// test/setup.ts
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL_TEST, // Separate test database
},
},
});
export async function setupDatabase() {
// Run migrations
await prisma.$executeRaw`SET FOREIGN_KEY_CHECKS = 0`;
// Truncate all tables
const tables = await prisma.$queryRaw<Array<{ table_name: string }>>`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
`;
for (const { table_name } of tables) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table_name}`);
}
await prisma.$executeRaw`SET FOREIGN_KEY_CHECKS = 1`;
}
export async function teardownDatabase() {
await prisma.$disconnect();
}
// Run before all tests
beforeAll(async () => {
await setupDatabase();
});
// Clean database between tests
afterEach(async () => {
await setupDatabase();
});
// Cleanup after all tests
afterAll(async () => {
await teardownDatabase();
});Testing API Endpoints with Supertest
// src/routes/users.ts
import express from 'express';
import { prisma } from '../db';
import { validateCreateUser } from '../validation/user';
import bcrypt from 'bcrypt';
export const usersRouter = express.Router();
usersRouter.post('/users', async (req, res) => {
try {
// Validate input
validateCreateUser(req.body);
// Check if email already exists
const existing = await prisma.user.findUnique({
where: { email: req.body.email },
});
if (existing) {
return res.status(409).json({
error: {
code: 'EMAIL_TAKEN',
message: 'Email address is already registered',
},
});
}
// Hash password
const passwordHash = await bcrypt.hash(req.body.password, 10);
// Create user
const user = await prisma.user.create({
data: {
email: req.body.email,
passwordHash,
name: req.body.name,
},
});
// Return user without password
return res.status(201).json({
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt,
});
} catch (error) {
if (error instanceof ValidationError) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: error.message,
field: error.field,
},
});
}
console.error('User creation error:', error);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
}
});
// test/routes/users.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import { prisma } from '../../src/db';
import bcrypt from 'bcrypt';
describe('POST /users', () => {
const validUserData = {
email: 'test@example.com',
password: 'Password123',
name: 'Test User',
};
describe('successful user creation', () => {
it('creates a new user with valid data', async () => {
const response = await request(app)
.post('/users')
.send(validUserData)
.expect(201);
expect(response.body).toMatchObject({
email: 'test@example.com',
name: 'Test User',
});
expect(response.body.id).toBeDefined();
expect(response.body.createdAt).toBeDefined();
expect(response.body.passwordHash).toBeUndefined(); // Never return password
});
it('stores hashed password in database', async () => {
const response = await request(app)
.post('/users')
.send(validUserData)
.expect(201);
const user = await prisma.user.findUnique({
where: { id: response.body.id },
});
expect(user).toBeDefined();
expect(user!.passwordHash).not.toBe(validUserData.password);
// Verify password can be validated
const isValid = await bcrypt.compare(validUserData.password, user!.passwordHash);
expect(isValid).toBe(true);
});
});
describe('validation errors', () => {
it('rejects invalid email', async () => {
const response = await request(app)
.post('/users')
.send({ ...validUserData, email: 'invalid-email' })
.expect(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.field).toBe('email');
});
it('rejects weak password', async () => {
const response = await request(app)
.post('/users')
.send({ ...validUserData, password: 'weak' })
.expect(400);
expect(response.body.error.field).toBe('password');
});
it('rejects missing required fields', async () => {
const response = await request(app)
.post('/users')
.send({ email: 'test@example.com' })
.expect(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('duplicate email handling', () => {
it('rejects duplicate email addresses', async () => {
// Create first user
await request(app)
.post('/users')
.send(validUserData)
.expect(201);
// Try to create duplicate
const response = await request(app)
.post('/users')
.send(validUserData)
.expect(409);
expect(response.body.error.code).toBe('EMAIL_TAKEN');
});
it('handles race conditions correctly', async () => {
// Simulate concurrent requests
const requests = Array(5).fill(null).map(() =>
request(app).post('/users').send(validUserData)
);
const responses = await Promise.allSettled(requests);
// Exactly one should succeed
const successful = responses.filter(r =>
r.status === 'fulfilled' && r.value.status === 201
);
expect(successful).toHaveLength(1);
// Others should return 409
const duplicates = responses.filter(r =>
r.status === 'fulfilled' && r.value.status === 409
);
expect(duplicates).toHaveLength(4);
});
});
});Key integration testing patterns:
- Use a separate test database (never test against production data)
- Clean database between tests for isolation
- Test full request/response cycle including status codes and headers
- Verify database state changes
- Test error handling and edge cases
- Test concurrent requests and race conditions
Mocking External APIs
When testing code that calls third-party APIs, mock the responses to avoid:
- Rate limiting during test runs
- Costs from API usage
- Flaky tests from network issues
- Dependency on external service availability
Using MSW (Mock Service Worker)
// test/mocks/stripe.ts
import { rest } from 'msw';
import { setupServer } from 'msw/node';
export const stripeHandlers = [
// Mock successful charge creation
rest.post('https://api.stripe.com/v1/charges', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
id: 'ch_test_123',
amount: 1000,
currency: 'usd',
status: 'succeeded',
paid: true,
})
);
}),
// Mock charge retrieval
rest.get('https://api.stripe.com/v1/charges/:id', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
id: req.params.id,
amount: 1000,
currency: 'usd',
status: 'succeeded',
})
);
}),
// Mock card declined error
rest.post('https://api.stripe.com/v1/charges', (req, res, ctx) => {
const body = req.body as any;
if (body.source === 'tok_chargeDeclined') {
return res(
ctx.status(402),
ctx.json({
error: {
type: 'card_error',
code: 'card_declined',
message: 'Your card was declined.',
},
})
);
}
}),
];
export const server = setupServer(...stripeHandlers);
// Setup
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// test/services/payment.test.ts
import { processPayment } from '../../src/services/payment';
describe('payment processing', () => {
it('successfully processes valid payment', async () => {
const result = await processPayment({
amount: 1000,
currency: 'usd',
token: 'tok_visa',
});
expect(result.status).toBe('succeeded');
expect(result.chargeId).toBe('ch_test_123');
});
it('handles declined cards gracefully', async () => {
const result = await processPayment({
amount: 1000,
currency: 'usd',
token: 'tok_chargeDeclined',
});
expect(result.status).toBe('failed');
expect(result.error).toBe('card_declined');
});
it('retries on network errors', async () => {
// Override handler to simulate network error then success
let attempts = 0;
server.use(
rest.post('https://api.stripe.com/v1/charges', (req, res, ctx) => {
attempts++;
if (attempts === 1) {
return res.networkError('Connection timeout');
}
return res(
ctx.status(200),
ctx.json({ id: 'ch_test_123', status: 'succeeded' })
);
})
);
const result = await processPayment({
amount: 1000,
currency: 'usd',
token: 'tok_visa',
});
expect(result.status).toBe('succeeded');
expect(attempts).toBe(2); // Verify retry happened
});
});Contract Testing: Ensuring API Compatibility
Contract testing verifies your API matches its documented schema. This prevents breaking changes that would break client integrations.
Using Pact for Consumer-Driven Contracts
// test/contracts/user-api.pact.test.ts
import { Pact } from '@pact-foundation/pact';
import { getUserById } from '../../src/client';
describe('User API Contract', () => {
const provider = new Pact({
consumer: 'frontend-app',
provider: 'user-api',
port: 3001,
});
beforeAll(() => provider.setup());
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
describe('GET /users/:id', () => {
it('returns user with valid ID', async () => {
await provider.addInteraction({
state: 'user with ID 123 exists',
uponReceiving: 'a request for user 123',
withRequest: {
method: 'GET',
path: '/users/123',
headers: {
Accept: 'application/json',
},
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json',
},
body: {
id: 123,
email: 'user@example.com',
name: 'John Doe',
createdAt: '2026-01-01T00:00:00Z',
},
},
});
const user = await getUserById(123);
expect(user.id).toBe(123);
expect(user.email).toBe('user@example.com');
});
it('returns 404 for non-existent user', async () => {
await provider.addInteraction({
state: 'user with ID 999 does not exist',
uponReceiving: 'a request for non-existent user',
withRequest: {
method: 'GET',
path: '/users/999',
},
willRespondWith: {
status: 404,
body: {
error: {
code: 'USER_NOT_FOUND',
message: 'User not found',
},
},
},
});
await expect(getUserById(999)).rejects.toThrow('User not found');
});
});
});End-to-End Testing
E2E tests verify complete user journeys across multiple services. Use Playwright for testing APIs that power web UIs.
// e2e/signup-flow.spec.ts
import { test, expect } from '@playwright/test';
test.describe('User signup flow', () => {
test('complete signup journey', async ({ page, request }) => {
// Step 1: Visit signup page
await page.goto('https://example.com/signup');
// Step 2: Fill form
await page.fill('[name="email"]', 'newuser@example.com');
await page.fill('[name="password"]', 'SecurePass123');
await page.fill('[name="name"]', 'New User');
// Step 3: Submit form
await page.click('button[type="submit"]');
// Step 4: Verify API call was made
await page.waitForResponse(resp =>
resp.url().includes('/api/users') &&
resp.status() === 201
);
// Step 5: Verify redirect to dashboard
await expect(page).toHaveURL(/\/dashboard/);
// Step 6: Verify user data is displayed
await expect(page.locator('[data-testid="user-name"]'))
.toHaveText('New User');
// Step 7: Verify database state via API
const response = await request.get('/api/users/me', {
headers: {
'Cookie': await page.context().cookies(),
},
});
const user = await response.json();
expect(user.email).toBe('newuser@example.com');
});
test('handles validation errors correctly', async ({ page }) => {
await page.goto('https://example.com/signup');
// Submit with invalid email
await page.fill('[name="email"]', 'invalid-email');
await page.fill('[name="password"]', 'SecurePass123');
await page.fill('[name="name"]', 'New User');
await page.click('button[type="submit"]');
// Verify error message appears
await expect(page.locator('[role="alert"]'))
.toContainText('Invalid email format');
// Verify no API call was made
const apiCalls = page.requests().filter(r => r.url().includes('/api/users'));
expect(apiCalls).toHaveLength(0);
});
});Load Testing
Load testing verifies your API can handle production traffic levels. Use k6 for realistic load scenarios.
// load-tests/user-creation.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
const errorRate = new Rate('errors');
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '3m', target: 50 }, // Stay at 50 users
{ duration: '1m', target: 100 }, // Ramp up to 100 users
{ duration: '3m', target: 100 }, // Stay at 100 users
{ duration: '1m', target: 0 }, // Ramp down
],
thresholds: {
'http_req_duration': ['p(95)<500'], // 95% of requests under 500ms
'errors': ['rate<0.1'], // Error rate under 10%
},
};
export default function () {
const payload = JSON.stringify({
email: `user-${Date.now()}-${__VU}@example.com`,
password: 'TestPassword123',
name: `Test User ${__VU}`,
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const response = http.post('https://api.example.com/users', payload, params);
const success = check(response, {
'status is 201': (r) => r.status === 201,
'response time < 500ms': (r) => r.timings.duration < 500,
'has user ID': (r) => JSON.parse(r.body).id !== undefined,
});
errorRate.add(!success);
sleep(1);
}
// Run:
// k6 run load-tests/user-creation.js
//
// Expected output:
// ✓ http_req_duration..............: avg=234ms min=89ms med=212ms max=467ms p(95)=389ms
// ✓ http_req_failed................: 0.02% (12 out of 48234)
// ✓ errors.........................: 0.02%Testing Third-Party API Dependencies
Your API is only as reliable as its dependencies. Test how your API handles third-party failures.
Testing Timeout Handling
// test/services/external-api.test.ts
import { server } from '../mocks/server';
import { rest } from 'msw';
import { fetchUserProfile } from '../../src/services/external-api';
describe('external API timeout handling', () => {
it('times out after 5 seconds', async () => {
server.use(
rest.get('https://external-api.com/users/:id', async (req, res, ctx) => {
// Simulate slow response (6 seconds)
await new Promise(resolve => setTimeout(resolve, 6000));
return res(ctx.json({ id: '123' }));
})
);
const start = Date.now();
await expect(fetchUserProfile('123')).rejects.toThrow('Request timeout');
const duration = Date.now() - start;
// Verify timeout happened around 5 seconds
expect(duration).toBeGreaterThanOrEqual(5000);
expect(duration).toBeLessThan(5500);
});
it('succeeds when response is fast enough', async () => {
server.use(
rest.get('https://external-api.com/users/:id', async (req, res, ctx) => {
await new Promise(resolve => setTimeout(resolve, 100)); // 100ms
return res(ctx.json({ id: '123', name: 'John' }));
})
);
const user = await fetchUserProfile('123');
expect(user.name).toBe('John');
});
});Testing Rate Limit Handling
describe('rate limit handling', () => {
it('retries with exponential backoff on 429', async () => {
let attempts = 0;
server.use(
rest.get('https://external-api.com/data', (req, res, ctx) => {
attempts++;
if (attempts <= 2) {
return res(
ctx.status(429),
ctx.set('Retry-After', '2'),
ctx.json({ error: 'Too many requests' })
);
}
return res(ctx.json({ data: 'success' }));
})
);
const start = Date.now();
const result = await fetchData();
const duration = Date.now() - start;
expect(result.data).toBe('success');
expect(attempts).toBe(3);
// Verify exponential backoff timing
// First retry after 2s (from Retry-After header)
// Second retry after 4s (exponential backoff)
expect(duration).toBeGreaterThanOrEqual(6000);
});
it('gives up after max retries', async () => {
server.use(
rest.get('https://external-api.com/data', (req, res, ctx) => {
return res(
ctx.status(429),
ctx.json({ error: 'Too many requests' })
);
})
);
await expect(fetchData()).rejects.toThrow('Rate limit exceeded');
});
});Monitoring Third-Party APIs in Production
Testing in development catches most issues, but production monitoring is essential for third-party API dependencies. When Stripe, AWS, or OpenAI goes down, you need to know immediately.
Real-Time Status Monitoring
API Status Check monitors 160+ third-party APIs in real-time:
- AI platforms: OpenAI, Anthropic, Gemini, Groq
- Cloud providers: AWS, Azure, GCP, Vercel
- Payments: Stripe, PayPal, Square
- Developer tools: GitHub, GitLab, Docker Hub
- Auth providers: Auth0, Okta, Clerk
Integrate status checks into your tests:
// test/services/api-health.test.ts
import fetch from 'node-fetch';
async function checkAPIStatus(service: string): Promise<boolean> {
const response = await fetch(`https://apistatuscheck.com/api/status/${service}`);
const data = await response.json();
return data.status === 'operational';
}
describe('third-party API health', () => {
it('verifies Stripe is operational before payment tests', async () => {
const stripeHealthy = await checkAPIStatus('stripe');
if (!stripeHealthy) {
console.warn('⚠️ Stripe is experiencing issues, skipping payment tests');
return;
}
// Run payment integration tests
await runPaymentTests();
});
it('monitors OpenAI status before AI feature tests', async () => {
const openaiHealthy = await checkAPIStatus('openai');
expect(openaiHealthy).toBe(true);
});
});Implementing Circuit Breakers
When a third-party API is down, fail fast instead of timing out. See our Circuit Breaker Pattern guide for implementation.
Test Coverage Best Practices
What to Aim For
- 80%+ code coverage: But coverage isn't everything—test critical paths first
- 100% of critical paths: Authentication, payments, data mutations
- All error conditions: Every error response should have a test
- Edge cases: Empty arrays, null values, boundary conditions
Measuring Coverage
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/routes/': {
branches: 90, // Higher threshold for API routes
functions: 90,
lines: 90,
statements: 90,
},
},
};
// Run tests with coverage:
// npm test -- --coverage
//
// View HTML report:
// open coverage/lcov-report/index.htmlCI/CD Integration
Run tests automatically on every commit and pull request.
GitHub Actions Workflow
# .github/workflows/test.yml
name: API Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run migrations
run: npm run migrate
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/testdb
- name: Run unit tests
run: npm test -- --testPathPattern=\.test\.ts$
- name: Run integration tests
run: npm test -- --testPathPattern=\.integration\.test\.ts$
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/testdb
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
- name: Run E2E tests
run: npm run test:e2e
env:
API_URL: http://localhost:3000
- name: Run load tests
run: |
docker run --rm -i loadimpact/k6 run - <load-tests/user-creation.js
if: github.event_name == 'push' && github.ref == 'refs/heads/main'Common Testing Mistakes to Avoid
1. Testing Implementation Details
❌ Bad: Testing internal state and private methods
it('sets internal _validated flag', () => {
const validator = new Validator();
validator.validate(data);
expect(validator._validated).toBe(true); // Testing private implementation
});✅ Good: Testing public behavior
it('accepts valid data', () => {
const validator = new Validator();
expect(() => validator.validate(validData)).not.toThrow();
});2. Brittle Assertions
❌ Bad: Testing exact response format
expect(response.body).toEqual({
id: 123,
email: 'user@example.com',
name: 'John Doe',
createdAt: '2026-03-11T05:00:00.000Z',
updatedAt: '2026-03-11T05:00:00.000Z',
emailVerified: false,
roles: [],
preferences: {},
});✅ Good: Testing critical fields
expect(response.body).toMatchObject({
id: expect.any(Number),
email: 'user@example.com',
name: 'John Doe',
});
expect(response.body.createdAt).toBeDefined();3. Slow Test Suites
If your test suite takes >30 seconds, developers won't run it. Optimize:
- Run unit tests in parallel:
jest --maxWorkers=4 - Use in-memory databases for faster tests
- Mock external API calls
- Only run E2E tests on CI, not locally
4. Flaky Tests
Tests that randomly fail destroy confidence. Common causes:
- Race conditions: Tests depending on timing or execution order
- Shared state: Tests not properly isolated (clean database between tests)
- External dependencies: Tests calling real APIs (use mocks)
- Random data: Tests with non-deterministic inputs (use seeded data)
5. Not Testing Error Cases
Most production bugs happen in error handling paths. Test:
- Invalid input
- Network failures
- Database errors
- Third-party API failures
- Rate limiting
- Authorization failures
Production Monitoring: The Final Layer
Even with 100% test coverage, bugs reach production. Monitor continuously:
Health Check Endpoint
// src/routes/health.ts
import { prisma } from '../db';
import { checkAPIStatus } from '../services/status-check';
export async function healthCheck() {
const checks = {
database: false,
stripe: false,
openai: false,
timestamp: new Date().toISOString(),
};
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
checks.database = true;
} catch (error) {
console.error('Database health check failed:', error);
}
try {
// Check Stripe status via API Status Check
const stripeStatus = await checkAPIStatus('stripe');
checks.stripe = stripeStatus === 'operational';
} catch (error) {
console.error('Stripe health check failed:', error);
}
try {
// Check OpenAI status
const openaiStatus = await checkAPIStatus('openai');
checks.openai = openaiStatus === 'operational';
} catch (error) {
console.error('OpenAI health check failed:', error);
}
const healthy = checks.database && checks.stripe && checks.openai;
return {
status: healthy ? 'healthy' : 'degraded',
checks,
};
}
// GET /health
app.get('/health', async (req, res) => {
const health = await healthCheck();
const statusCode = health.status === 'healthy' ? 200 : 503;
res.status(statusCode).json(health);
});Alerting on Test Failures
// Monitor test results in production
import { sendAlert } from './alerts';
app.post('/api/test-results', async (req, res) => {
const { testsRun, failures, coverage } = req.body;
if (failures > 0) {
await sendAlert({
severity: 'high',
message: `${failures} tests failed in latest deployment`,
link: `https://ci.example.com/builds/${req.body.buildId}`,
});
}
if (coverage < 80) {
await sendAlert({
severity: 'medium',
message: `Coverage dropped to ${coverage}% (below 80% threshold)`,
});
}
res.json({ received: true });
});Testing Checklist
Before deploying to production, verify:
- ✅ Unit tests cover all business logic (80%+ coverage)
- ✅ Integration tests cover all API endpoints
- ✅ Authentication and authorization are tested
- ✅ All error responses have tests
- ✅ Validation logic is thoroughly tested
- ✅ Database constraints are tested (unique keys, foreign keys)
- ✅ Race conditions and concurrent requests are tested
- ✅ Third-party API failures are mocked and tested
- ✅ Rate limiting is tested
- ✅ Timeout handling is tested
- ✅ Load tests pass at expected traffic levels
- ✅ E2E tests cover critical user journeys
- ✅ Tests run on CI/CD pipeline
- ✅ Health check endpoint is implemented
- ✅ Third-party API status monitoring is configured
Resources and Tools
Testing Libraries
- Jest: Fast unit testing framework
- Supertest: HTTP assertions for integration tests
- MSW (Mock Service Worker): Mock external APIs
- Pact: Contract testing
- Playwright: E2E testing
- k6: Load testing
Monitoring
- API Status Check: Monitor 160+ third-party APIs
- Datadog: APM and error tracking
- Sentry: Error monitoring
- New Relic: Full-stack observability
Conclusion
A comprehensive API testing strategy is your safety net. It enables fast iteration, catches bugs before production, and gives you confidence to ship. Start with the testing pyramid: lots of fast unit tests, fewer integration tests, and minimal E2E tests. Mock external dependencies during testing, but monitor them continuously in production.
The tools matter less than the discipline. Ship tests with every feature. Fix flaky tests immediately. Monitor your dependencies. Your future self will thank you.