API / Backend Testing with Supertest
Core Principles
- •Test the HTTP interface, not internal functions. Hit real endpoints with supertest.
- •Isolate tests with per-test database transactions or truncation.
- •Mock only external services (Stripe, SendGrid), never your own database layer.
Basic Endpoint Testing
ts
import request from 'supertest';
import { describe, it, expect } from 'vitest';
import { app } from '../app';
describe('GET /api/users', () => {
it('returns 200 with a list of users', async () => {
const res = await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${testToken}`)
.expect(200);
expect(res.body).toEqual({
success: true,
data: expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), email: expect.any(String) }),
]),
});
});
it('returns 401 without auth token', async () => {
await request(app).get('/api/users').expect(401);
});
});
describe('POST /api/users', () => {
it('creates a user and returns 201', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken}`)
.send({ email: 'new@example.com', name: 'New User' })
.expect(201);
expect(res.body.data).toMatchObject({
email: 'new@example.com',
name: 'New User',
});
});
it('returns 400 for invalid payload', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken}`)
.send({ email: 'not-an-email' })
.expect(400);
expect(res.body.success).toBe(false);
expect(res.body.errors).toBeDefined();
});
});
Database Setup and Teardown
ts
import { db } from '../db';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
beforeAll(async () => {
await migrate(db, { migrationsFolder: './drizzle' });
});
beforeEach(async () => {
// Truncate all tables before each test
await db.execute(sql`TRUNCATE users, projects, tasks RESTART IDENTITY CASCADE`);
// Seed required baseline data
await db.insert(users).values({ email: 'admin@test.com', role: 'admin' });
});
afterAll(async () => {
await db.$client.end();
});
Transaction-Based Isolation (Alternative)
ts
import { db } from '../db';
let tx: Transaction;
beforeEach(async () => {
tx = await db.transaction();
// Replace db reference in app with transaction
app.locals.db = tx;
});
afterEach(async () => {
await tx.rollback();
});
Middleware Testing
ts
describe('auth middleware', () => {
it('attaches user to request on valid token', async () => {
const token = signTestToken({ userId: 1, role: 'user' });
const res = await request(app)
.get('/api/me')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body.data.id).toBe(1);
});
it('rejects expired tokens', async () => {
const token = signTestToken({ userId: 1 }, { expiresIn: '-1h' });
await request(app)
.get('/api/me')
.set('Authorization', `Bearer ${token}`)
.expect(401);
});
});
describe('rate limiter', () => {
it('blocks after exceeding limit', async () => {
for (let i = 0; i < 100; i++) {
await request(app).get('/api/public');
}
await request(app).get('/api/public').expect(429);
});
});
Mocking External Services
ts
import { vi } from 'vitest';
import * as stripeService from '../services/stripe';
vi.mock('../services/stripe', () => ({
createCustomer: vi.fn().mockResolvedValue({ id: 'cus_test123' }),
createSubscription: vi.fn().mockResolvedValue({ id: 'sub_test123', status: 'active' }),
}));
it('creates subscription on signup', async () => {
const res = await request(app)
.post('/api/subscribe')
.set('Authorization', `Bearer ${testToken}`)
.send({ plan: 'pro' })
.expect(200);
expect(stripeService.createSubscription).toHaveBeenCalledWith(
expect.objectContaining({ plan: 'pro' })
);
});
Test Helpers
ts
// test/helpers.ts
export function authHeader(role: 'admin' | 'user' = 'user') {
const token = signTestToken({ userId: 1, role });
return { Authorization: `Bearer ${token}` };
}
export function expectPaginated(body: any) {
expect(body).toMatchObject({
success: true,
data: expect.any(Array),
meta: { page: expect.any(Number), limit: expect.any(Number), total: expect.any(Number) },
});
}
Anti-Patterns
- •NEVER import and call controller functions directly. Always go through supertest.
- •NEVER share mutable state between tests without resetting it in beforeEach.
- •NEVER leave test data in the database after tests complete.
- •NEVER mock your own database layer in integration tests; use a real test database.
- •NEVER hardcode ports; let supertest bind to a random available port.