API Endpoint Testing Workflow with Claude Code
The Problem
Your API has dozens of endpoints but limited test coverage. Writing API tests manually is tedious: you need to handle authentication, set up test data, test success and error cases, validate response schemas, and clean up afterward. Most teams skip edge cases and only test the happy path.
Quick Start
Ask Claude Code to generate tests for an existing endpoint:
Read the route handler at src/routes/users.ts and generate
comprehensive API tests. Include:
- Success cases (200, 201)
- Validation errors (400)
- Authentication errors (401, 403)
- Not found (404)
- Edge cases (empty strings, max length, special characters)
Use supertest with Jest. Set up proper test data fixtures.
What’s Happening
API endpoint testing validates that your server responds correctly to HTTP requests. Unlike unit tests that test individual functions, API tests send real HTTP requests to your server and verify the complete response: status code, headers, body structure, and data content.
Claude Code generates comprehensive API tests because it can read your route handlers, middleware, validation schemas, and database models to understand exactly what each endpoint expects and returns.
Step-by-Step Guide
Step 1: Set up the test infrastructure
Ask Claude Code to create the test setup:
Set up API integration testing with supertest and Jest.
I need:
- Test database setup/teardown
- Authentication helper (generate test JWT tokens)
- Request helper with base URL and common headers
- Test data factories for User, Project, and Task
Claude Code creates the infrastructure:
// tests/setup.ts
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
const prisma = new PrismaClient();
beforeAll(async () => {
// Use a test database
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
});
afterAll(async () => {
await prisma.$disconnect();
});
afterEach(async () => {
// Clean up test data in reverse dependency order
await prisma.comment.deleteMany();
await prisma.task.deleteMany();
await prisma.project.deleteMany();
await prisma.user.deleteMany();
});
export { prisma };
// tests/helpers/auth.ts
import jwt from 'jsonwebtoken';
interface TestUser {
id: string;
email: string;
role: 'admin' | 'member';
}
export function createTestToken(user: TestUser): string {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET || 'test-secret',
{ expiresIn: '1h' }
);
}
export function authHeader(user: TestUser): { Authorization: string } {
return { Authorization: `Bearer ${createTestToken(user)}` };
}
// tests/factories/user.ts
import { prisma } from '../setup';
import { faker } from '@faker-js/faker';
export async function createTestUser(overrides: Partial<{
email: string;
name: string;
role: string;
}> = {}) {
return prisma.user.create({
data: {
email: overrides.email ?? faker.internet.email(),
name: overrides.name ?? faker.person.fullName(),
role: overrides.role ?? 'member',
passwordHash: '$2b$10$test-hash', // Pre-computed bcrypt hash
},
});
}
Step 2: Generate tests for CRUD endpoints
Ask Claude Code to read a route handler and generate tests:
Read src/routes/users.ts and generate tests for every endpoint.
Cover: success, validation, auth, not found, and edge cases.
Claude Code generates comprehensive tests:
// tests/api/users.test.ts
import request from 'supertest';
import { app } from '../../src/app';
import { createTestUser } from '../factories/user';
import { authHeader } from '../helpers/auth';
describe('GET /api/users', () => {
it('returns paginated list of users', async () => {
const admin = await createTestUser({ role: 'admin' });
await createTestUser();
await createTestUser();
const res = await request(app)
.get('/api/users')
.set(authHeader(admin))
.query({ page: 1, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(3);
expect(res.body.pagination).toEqual({
page: 1,
limit: 10,
total: 3,
totalPages: 1,
});
});
it('returns 401 without authentication', async () => {
const res = await request(app).get('/api/users');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Authentication required');
});
it('returns 403 for non-admin users', async () => {
const member = await createTestUser({ role: 'member' });
const res = await request(app)
.get('/api/users')
.set(authHeader(member));
expect(res.status).toBe(403);
});
it('filters by email when search param provided', async () => {
const admin = await createTestUser({ role: 'admin' });
await createTestUser({ email: '[email protected]' });
await createTestUser({ email: '[email protected]' });
const res = await request(app)
.get('/api/users')
.set(authHeader(admin))
.query({ search: 'alice' });
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].email).toBe('[email protected]');
});
});
describe('POST /api/users', () => {
it('creates a new user with valid data', async () => {
const admin = await createTestUser({ role: 'admin' });
const res = await request(app)
.post('/api/users')
.set(authHeader(admin))
.send({
email: '[email protected]',
name: 'New User',
role: 'member',
});
expect(res.status).toBe(201);
expect(res.body.email).toBe('[email protected]');
expect(res.body.passwordHash).toBeUndefined(); // Never expose
});
it('returns 400 for invalid email', async () => {
const admin = await createTestUser({ role: 'admin' });
const res = await request(app)
.post('/api/users')
.set(authHeader(admin))
.send({
email: 'not-an-email',
name: 'Test',
});
expect(res.status).toBe(400);
expect(res.body.errors).toContainEqual(
expect.objectContaining({ field: 'email' })
);
});
it('returns 409 for duplicate email', async () => {
const admin = await createTestUser({ role: 'admin' });
await createTestUser({ email: '[email protected]' });
const res = await request(app)
.post('/api/users')
.set(authHeader(admin))
.send({
email: '[email protected]',
name: 'Duplicate',
});
expect(res.status).toBe(409);
});
it('handles empty string name', async () => {
const admin = await createTestUser({ role: 'admin' });
const res = await request(app)
.post('/api/users')
.set(authHeader(admin))
.send({
email: '[email protected]',
name: '',
});
expect(res.status).toBe(400);
expect(res.body.errors).toContainEqual(
expect.objectContaining({ field: 'name' })
);
});
});
describe('GET /api/users/:id', () => {
it('returns user by ID', async () => {
const admin = await createTestUser({ role: 'admin' });
const target = await createTestUser({ name: 'Target User' });
const res = await request(app)
.get(`/api/users/${target.id}`)
.set(authHeader(admin));
expect(res.status).toBe(200);
expect(res.body.name).toBe('Target User');
});
it('returns 404 for non-existent ID', async () => {
const admin = await createTestUser({ role: 'admin' });
const res = await request(app)
.get('/api/users/non-existent-id')
.set(authHeader(admin));
expect(res.status).toBe(404);
});
});
Step 3: Test response schemas
Validate that response bodies match your API contract:
// tests/schemas/user.ts
import { z } from 'zod';
export const userResponseSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
role: z.enum(['admin', 'member']),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const paginatedUsersSchema = z.object({
data: z.array(userResponseSchema),
pagination: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
totalPages: z.number(),
}),
});
Use the schema in tests:
it('response matches the user schema', async () => {
const admin = await createTestUser({ role: 'admin' });
const res = await request(app)
.get('/api/users')
.set(authHeader(admin));
const parsed = paginatedUsersSchema.safeParse(res.body);
expect(parsed.success).toBe(true);
});
Step 4: Test error responses consistently
Ask Claude Code to generate error case tests:
Generate tests for all error responses across my API. Every error
response should have a consistent shape: { error: string, code: string }.
Check that no endpoint leaks stack traces or internal details in errors.
Step 5: Add performance assertions
it('responds within 200ms for paginated list', async () => {
const admin = await createTestUser({ role: 'admin' });
// Seed 100 users
await Promise.all(Array.from({ length: 100 }, () => createTestUser()));
const start = Date.now();
const res = await request(app)
.get('/api/users')
.set(authHeader(admin))
.query({ page: 1, limit: 20 });
const duration = Date.now() - start;
expect(res.status).toBe(200);
expect(duration).toBeLessThan(200);
});
Prevention
Add API testing rules to your CLAUDE.md:
## API Testing Rules
- Every new endpoint must have tests before merging
- Test: success, validation, auth, not found, and at least one edge case
- Validate response schemas using Zod
- Never hardcode IDs in tests — use factories
- Clean up test data after each test (afterEach)
- Keep test data minimal — only create what the test needs