Integration Testing Your Node.js API: The Setup That Actually Catches Bugs
Vitest, Supertest, transaction-based test isolation, and the factory pattern that makes test data effortless
Senior Developer

Unit tests are fast and easy to write. They are also very good at telling you your functions work in isolation while your API is broken in production.
The test that would have caught the bug is usually an integration test — one that hits a real endpoint, talks to a real database, and verifies the entire request/response cycle. These are the tests that validate what unit tests cannot: routing logic, middleware execution order, request validation, authentication checks, error response shapes, and database interactions that only fail under real data constraints.
This guide builds a production-grade integration test setup using Vitest and Supertest, with a real Postgres database, proper test isolation, and patterns that scale to a large test suite without becoming slow or brittle.
Why Vitest Over Jest in 2026
Integration tests verify that multiple modules or services work together correctly. For most Node.js backends in 2026, Vitest is the better testing framework choice for new projects:
Native ESM support without configuration gymnastics
TypeScript support out of the box — no
ts-jestorbabel-jestFaster than Jest on cold starts due to Vite's dependency pre-bundling
Jest-compatible API —
describe,it,expect,vi.mock— minimal migration frictionWatch mode that re-runs only affected tests
If your project already uses Jest, the patterns here apply equally — swap vi for jest and vitest imports for @jest/globals.
Setup
npm install -D vitest supertest @types/supertest// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // No need to import describe/it/expect
environment: 'node',
setupFiles: ['./src/tests/setup.ts'],
testTimeout: 30_000, // Integration tests can be slow
pool: 'forks', // Separate process per test file — true isolation
poolOptions: {
forks: { singleFork: true }, // One process for all tests — shared DB connection
},
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
exclude: ['**/node_modules/**', '**/tests/**'],
},
},
});The Test Database Setup
The most important decision in integration testing is how you manage test data. The wrong approach (sharing state between tests) produces flaky tests that pass in isolation and fail in a suite.
The right approach: each test runs in a transaction that is rolled back at the end. No cleanup code, no seed/teardown overhead, complete isolation.
// src/tests/setup.ts
import { Pool } from 'pg';
import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
// Separate test database — never run tests against production
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL
|| 'postgresql://testuser:testpass@localhost:5432/testdb';
export const testPool = new Pool({ connectionString: TEST_DATABASE_URL });
// Shared client — holds the transaction open across the test
let testClient: any;
beforeAll(async () => {
// Run migrations on the test database before any tests
// Use your migration tool of choice
await runMigrations(TEST_DATABASE_URL);
});
beforeEach(async () => {
testClient = await testPool.connect();
// Start a transaction — all queries in this test run inside it
await testClient.query('BEGIN');
});
afterEach(async () => {
// Rollback — undoes every INSERT/UPDATE/DELETE from the test
await testClient.query('ROLLBACK');
testClient.release();
});
afterAll(async () => {
await testPool.end();
});
// Export so tests can use the same transactional client
export { testClient };For this to work, your app needs to use the test client during tests. Inject the database via a module or environment variable:
// src/lib/db.ts
import { Pool } from 'pg';
let _pool: Pool | null = null;
export function getPool(): Pool {
if (!_pool) {
_pool = new Pool({ connectionString: process.env.DATABASE_URL });
}
return _pool;
}
// Allow tests to inject a transactional client
export function setPool(pool: Pool) {
_pool = pool;
}Building the Test App
Supertest needs your Express app instance — not a running server. Separate your app definition from the server startup:
// src/app.ts — just the Express app, no listen()
import express from 'express';
import { requestLogger } from './middleware/requestLogger';
import { errorHandler } from './middleware/errorHandler';
import router from './routes';
const app = express();
app.use(express.json());
app.use(requestLogger);
app.use('/api', router);
app.use(errorHandler);
export default app;
// src/server.ts — starts the actual server
import app from './app';
app.listen(3000);// src/tests/helpers/testApp.ts
import supertest from 'supertest';
import app from '../../app';
import { setPool } from '../../lib/db';
import { testPool, testClient } from '../setup';
export function createTestApp() {
// Inject the transactional test client
setPool(testClient);
return supertest(app);
}Writing Integration Tests
Authentication Routes
// src/tests/integration/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestApp } from '../helpers/testApp';
import { createTestUser, createTestTenant } from '../helpers/factories';
describe('POST /api/auth/login', () => {
let request: ReturnType<typeof createTestApp>;
beforeEach(() => {
request = createTestApp();
});
it('returns 200 and tokens with valid credentials', async () => {
const { user, tenant } = await createTestUser({
email: 'test@example.com',
password: 'ValidPassword123!',
});
const response = await request
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'ValidPassword123!' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
accessToken: expect.any(String),
user: {
id: user.id,
email: 'test@example.com',
},
});
// Refresh token should be in httpOnly cookie, not body
expect(response.body.refreshToken).toBeUndefined();
expect(response.headers['set-cookie']).toBeDefined();
});
it('returns 401 with wrong password', async () => {
await createTestUser({ email: 'test@example.com', password: 'correct' });
const response = await request
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'wrong' });
expect(response.status).toBe(401);
expect(response.body.error.code).toBe('AUTHENTICATION_ERROR');
});
it('returns 401 for non-existent email', async () => {
const response = await request
.post('/api/auth/login')
.send({ email: 'nobody@example.com', password: 'anything' });
expect(response.status).toBe(401);
// Same error as wrong password — do not reveal whether email exists
expect(response.body.error.code).toBe('AUTHENTICATION_ERROR');
});
it('returns 400 when email is missing', async () => {
const response = await request
.post('/api/auth/login')
.send({ password: 'ValidPassword123!' });
expect(response.status).toBe(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
it('rate limits after 5 failed attempts', async () => {
await createTestUser({ email: 'test@example.com', password: 'correct' });
// Make 5 failed attempts
for (let i = 0; i < 5; i++) {
await request
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'wrong' });
}
// 6th attempt should be rate limited
const response = await request
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'wrong' });
expect(response.status).toBe(429);
});
});Protected Routes
// src/tests/helpers/auth.ts
import supertest from 'supertest';
import { createTestUser } from './factories';
import app from '../../app';
export async function getAuthenticatedRequest(overrides = {}) {
const request = supertest(app);
const { user, tenant } = await createTestUser(overrides);
const loginResponse = await request
.post('/api/auth/login')
.send({ email: user.email, password: 'TestPassword123!' });
const { accessToken } = loginResponse.body;
// Return a request builder pre-configured with auth header
return {
request,
user,
tenant,
authHeader: `Bearer ${accessToken}`,
get: (url: string) => request.get(url).set('Authorization', `Bearer ${accessToken}`),
post: (url: string) => request.post(url).set('Authorization', `Bearer ${accessToken}`),
put: (url: string) => request.put(url).set('Authorization', `Bearer ${accessToken}`),
delete: (url: string) => request.delete(url).set('Authorization', `Bearer ${accessToken}`),
};
}// src/tests/integration/orders.test.ts
import { describe, it, expect } from 'vitest';
import { getAuthenticatedRequest } from '../helpers/auth';
import { createTestProduct } from '../helpers/factories';
describe('POST /api/orders', () => {
it('creates an order and returns 201', async () => {
const { post, user, tenant } = await getAuthenticatedRequest();
const product = await createTestProduct({ tenantId: tenant.id, stock: 10, price: 29.99 });
const response = await post('/api/orders').send({
items: [{ productId: product.id, quantity: 2 }],
shippingAddress: {
line1: '123 Main St',
city: 'San Francisco',
state: 'CA',
postcode: '94105',
country: 'US',
},
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
total: '59.98',
});
});
it('returns 422 when product is out of stock', async () => {
const { post, tenant } = await getAuthenticatedRequest();
const product = await createTestProduct({ tenantId: tenant.id, stock: 0 });
const response = await post('/api/orders').send({
items: [{ productId: product.id, quantity: 1 }],
});
expect(response.status).toBe(422);
expect(response.body.error.code).toBe('UNPROCESSABLE');
});
it('returns 401 without auth token', async () => {
const request = supertest(app);
const response = await request.post('/api/orders').send({});
expect(response.status).toBe(401);
});
});Test Factories
// src/tests/helpers/factories.ts
import bcrypt from 'bcryptjs';
import { testClient } from '../setup';
import { randomUUID } from 'crypto';
export async function createTestTenant(overrides = {}) {
const tenant = {
id: randomUUID(),
slug: `test-${randomUUID().slice(0, 8)}`,
name: 'Test Tenant',
plan: 'pro',
...overrides,
};
await testClient.query(
`INSERT INTO tenants (id, slug, name, plan) VALUES ($1, $2, $3, $4)`,
[tenant.id, tenant.slug, tenant.name, tenant.plan]
);
return tenant;
}
export async function createTestUser(overrides: any = {}) {
const tenant = overrides.tenant || await createTestTenant();
const passwordHash = await bcrypt.hash(overrides.password || 'TestPassword123!', 10);
const user = {
id: randomUUID(),
tenantId: tenant.id,
email: overrides.email || `user-${randomUUID().slice(0, 8)}@test.com`,
fullName: overrides.fullName || 'Test User',
role: overrides.role || 'member',
};
await testClient.query(
`INSERT INTO users (id, tenant_id, email, full_name, role, password_hash)
VALUES ($1, $2, $3, $4, $5, $6)`,
[user.id, user.tenantId, user.email, user.fullName, user.role, passwordHash]
);
return { user, tenant };
}
export async function createTestProduct(overrides: any = {}) {
const product = {
id: randomUUID(),
tenantId: overrides.tenantId,
name: overrides.name || 'Test Product',
price: overrides.price || 9.99,
stock: overrides.stock ?? 100,
active: true,
};
await testClient.query(
`INSERT INTO products (id, tenant_id, name, price, stock, active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[product.id, product.tenantId, product.name, product.price, product.stock, product.active]
);
return product;
}Running Tests in CI
# .github/workflows/ci.yml (test job)
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Run tests
run: npm test
env:
TEST_DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
NODE_ENV: test
JWT_PRIVATE_KEY: ${{ secrets.TEST_JWT_PRIVATE_KEY }}What to Test and What Not To
Testing depth should match business risk, not trend pressure. Unit tests do not validate routing, middleware, serialization, and error envelope behavior.
Write integration tests for:
Authentication flows end-to-end
Authorization — verify users cannot access other tenants' data
Request validation — confirm invalid inputs return correct error shapes
Business logic with database side effects
Error response shapes — confirm your error handler works as designed
Rate limiting behavior
Do not write integration tests for:
Pure utility functions (test these as unit tests — faster)
Third-party API calls (mock at the HTTP boundary with
mswornock)Things already covered by your ORM or framework
The rule: if tests pass while real integration repeatedly fails, mocking is likely hiding boundary problems. Mock at external seams, not core behavior.
Comments (0)
Login to post a comment.