Claude Code for API Testing: REST, Contract Testing, and Mock Servers — Claude Skills 360 Blog
Blog / Development / Claude Code for API Testing: REST, Contract Testing, and Mock Servers
Development

Claude Code for API Testing: REST, Contract Testing, and Mock Servers

Published: May 20, 2026
Read time: 9 min read
By: Claude Skills 360

API testing sits between unit testing (test the function) and integration testing (test the whole system). It requires a different approach: you need to test HTTP contract (status codes, response shape, headers), business logic (does the auth work?), and database effects (did the record get created?) — but you don’t want to test against live external services. Claude Code generates layered API tests that cover these concerns correctly.

This guide covers API testing with Claude Code: unit testing endpoints, contract testing, integration tests with real databases, mock servers with MSW, and API client testing.

Setting Up Claude Code for API Testing

Test strategy context prevents wrong-layer tests:

# API Testing Context

## Stack
- Express + TypeScript API tested with Jest + Supertest
- Test database: PostgreSQL in Docker (not SQLite — schema compatibility)
- External API mocking: msw (mock service worker) in tests
- Contract testing: Pact (consumer-driven contracts)

## Test Strategy
- Unit tests: handler logic with mocked services (fast)
- Integration tests: full HTTP with real test database (slower, more confident)
- Contract tests: verify our API shape matches what clients expect
- Never mock the database — use real DB in tests (see testing/memory-guide)

## Conventions
- Test DB resets between test files (not between tests — too slow)
- Each test file uses a transaction that rolls back
- External APIs always mocked — never hit real Stripe/Twilio/etc in CI

See the CLAUDE.md setup guide for complete configuration.

Integration Testing with Supertest

Basic HTTP Tests

Write integration tests for the user registration endpoint.
POST /api/auth/register
Cover: success, duplicate email, validation errors, 
weak password.
import request from 'supertest';
import { app } from '../app';
import { db } from '../db';

describe('POST /api/auth/register', () => {
  afterEach(async () => {
    // Clean test data — only the rows we know we created
    await db.user.deleteMany({ where: { email: { endsWith: '@test.example.com' } } });
  });
  
  it('creates a user and returns 201 with tokens', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({
        email: '[email protected]',
        password: 'SecurePass123!',
        name: 'Test User',
      });
    
    expect(res.status).toBe(201);
    expect(res.body).toMatchObject({
      user: {
        email: '[email protected]',
        name: 'Test User',
      },
    });
    expect(res.body.accessToken).toBeDefined();
    expect(res.body.refreshToken).toBeDefined();
    
    // Verify user persisted in DB
    const dbUser = await db.user.findUnique({ 
      where: { email: '[email protected]' } 
    });
    expect(dbUser).not.toBeNull();
    expect(dbUser!.passwordHash).not.toBe('SecurePass123!'); // Must be hashed
  });
  
  it('returns 409 for duplicate email', async () => {
    // Create existing user
    await db.user.create({ 
      data: { email: '[email protected]', name: 'Existing', passwordHash: 'hash' } 
    });
    
    const res = await request(app)
      .post('/api/auth/register')
      .send({ email: '[email protected]', password: 'SecurePass123!', name: 'New' });
    
    expect(res.status).toBe(409);
    expect(res.body.error).toMatch(/already registered/i);
  });
  
  it('returns 422 for validation errors', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({ email: 'not-an-email', password: '123' });
    
    expect(res.status).toBe(422);
    expect(res.body.details).toBeDefined();
    expect(res.body.details.some((d: any) => d.field === 'email')).toBe(true);
  });
});

Claude uses a test-specific email domain (@test.example.com) for targeted cleanup rather than deleteMany({}) on the whole table. The verification step (“passwordHash must not equal the plain password”) tests a security property, not just the response shape.

Authenticated Endpoint Tests

Write tests for a protected endpoint.
GET /api/projects — requires auth, returns only the user's projects.
describe('GET /api/projects', () => {
  let authHeaders: { Authorization: string };
  let userId: string;
  
  beforeAll(async () => {
    // Create test user and get token
    const { user, accessToken } = await createTestUser('[email protected]');
    userId = user.id;
    authHeaders = { Authorization: `Bearer ${accessToken}` };
    
    // Create test projects
    await db.project.createMany({
      data: [
        { name: 'Project A', ownerId: userId },
        { name: 'Project B', ownerId: userId },
        { name: 'Other User Project', ownerId: 'different-user-id' },
      ],
    });
  });
  
  afterAll(async () => {
    await db.project.deleteMany({ where: { ownerId: userId } });
    await db.user.delete({ where: { id: userId } });
  });
  
  it('returns 401 without auth', async () => {
    const res = await request(app).get('/api/projects');
    expect(res.status).toBe(401);
  });
  
  it('returns only the authenticated user\'s projects', async () => {
    const res = await request(app)
      .get('/api/projects')
      .set(authHeaders);
    
    expect(res.status).toBe(200);
    expect(res.body.projects).toHaveLength(2);
    expect(res.body.projects.every((p: any) => p.ownerId === userId)).toBe(true);
  });
  
  it('supports pagination', async () => {
    const res = await request(app)
      .get('/api/projects?page=1&pageSize=1')
      .set(authHeaders);
    
    expect(res.status).toBe(200);
    expect(res.body.projects).toHaveLength(1);
    expect(res.body.pagination.total).toBe(2);
  });
});

Transaction Rollback Pattern

Set up tests to use transactions that roll back after each test.
Much faster than DELETE cleanup.
// jest.setup.ts
import { db } from './db';

let transaction: any;

beforeEach(async () => {
  // Begin transaction — all test operations happen inside it
  transaction = await db.$transaction((tx) => {
    // Store the transactional client so tests use it
    (global as any).testTx = tx;
    return new Promise(() => {}); // Keep transaction open
  }, { timeout: 30000 });
});

afterEach(async () => {
  // Roll back the transaction — no cleanup code needed
  await transaction.rollback?.();
  (global as any).testTx = null;
});

The transaction approach is faster than DELETE cleanup for large test suites, but requires that all test code uses the transactional client (testTx) instead of the regular db client.

Mocking External APIs with MSW

Our checkout flow calls Stripe. 
Write tests that mock the Stripe API calls using MSW.
Cover: successful payment, card declined, network error.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.post('https://api.stripe.com/v1/payment_intents', () => {
    return HttpResponse.json({
      id: 'pi_test_123',
      status: 'succeeded',
      amount: 5000,
      currency: 'usd',
    });
  })
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('POST /api/checkout', () => {
  it('creates order on successful payment', async () => {
    const res = await request(app)
      .post('/api/checkout')
      .set(authHeaders)
      .send({ items: [{ productId: 'prod_123', quantity: 1 }] });
    
    expect(res.status).toBe(201);
    expect(res.body.order.status).toBe('confirmed');
  });
  
  it('returns 402 on card declined', async () => {
    server.use(
      http.post('https://api.stripe.com/v1/payment_intents', () => {
        return HttpResponse.json(
          { error: { type: 'card_error', code: 'card_declined', message: 'Your card was declined.' } },
          { status: 402 }
        );
      })
    );
    
    const res = await request(app)
      .post('/api/checkout')
      .set(authHeaders)
      .send({ items: [{ productId: 'prod_123', quantity: 1 }] });
    
    expect(res.status).toBe(402);
    expect(res.body.error.code).toBe('card_declined');
  });
  
  it('handles Stripe network error gracefully', async () => {
    server.use(
      http.post('https://api.stripe.com/v1/payment_intents', () => {
        return HttpResponse.networkError();
      })
    );
    
    const res = await request(app)
      .post('/api/checkout')
      .set(authHeaders)
      .send({ items: [{ productId: 'prod_123', quantity: 1 }] });
    
    expect(res.status).toBe(503);
  });
});

onUnhandledRequest: 'error' is important — if your code makes an unmocked external call, the test fails loudly instead of hitting the real API. MSW intercepts at the network layer, not at the import level, so it works with any HTTP client.

Contract Testing with Pact

We have a React frontend that consumes the orders API.
Set up Pact consumer-driven contract tests.
The frontend defines what it expects from the API.
// consumer (frontend) — defines the contract
import { Pact } from '@pact-foundation/pact';

const provider = new Pact({
  consumer: 'web-app',
  provider: 'api',
  port: 1234,
});

describe('Orders API contract', () => {
  before(() => provider.setup());
  after(() => provider.finalize());
  afterEach(() => provider.verify());
  
  it('GET /api/orders returns order list', async () => {
    await provider.addInteraction({
      state: 'user has two orders',
      uponReceiving: 'a request for orders',
      withRequest: {
        method: 'GET',
        path: '/api/orders',
        headers: { Authorization: 'Bearer test-token' },
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          orders: Matchers.eachLike({
            id: Matchers.uuid(),
            status: Matchers.string('pending'),
            totalCents: Matchers.integer(1000),
            createdAt: Matchers.iso8601DateTimeWithMillis(),
          }),
          pagination: {
            total: Matchers.integer(2),
          },
        },
      },
    });
    
    const orders = await fetchOrders('test-token');
    expect(orders).toHaveLength(1); // Pact uses minimum 1 by default
  });
});

Pact generates a contract file (JSON). The API then runs “provider verification” — it replays the consumer’s expectations against the real API and must pass. This catches breaking API changes before deployment.

Testing File Upload Endpoints

Test a file upload endpoint.
It accepts images, validates size and type, stores in S3.
Mock S3. Test file type rejection and size limits.
// Mock S3 with MSW
server.use(
  http.put(/s3\.amazonaws\.com/, () => {
    return new HttpResponse(null, { status: 200 });
  })
);

it('accepts valid image upload', async () => {
  const imageBuffer = Buffer.from('fake-jpeg-bytes');
  
  const res = await request(app)
    .post('/api/upload')
    .set(authHeaders)
    .attach('file', imageBuffer, {
      filename: 'photo.jpg',
      contentType: 'image/jpeg',
    });
  
  expect(res.status).toBe(200);
  expect(res.body.url).toMatch(/cloudfront\.net/);
});

it('rejects files over 5MB', async () => {
  const largeBuffer = Buffer.alloc(6 * 1024 * 1024); // 6MB
  
  const res = await request(app)
    .post('/api/upload')
    .set(authHeaders)
    .attach('file', largeBuffer, { filename: 'big.jpg', contentType: 'image/jpeg' });
  
  expect(res.status).toBe(413);
});

Supertest’s .attach() sends multipart form data — correct for file upload endpoints.

Test Data Factories

Create factory functions for test data.
I need to create users, orders, and order items in various states.
Should be type-safe and support overrides.
// factories/user.ts
import { faker } from '@faker-js/faker';
import { db } from '../db';

export function buildUser(overrides: Partial<UserCreateInput> = {}): UserCreateInput {
  return {
    email: faker.internet.email({ provider: 'test.example.com' }),
    name: faker.person.fullName(),
    passwordHash: '$2b$12$testhashedpassword12345678901234', // Pre-hashed "password"
    ...overrides,
  };
}

export async function createTestUser(overrides?: Partial<UserCreateInput>) {
  const data = buildUser(overrides);
  const user = await db.user.create({ data });
  const { accessToken } = generateTokens(user.id);
  return { user, accessToken };
}

@faker-js/faker for realistic test data, forced to @test.example.com for cleanup. The buildUser function (pure, no DB side effects) and createTestUser (persists to DB) separation is important — some tests need the built data without persistence.

API Testing with Claude Code

The pattern that works well: describe your endpoint’s behavior in terms of inputs and expected outputs, including error cases. Claude generates the full test file including setup, teardown, and the edge cases you’d otherwise forget.

For the general testing philosophy (when to use real databases vs. mocks), the testing guide covers these decisions in detail. For security-focused testing (does auth actually work?), the code review guide covers security review patterns that complement these test patterns. The Claude Skills 360 bundle includes API testing skill sets for REST and GraphQL. Start with the free tier.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free