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:

The API Testing Pyramid

A balanced testing strategy follows the testing pyramid:

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

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:

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:

Mocking External APIs

When testing code that calls third-party APIs, mock the responses to avoid:

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:

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

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.html

CI/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:

4. Flaky Tests

Tests that randomly fail destroy confidence. Common causes:

5. Not Testing Error Cases

Most production bugs happen in error handling paths. Test:

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:

Resources and Tools

Testing Libraries

Monitoring

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.

Monitor Your API Dependencies

Don't let third-party API outages catch you off guard. API Status Check monitors 160+ APIs in real-time so you know about issues before your users do.

Check status for: StripeOpenAIAWSGitHubAnthropicVercelSupabase