Supertest API Integration Testing with TypeScript — 2026 Guide
Advertisement
Introduction
Why Integration Tests Matter
Unit tests verify logic in isolation. Integration tests verify that your routes, middleware, validation, business logic, and database work together correctly. Supertest fires real HTTP requests against your Express app in-process — no running server required — making integration tests fast and reliable.
A well-tested API has: unit tests for business logic, integration tests for HTTP layer, and E2E tests for critical user flows.
Installation
npm install --save-dev supertest @types/supertest vitest @vitest/coverage-v8App Setup (Testable Pattern)
The key to testable Express apps is exporting the app object separately from listen():
// src/app.ts — export app without starting server
import express from 'express';
import { router } from './routes';
export const app = express();
app.use(express.json());
app.use('/api', router);
// Global error handler
app.use((err: Error, _req: any, res: any, _next: any) => {
console.error(err);
res.status(500).json({ error: err.message });
});
// src/server.ts — start server separately
import { app } from './app';
app.listen(3000, () => console.log('Server running'));Writing Integration Tests
// src/routes/users.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import supertest from 'supertest';
import { app } from '../app';
import { db } from '../db';
const request = supertest(app);
describe('Users API', () => {
beforeAll(async () => {
await db.migrate.latest(); // run migrations
await db.seed.run(); // seed test data
});
afterAll(async () => {
await db.destroy(); // close connection pool
});
beforeEach(async () => {
await db('users').truncate(); // fresh state per test
});
describe('GET /api/users', () => {
it('returns empty array when no users', async () => {
const res = await request.get('/api/users').expect(200);
expect(res.body).toEqual({ data: [], meta: { total: 0, page: 1 } });
});
it('returns paginated users', async () => {
await db('users').insert([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
]);
const res = await request
.get('/api/users')
.query({ page: 1, limit: 10 })
.expect(200);
expect(res.body.data).toHaveLength(2);
expect(res.body.meta.total).toBe(2);
});
});
describe('POST /api/users', () => {
it('creates a user with valid data', async () => {
const res = await request
.post('/api/users')
.send({ name: 'Alice', email: 'alice@example.com', password: 'secure123' })
.expect(201);
expect(res.body.user).toMatchObject({ name: 'Alice', email: 'alice@example.com' });
expect(res.body.user.id).toBeDefined();
expect(res.body.user.password).toBeUndefined(); // password not exposed
});
it('returns 422 for invalid email', async () => {
const res = await request
.post('/api/users')
.send({ name: 'Alice', email: 'not-an-email', password: 'secure123' })
.expect(422);
expect(res.body.errors.email).toBeDefined();
});
it('returns 409 for duplicate email', async () => {
await db('users').insert({ name: 'Alice', email: 'alice@example.com' });
await request
.post('/api/users')
.send({ name: 'Bob', email: 'alice@example.com', password: 'secure123' })
.expect(409);
});
});
});Testing Authenticated Routes
import jwt from 'jsonwebtoken';
function authToken(userId: number, role = 'user'): string {
return jwt.sign(
{ userId, role },
process.env.JWT_SECRET ?? 'test-secret',
{ expiresIn: '1h' }
);
}
describe('Protected routes', () => {
it('returns 401 without token', async () => {
await request.get('/api/profile').expect(401);
});
it('returns profile for authenticated user', async () => {
const [userId] = await db('users').insert({ name: 'Alice', email: 'a@b.com' });
const token = authToken(userId);
const res = await request
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body.user.email).toBe('a@b.com');
});
it('returns 403 for non-admin on admin route', async () => {
const token = authToken(1, 'user');
await request
.delete('/api/admin/users/1')
.set('Authorization', `Bearer ${token}`)
.expect(403);
});
});Testing File Uploads
import path from 'path';
describe('File upload', () => {
it('accepts valid image upload', async () => {
const res = await request
.post('/api/upload')
.set('Authorization', `Bearer ${authToken(1)}`)
.attach('file', path.join(__dirname, 'fixtures/test-image.jpg'))
.expect(200);
expect(res.body.url).toMatch(/https?:\/\//);
});
it('rejects files over 5MB', async () => {
await request
.post('/api/upload')
.attach('file', path.join(__dirname, 'fixtures/large-file.pdf'))
.expect(413);
});
});Test Database Strategy
// Option 1 — SQLite in-memory for fast tests
import Database from 'better-sqlite3';
const testDb = new Database(':memory:');
// Option 2 — PostgreSQL test database with transactions
// Start transaction before each test, rollback after
// Prevents data from persisting between tests without truncation
// Option 3 — Test containers (recommended for production parity)
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container: any;
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
process.env.DATABASE_URL = container.getConnectionUri();
await runMigrations();
});
afterAll(async () => {
await container.stop();
});Common Mistakes
- Importing
appafter callingapp.listen()— starts a real server during tests, causing port conflicts - Not resetting database state between tests — test A's data leaks into test B, causing flaky failures
- Using
process.env.NODE_ENVchecks that skip middleware in test env — tests miss behavior that exists in production - Testing a new
appinstance per test file without a shared connection — exhausts database connection pools - Writing assertions only on status codes — also assert response body shape for meaningful test coverage
Best Practices
- Export
appseparately fromlisten()so Supertest can attach without binding a port - Use a dedicated test database or in-memory SQLite — never run integration tests against production
- Reset (truncate/rollback) database tables in
beforeEach, notafterAll— ensures clean state - Generate JWT tokens inline using the same secret as the app — do not hardcode fixture tokens
- Run integration tests in CI against a real database (Docker/test containers) for production parity
Key Takeaways
- Supertest makes HTTP requests to your Express app in-process — no running server or random ports
- Export
appfromapp.tsandlisten()fromserver.ts— the golden rule for testable Express apps - Integration tests verify the full stack: routing, middleware, validation, business logic, and database
- Use
beforeEachtruncation for test isolation — notafterAllcleanup which can miss failures - Generate real JWT tokens in tests using
jsonwebtoken.sign()— avoid hardcoded fixture tokens - Supertest chains
.set(),.send(),.query(),.attach()for full request control - Test all response shapes, not just status codes — verify error bodies contain
errors.fieldName - Use Testcontainers for PostgreSQL/MySQL in CI to achieve production-parity database testing
Advertisement
Related reading
Node.js Built-in Test Runner — Ditch Jest and Vitest for Zero-Dependency Testing6 min readNode.js API Best Practices 2026 — Build Production-Ready REST APIs5 min readPrisma ORM Guide 2026 — Type-Safe Database Access with PostgreSQL5 min readAPI-First Development in 2026 — Design, Mock, Validate, Then Build6 min readbetter-auth — The Open-Source Auth Library That Replaces NextAuth6 min readData Corruption from Bad Serialization — When Your Data Silently Changes6 min read