
Api Testing
- 90 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with testing & qa tasks during AI-assisted development.
About
api-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- api-testing
- Testing & QA
- AI-coding skill
Api Testing by the numbers
- 90 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,041 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill api-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 90 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with testing & qa tasks during AI-assisted development.
Files
API Testing
Overview
API testing validates HTTP endpoints by sending requests and asserting responses, covering status codes, headers, body content, and error handling. Supertest provides a fluent chainable API for integration testing against Express, Fastify, and Hono apps without starting a real server. MSW (Mock Service Worker) v2 intercepts outgoing HTTP requests at the network level, enabling realistic mocking of external services in both Node.js tests and browser environments.
When to use: Integration tests for REST APIs, testing middleware pipelines, validating request/response contracts, mocking third-party APIs in tests, testing error handling and edge cases.
When NOT to use: Unit testing pure functions (use direct assertions), E2E browser testing (use Playwright/Cypress), load/performance testing (use k6/Artillery), testing static file serving.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| GET request | request(app).get('/path') | Returns supertest Test object |
| POST with body | request(app).post('/path').send(body) | Automatically sets Content-Type |
| Auth header | .set('Authorization', 'Bearer token') | Chain before .expect() |
| Status assertion | .expect(200) | Chainable with body assertions |
| Body assertion | .expect({ key: 'value' }) | Deep equality check |
| Header assertion | .expect('Content-Type', /json/) | Accepts string or regex |
| MSW HTTP handler | http.get('/api/users', resolver) | Intercepts matching requests |
| MSW GraphQL handler | graphql.query('GetUser', resolver) | Intercepts by operation name |
| MSW response | HttpResponse.json(data, { status }) | v2 response format |
| MSW error simulation | HttpResponse.error() | Simulates network failure |
| MSW one-time handler | http.get(path, resolver, { once: true }) | Auto-removed after first match |
| MSW per-test override | server.use(handler) | Override default handlers in specific tests |
| Schema validation | schema.parse(response.body) | Validates response structure with Zod |
| Cookie persistence | const agent = request.agent(app) | Maintains cookies across requests |
| Fastify inject | app.inject({ method, url }) | Built-in testing without supertest |
| Hono test client | testClient(app) | Type-safe request builder |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Not awaiting supertest requests | Always await request(app).get('/path') |
| Sharing server state between tests | Reset handlers with server.resetHandlers() in afterEach |
| Mocking fetch/axios directly | Use MSW to intercept at the network level |
Forgetting server.listen() in setup | Call in beforeAll, resetHandlers in afterEach |
| Passing Fastify instance to supertest | Use fastify.server (the underlying Node server) |
| Asserting before response completes | Use await or return the supertest chain |
| Hardcoding test data across many tests | Use factories or fixtures for test data |
| Not testing error responses | Test 4xx and 5xx paths alongside happy paths |
Using server.close() in afterEach | Use afterAll for close, afterEach for reset only |
| Ignoring response headers in assertions | Validate Content-Type, Cache-Control, CORS headers |
Not using onUnhandledRequest: 'error' | Catch unhandled requests to prevent silent test gaps |
| Testing implementation instead of behavior | Assert on response shape, not internal function calls |
Delegation
- Test structure review: Use
Taskagent - Code review: Delegate to
code-revieweragent - Pattern discovery: Use
Exploreagent
If the vitest-testing skill is available, delegate general Vitest configuration and patterns to it.Otherwise, recommend: npx skills add oakoss/agent-skills --skill vitest-testingReferences
- Supertest integration testing patterns for Express, Fastify, and Hono
- MSW request handlers, response resolvers, and server setup
- Test organization, fixtures, factories, and setup/teardown
- Response assertions, status codes, headers, and schema validation
Assertion Patterns
Status Code Assertions
Supertest supports chained status code checks:
await request(app).get('/api/users').expect(200);
await request(app).post('/api/users').send(data).expect(201);
await request(app).delete('/api/users/1').expect(204);
await request(app).get('/api/missing').expect(404);
await request(app).post('/api/users').send({}).expect(422);For programmatic assertions, use the response object:
const response = await request(app).get('/api/users');
expect(response.status).toBe(200);
expect(response.statusCode).toBe(200);Header Assertions
Assert exact values or patterns:
await request(app)
.get('/api/users')
.expect('Content-Type', /application\/json/)
.expect('Cache-Control', 'no-store');Check custom headers from the response object:
const response = await request(app).get('/api/items');
expect(response.headers['x-total-count']).toBe('42');
expect(response.headers['x-request-id']).toBeDefined();CORS Header Assertions
it('includes CORS headers', async () => {
const response = await request(app)
.options('/api/users')
.set('Origin', 'https://example.com')
.set('Access-Control-Request-Method', 'GET');
expect(response.headers['access-control-allow-origin']).toBe(
'https://example.com',
);
expect(response.headers['access-control-allow-methods']).toContain('GET');
});Response Body Assertions
Exact Match
await request(app)
.get('/api/config')
.expect(200)
.expect({ theme: 'dark', language: 'en' });Partial Match with expect.objectContaining
const response = await request(app).get('/api/users/1').expect(200);
expect(response.body).toEqual(
expect.objectContaining({
name: 'Alice',
email: 'alice@example.com',
}),
);Array Assertions
const response = await request(app).get('/api/users').expect(200);
expect(response.body).toHaveLength(3);
expect(response.body).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'Alice' })]),
);
expect(response.body).toSatisfy((users: { role: string }[]) =>
users.every((u) => u.role === 'user'),
);Nested Object Assertions
const response = await request(app).get('/api/users/1/profile').expect(200);
expect(response.body).toEqual({
user: expect.objectContaining({
id: expect.any(String),
name: expect.any(String),
address: expect.objectContaining({
city: expect.any(String),
country: expect.any(String),
}),
}),
metadata: expect.objectContaining({
createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}/),
}),
});Callback-Style Assertions
Supertest .expect() accepts a callback for custom logic:
await request(app)
.get('/api/users')
.expect(200)
.expect((res) => {
expect(res.body.length).toBeGreaterThan(0);
expect(res.body[0]).toHaveProperty('id');
expect(res.body[0]).toHaveProperty('name');
});Schema Validation with Zod
Define response schemas and validate against them:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.string().datetime(),
});
const UsersResponseSchema = z.array(UserSchema);
it('returns valid user data', async () => {
const response = await request(app).get('/api/users').expect(200);
const result = UsersResponseSchema.safeParse(response.body);
expect(result.success).toBe(true);
});Custom Vitest Matcher for Zod
Create a reusable matcher:
import { type ZodSchema } from 'zod';
import { expect } from 'vitest';
expect.extend({
toMatchSchema(received: unknown, schema: ZodSchema) {
const result = schema.safeParse(received);
if (result.success) {
return {
pass: true,
message: () => 'Expected data not to match schema',
};
}
return {
pass: false,
message: () =>
`Schema validation failed:\n${result.error.issues
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
.join('\n')}`,
};
},
});
declare module 'vitest' {
interface Assertion {
toMatchSchema(schema: ZodSchema): void;
}
}Usage:
it('returns valid user data', async () => {
const response = await request(app).get('/api/users').expect(200);
expect(response.body).toMatchSchema(UsersResponseSchema);
});Shared Schemas Between Source and Tests
Reuse Zod schemas from your application code to ensure test assertions match the actual API contract:
import { UserResponseSchema } from '../src/schemas/user';
it('conforms to the API contract', async () => {
const response = await request(app).get('/api/users/1').expect(200);
expect(() => UserResponseSchema.parse(response.body)).not.toThrow();
});Error Response Assertions
Standard Error Format
const ErrorResponseSchema = z.object({
error: z.string(),
message: z.string(),
statusCode: z.number(),
});
it('returns structured error for invalid input', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid' })
.expect(422);
expect(response.body).toMatchObject({
error: 'Validation Error',
statusCode: 422,
});
expect(response.body.message).toBeDefined();
});Validation Error Details
it('returns field-level validation errors', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '', email: 'bad' })
.expect(422);
expect(response.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
field: 'name',
message: expect.any(String),
}),
expect.objectContaining({
field: 'email',
message: expect.any(String),
}),
]),
);
});Pagination Assertions
it('returns paginated results', async () => {
const response = await request(app)
.get('/api/posts')
.query({ page: 2, limit: 10 })
.expect(200);
expect(response.body).toEqual(
expect.objectContaining({
data: expect.any(Array),
meta: expect.objectContaining({
page: 2,
limit: 10,
total: expect.any(Number),
totalPages: expect.any(Number),
}),
}),
);
expect(response.body.data.length).toBeLessThanOrEqual(10);
});Authentication and Authorization Assertions
describe('protected endpoints', () => {
it('returns 401 without token', async () => {
await request(app).get('/api/admin/users').expect(401);
});
it('returns 403 with insufficient permissions', async () => {
await request(app)
.get('/api/admin/users')
.set('Authorization', `Bearer ${userToken}`)
.expect(403);
});
it('returns 200 with admin token', async () => {
await request(app)
.get('/api/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
});
});Timing Assertions
it('responds within acceptable time', async () => {
const start = performance.now();
await request(app).get('/api/health').expect(200);
const duration = performance.now() - start;
expect(duration).toBeLessThan(200);
});Common Assertion Patterns Table
| What to Assert | Supertest Chain | Vitest Expect |
|---|---|---|
| Status code | .expect(200) | expect(res.status).toBe(200) |
| Content-Type | .expect('Content-Type', /json/) | expect(res.headers['content-type']).toMatch(/json/) |
| Exact body | .expect({ key: 'val' }) | expect(res.body).toEqual({ key: 'val' }) |
| Body shape | callback in .expect() | expect(res.body).toMatchObject({}) |
| Array length | callback in .expect() | expect(res.body).toHaveLength(n) |
| Property exists | callback in .expect() | expect(res.body).toHaveProperty('key') |
| Schema validation | N/A | expect(res.body).toMatchSchema(schema) |
| Header exists | .expect('X-Key', /.*/) | expect(res.headers['x-key']).toBeDefined() |
MSW Handlers
Server Setup
Create a shared MSW server for Node.js tests:
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);Wire the server into your test setup file (referenced by vitest.config.ts setupFiles):
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());The onUnhandledRequest: 'error' option throws when a request has no matching handler, preventing silent test gaps.
Vitest Configuration
Register the setup file in your Vitest config:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/mocks/setup.ts'],
},
});HTTP Handlers
MSW v2 uses the http namespace (replacing rest from v1):
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({ id, name: 'Alice' });
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
http.patch('/api/users/:id', async ({ params, request }) => {
const { id } = params;
const updates = await request.json();
return HttpResponse.json({ id, ...updates });
}),
http.delete('/api/users/:id', () => {
return new HttpResponse(null, { status: 204 });
}),
];Response Resolver Parameters
The resolver receives a single object with these properties:
http.post('/api/items', async ({ request, params, cookies }) => {
const url = new URL(request.url);
const page = url.searchParams.get('page');
const body = await request.json();
const sessionId = cookies.session_id;
return HttpResponse.json({ created: true });
});| Property | Type | Description |
|---|---|---|
request | Request | Standard Fetch API Request object |
params | object | Path parameters from :param syntax |
cookies | object | Parsed request cookies |
GraphQL Handlers
Intercept GraphQL operations by name:
import { graphql, HttpResponse } from 'msw';
export const graphqlHandlers = [
graphql.query('GetUsers', () => {
return HttpResponse.json({
data: {
users: [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
],
},
});
}),
graphql.mutation('CreateUser', ({ variables }) => {
const { name } = variables;
return HttpResponse.json({
data: {
createUser: { id: '3', name },
},
});
}),
];Scope handlers to specific GraphQL endpoints with graphql.link():
const github = graphql.link('https://api.github.com/graphql');
const stripe = graphql.link('https://api.stripe.com/graphql');
export const handlers = [
github.query('GetRepos', () => {
return HttpResponse.json({
data: { repos: [{ name: 'my-repo' }] },
});
}),
stripe.query('GetCharges', () => {
return HttpResponse.json({
data: { charges: [] },
});
}),
];Per-Test Handler Overrides
Override default handlers for specific test scenarios:
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
it('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 },
);
}),
);
const response = await fetch('/api/users');
expect(response.status).toBe(500);
});Overrides are automatically cleared by server.resetHandlers() in afterEach.
One-Time Handlers
Handlers that auto-remove after the first match:
server.use(
http.get(
'/api/users',
() => {
return HttpResponse.json({ users: [] }, { status: 200 });
},
{ once: true },
),
);Useful for testing retry logic where the first request fails but the second succeeds:
it('retries on failure', async () => {
server.use(
http.get(
'/api/data',
() => {
return HttpResponse.error();
},
{ once: true },
),
);
// First request triggers the one-time error handler
// Second request falls through to the default success handler
});Network Error Simulation
http.get('/api/data', () => {
return HttpResponse.error();
});HttpResponse.error() simulates a network-level failure (connection refused, DNS failure). The request never completes.
Response Delay
import { http, HttpResponse, delay } from 'msw';
http.get('/api/slow', async () => {
await delay(2000);
return HttpResponse.json({ data: 'slow response' });
});
// Use 'infinite' delay for timeout testing
http.get('/api/timeout', async () => {
await delay('infinite');
return HttpResponse.json({ data: 'never reached' });
});Custom Response Headers
http.get('/api/data', () => {
return HttpResponse.json(
{ items: [] },
{
headers: {
'X-Total-Count': '42',
'Cache-Control': 'no-cache',
},
},
);
});TypeScript Type Safety
Use generic arguments for type-safe handlers:
import { http, HttpResponse } from 'msw';
type UserParams = { id: string };
type CreateUserBody = { name: string; email: string };
type UserResponse = { id: string; name: string; email: string };
export const handlers = [
http.get<UserParams, never, UserResponse>('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice',
email: 'alice@example.com',
});
}),
http.post<never, CreateUserBody, UserResponse>(
'/api/users',
async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
id: '1',
name: body.name,
email: body.email,
});
},
),
];Handler Organization
Structure handlers by resource for maintainability:
import { userHandlers } from './handlers/users';
import { postHandlers } from './handlers/posts';
import { authHandlers } from './handlers/auth';
export const handlers = [...authHandlers, ...userHandlers, ...postHandlers];Keep default handlers representing the happy path. Override with server.use() for error and edge-case tests.
Life-Cycle Events
Observe network traffic without affecting responses:
server.events.on('request:start', ({ request }) => {
console.log('Outgoing:', request.method, request.url);
});
server.events.on('response:mocked', ({ request, response }) => {
console.log('Mocked:', request.url, response.status);
});Useful for debugging which requests are intercepted during test runs.
Supertest Patterns
Express Integration
Supertest accepts an Express app instance directly. No need to call app.listen() in tests:
import request from 'supertest';
import { createApp } from '../src/app';
const app = createApp();
describe('GET /api/users', () => {
it('returns a list of users', async () => {
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
name: expect.any(String),
}),
]),
);
});
});Export a factory function (createApp) rather than a singleton to ensure test isolation.
Fastify Integration
Fastify provides a built-in inject method that avoids starting a real server. Prefer inject over supertest when possible:
import Fastify from 'fastify';
import { userRoutes } from '../src/routes/users';
describe('GET /api/users', () => {
const app = Fastify();
beforeAll(async () => {
app.register(userRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
});
it('returns users', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/users',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(String) }),
]),
);
});
});If you must use supertest with Fastify, pass fastify.server (not the Fastify instance):
import request from 'supertest';
beforeAll(async () => {
await app.ready();
});
it('works with supertest', async () => {
await request(app.server).get('/api/users').expect(200);
});Hono Integration
Hono provides app.request() and a type-safe testClient:
import { Hono } from 'hono';
import { testClient } from 'hono/testing';
const app = new Hono()
.get('/api/users', (c) => c.json([{ id: '1', name: 'Alice' }]))
.post('/api/users', async (c) => {
const body = await c.req.json();
return c.json({ id: '2', ...body }, 201);
});
describe('User API', () => {
const client = testClient(app);
it('lists users', async () => {
const response = await client.api.users.$get();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
});
it('creates a user', async () => {
const response = await client.api.users.$post({
json: { name: 'Bob' },
});
expect(response.status).toBe(201);
});
});Chain route definitions on the Hono instance so testClient infers types correctly.
Request Chaining
Supertest supports fluent chaining for building requests:
it('creates a resource with authentication', async () => {
const response = await request(app)
.post('/api/posts')
.set('Authorization', `Bearer ${token}`)
.set('Accept', 'application/json')
.send({ title: 'New Post', content: 'Body text' })
.expect('Content-Type', /json/)
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.title).toBe('New Post');
});Order does not matter for .set() and .send(), but .expect() assertions run after the request completes.
Cookie and Session Persistence
Use request.agent() to maintain cookies across requests:
describe('authenticated flow', () => {
const agent = request.agent(app);
it('logs in and accesses protected resource', async () => {
await agent
.post('/api/login')
.send({ email: 'user@example.com', password: 'password' })
.expect(200);
await agent
.get('/api/profile')
.expect(200)
.expect((res) => {
expect(res.body.email).toBe('user@example.com');
});
});
});Query Parameters
it('filters by status', async () => {
await request(app)
.get('/api/orders')
.query({ status: 'pending', page: 1, limit: 10 })
.expect(200)
.expect((res) => {
expect(res.body.data).toSatisfy((items: unknown[]) =>
items.every((item: any) => item.status === 'pending'),
);
});
});File Uploads
it('uploads an avatar image', async () => {
await request(app)
.post('/api/users/1/avatar')
.set('Authorization', `Bearer ${token}`)
.attach('avatar', Buffer.from('fake-image'), 'avatar.png')
.expect(200)
.expect((res) => {
expect(res.body.avatarUrl).toMatch(/\.png$/);
});
});Testing Different HTTP Methods
describe('REST operations', () => {
let createdId: string;
it('POST creates resource', async () => {
const res = await request(app)
.post('/api/items')
.send({ name: 'Widget' })
.expect(201);
createdId = res.body.id;
});
it('GET retrieves resource', async () => {
await request(app)
.get(`/api/items/${createdId}`)
.expect(200)
.expect((res) => {
expect(res.body.name).toBe('Widget');
});
});
it('PATCH updates resource', async () => {
await request(app)
.patch(`/api/items/${createdId}`)
.send({ name: 'Updated Widget' })
.expect(200);
});
it('DELETE removes resource', async () => {
await request(app).delete(`/api/items/${createdId}`).expect(204);
});
});Testing Middleware
Test middleware effects through endpoints rather than testing middleware in isolation:
describe('rate limiting middleware', () => {
it('allows requests under the limit', async () => {
await request(app).get('/api/data').expect(200);
});
it('returns 429 when rate limit exceeded', async () => {
const requests = Array.from({ length: 101 }, () =>
request(app).get('/api/data'),
);
const responses = await Promise.all(requests);
const tooMany = responses.filter((r) => r.status === 429);
expect(tooMany.length).toBeGreaterThan(0);
});
});Error Response Testing
it('returns 404 for missing resource', async () => {
await request(app)
.get('/api/items/nonexistent')
.expect(404)
.expect((res) => {
expect(res.body).toEqual({
error: 'Not Found',
message: expect.any(String),
});
});
});
it('returns 422 for invalid input', async () => {
await request(app)
.post('/api/items')
.send({ name: '' })
.expect(422)
.expect((res) => {
expect(res.body.errors).toBeDefined();
});
});Common Patterns Table
| Pattern | Supertest | Fastify inject | Hono testClient |
|---|---|---|---|
| GET request | request(app).get(url) | app.inject({ method: 'GET', url }) | client.path.$get() |
| POST with JSON | .post(url).send(body) | inject({ method: 'POST', payload }) | client.path.$post({ json }) |
| Set header | .set('Key', 'value') | inject({ headers: { key: val } }) | Pass in request init |
| Assert status | .expect(200) | expect(res.statusCode).toBe(200) | expect(res.status).toBe(200) |
| Assert JSON | .expect({ key: 'val' }) | expect(res.json()) | await res.json() |
| Cookie persistence | request.agent(app) | Manual cookie forwarding | Manual cookie forwarding |
Test Organization
Directory Structure
Organize API tests alongside or mirroring the source structure:
src/
routes/
users.ts
posts.ts
tests/
setup.ts # Global test setup (MSW server, DB connection)
helpers/
request.ts # Shared supertest helpers
factories.ts # Test data factories
fixtures.ts # Static test data
api/
users.test.ts # Tests for /api/users
posts.test.ts # Tests for /api/posts
mocks/
handlers/
users.ts # MSW handlers for user endpoints
posts.ts # MSW handlers for post endpoints
handlers.ts # Combined handler exports
server.ts # MSW server instanceGlobal Test Setup
Create a setup file that initializes shared resources:
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
beforeAll(async () => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(async () => {
server.close();
});Register in vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./tests/setup.ts'],
testTimeout: 10_000,
},
});Test Data Factories
Factories generate test data with sensible defaults and allow overrides:
type User = {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: string;
};
let counter = 0;
function createUser(overrides: Partial<User> = {}): User {
counter += 1;
return {
id: `user-${counter}`,
name: `User ${counter}`,
email: `user${counter}@example.com`,
role: 'user',
createdAt: new Date().toISOString(),
...overrides,
};
}
function createUsers(count: number, overrides: Partial<User> = {}): User[] {
return Array.from({ length: count }, () => createUser(overrides));
}Usage in tests:
it('returns admin users only', async () => {
const admin = createUser({ role: 'admin' });
const regular = createUser({ role: 'user' });
server.use(
http.get('/api/users', ({ request }) => {
const url = new URL(request.url);
const role = url.searchParams.get('role');
const users = [admin, regular].filter((u) => !role || u.role === role);
return HttpResponse.json(users);
}),
);
const response = await request(app)
.get('/api/users')
.query({ role: 'admin' })
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].role).toBe('admin');
});Static Fixtures
Use fixtures for stable test data that does not change between tests:
export const fixtures = {
validUser: {
name: 'Alice Smith',
email: 'alice@example.com',
},
invalidUser: {
name: '',
email: 'not-an-email',
},
validPost: {
title: 'Test Post',
content: 'This is a test post with sufficient content.',
},
} as const;Prefer factories for data that needs uniqueness. Use fixtures for validation test cases and error scenarios.
Shared Request Helpers
Reduce boilerplate with helper functions:
import request from 'supertest';
import { type Express } from 'express';
export function createAuthenticatedAgent(app: Express, token: string) {
const agent = request(app);
const originalGet = agent.get.bind(agent);
const originalPost = agent.post.bind(agent);
return {
get: (url: string) =>
originalGet(url).set('Authorization', `Bearer ${token}`),
post: (url: string) =>
originalPost(url).set('Authorization', `Bearer ${token}`),
};
}
export async function loginAs(
app: Express,
credentials: { email: string; password: string },
) {
const response = await request(app).post('/api/auth/login').send(credentials);
return response.body.token as string;
}Database Setup and Teardown
For tests that hit a real database, isolate state between tests:
import { beforeEach, afterAll } from 'vitest';
import { db } from '../src/db';
import { migrate } from '../src/db/migrate';
beforeEach(async () => {
await db.execute('BEGIN');
});
afterEach(async () => {
await db.execute('ROLLBACK');
});
afterAll(async () => {
await db.close();
});Transaction rollback ensures each test starts with a clean database state without re-seeding.
In-Memory Database Alternative
For MongoDB, use mongodb-memory-server to avoid external dependencies:
import { MongoMemoryServer } from 'mongodb-memory-server';
import mongoose from 'mongoose';
let mongoServer: MongoMemoryServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterEach(async () => {
const collections = await mongoose.connection.db.collections();
for (const collection of collections) {
await collection.deleteMany({});
}
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});Test Isolation Strategies
| Strategy | Pros | Cons | Best For |
|---|---|---|---|
| Transaction rollback | Fast, reliable | Requires transaction support | SQL databases |
| Truncate tables | Works with any DB | Slower than rollback | NoSQL or cross-table relations |
| In-memory DB | No external dependencies | May differ from production | CI pipelines, rapid iteration |
| MSW mocking | No database needed | Does not test real DB queries | API contract testing |
| Docker test DB | Production-identical | Slower startup | Integration test suites |
Grouping Related Tests
Use describe blocks to group by endpoint and operation:
describe('/api/users', () => {
describe('GET /', () => {
it('returns all users', async () => {
/* ... */
});
it('filters by role', async () => {
/* ... */
});
it('paginates results', async () => {
/* ... */
});
});
describe('POST /', () => {
it('creates a user with valid data', async () => {
/* ... */
});
it('rejects invalid email', async () => {
/* ... */
});
it('rejects duplicate email', async () => {
/* ... */
});
});
describe('GET /:id', () => {
it('returns the user', async () => {
/* ... */
});
it('returns 404 for unknown id', async () => {
/* ... */
});
});
describe('DELETE /:id', () => {
it('requires authentication', async () => {
/* ... */
});
it('requires admin role', async () => {
/* ... */
});
it('deletes the user', async () => {
/* ... */
});
});
});Parallel vs Sequential Tests
Vitest runs test files in parallel by default. For API tests with shared state:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Run API test files sequentially if they share a database
fileParallelism: false,
// Or use pool threads with single thread
pool: 'forks',
poolOptions: {
forks: { singleFork: true },
},
},
});Tests within a single file run sequentially by default. Use concurrent for independent tests:
describe('read-only endpoints', () => {
it.concurrent('GET /api/users returns 200', async () => {
/* ... */
});
it.concurrent('GET /api/posts returns 200', async () => {
/* ... */
});
it.concurrent('GET /api/tags returns 200', async () => {
/* ... */
});
});Environment Variables
Use Vitest env configuration to set test-specific variables:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
env: {
DATABASE_URL: 'postgresql://localhost:5432/test_db',
API_SECRET: 'test-secret',
NODE_ENV: 'test',
},
},
});Avoid loading .env files in tests. Explicit configuration prevents accidental use of production credentials.