Skip to content

Integration Testing

Testing real boundaries — database queries, HTTP handlers, message queues — without mocking what matters.

Real-World Analogy

Testing a new employee’s first day working with real coworkers, not role-playing it in a training room. The training (unit tests) teaches the job; the real first day reveals whether the knowledge translates — whether they can actually use the filing system, talk to the right people, follow the actual workflows.

Why Integration Tests Need Real Dependencies

// This test will always pass even if your SQL is wrong
const mockDb = { query: jest.fn().mockResolvedValue({ rows: [] }) };
await createUser(mockDb, { email: 'test@example.com' });
// mockDb.query was called — but did the INSERT work?
// Does the email uniqueness constraint fire? No.
// Does the trigger that creates a profile row run? No.
// You tested that your code calls query(). That's it.

// A real test DB catches:
// - Syntax errors in SQL
// - Wrong column names
// - Constraint violations
// - Missing indexes causing slow queries
// - Trigger side effects
// - Transaction behavior

Database Integration Tests

Use a real database. Roll back after each test:

// src/test/setup.ts
import { Pool } from 'pg';
import { runMigrations } from '../db/migrate';

export const testDb = new Pool({
	connectionString: process.env.DATABASE_URL ?? 'postgres://test:test@localhost:5432/testdb'
});

// Run migrations once before all tests
beforeAll(async () => {
	await runMigrations(testDb);
});

// Clean up after all tests
afterAll(async () => {
	await testDb.end();
});
// src/users/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { testDb } from '../test/setup';
import { createUser, getUserByEmail } from './users';

describe('users integration', () => {
	beforeEach(async () => {
		await testDb.query('BEGIN');
	});

	afterEach(async () => {
		await testDb.query('ROLLBACK');
	});

	it('creates a user and retrieves by email', async () => {
		const email = 'fatima@example.com';

		const user = await createUser(testDb, { email, name: 'Fatima' });

		expect(user.id).toBeDefined();
		expect(user.email).toBe(email);

		const found = await getUserByEmail(testDb, email);
		expect(found?.id).toBe(user.id);
	});

	it('enforces unique email constraint', async () => {
		await createUser(testDb, { email: 'fatima@example.com', name: 'Fatima' });

		await expect(
			createUser(testDb, { email: 'fatima@example.com', name: 'Other' })
		).rejects.toThrow(/unique/i);
	});

	it('returns null for nonexistent email', async () => {
		const result = await getUserByEmail(testDb, 'nobody@example.com');
		expect(result).toBeNull();
	});
});

Transaction rollback means tests don’t interfere with each other — each test starts with a clean slate and leaves no trace.

Testcontainers — Spin Up Dependencies in CI

Testcontainers starts real Docker containers for your test run — no pre-configured test DB needed:

npm install -D @testcontainers/postgresql
// src/test/db-container.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { runMigrations } from '../db/migrate';

let pool: Pool;

export async function startTestDatabase() {
	const container = await new PostgreSqlContainer('postgres:16').withDatabase('testdb').start();

	pool = new Pool({ connectionString: container.getConnectionUri() });

	await runMigrations(pool);

	return {
		pool,
		stop: async () => {
			await pool.end();
			await container.stop();
		}
	};
}
// vitest.config.ts — global setup
export default defineConfig({
	test: {
		globalSetup: './src/test/global-setup.ts'
	}
});

// src/test/global-setup.ts
import { startTestDatabase } from './db-container';

export async function setup() {
	const { pool, stop } = await startTestDatabase();
	process.env.DATABASE_URL = pool.options.connectionString;
	return stop; // Vitest calls this as teardown
}

HTTP Integration Tests

Test your HTTP layer end-to-end — real server, real DB, real middleware:

npm install -D supertest @types/supertest
// src/app.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import { app } from './app';
import { testDb } from './test/setup';

const request = supertest(app);

describe('POST /users', () => {
	it('creates user and returns 201', async () => {
		const res = await request
			.post('/users')
			.send({ email: 'omar@example.com', name: 'Omar' })
			.set('Accept', 'application/json');

		expect(res.status).toBe(201);
		expect(res.body.id).toBeDefined();
		expect(res.body.email).toBe('omar@example.com');
	});

	it('returns 400 for duplicate email', async () => {
		await request.post('/users').send({ email: 'dup@example.com', name: 'First' });

		const res = await request.post('/users').send({ email: 'dup@example.com', name: 'Second' });

		expect(res.status).toBe(400);
		expect(res.body.error).toMatch(/already exists/i);
	});

	it('returns 422 for invalid email', async () => {
		const res = await request.post('/users').send({ email: 'not-an-email', name: 'Omar' });

		expect(res.status).toBe(422);
		expect(res.body.errors).toContainEqual(expect.objectContaining({ field: 'email' }));
	});
});

describe('GET /users/:id', () => {
	it('returns 404 for missing user', async () => {
		const res = await request.get('/users/00000000-0000-0000-0000-000000000000');
		expect(res.status).toBe(404);
	});
});

Testing Authenticated Routes

// Helper: create a test user and get auth token
async function authenticatedRequest(overrides: Partial<User> = {}) {
	const user = await createUser(testDb, {
		email: `test-${randomUUID()}@example.com`,
		...overrides
	});
	const token = signJwt({ userId: user.id, role: user.role });

	return {
		user,
		agent: supertest(app).set('Authorization', `Bearer ${token}`)
	};
}

// Use in tests
it('allows admin to delete users', async () => {
	const { agent } = await authenticatedRequest({ role: 'admin' });
	const target = await createUser(testDb, { email: 'victim@example.com' });

	const res = await agent.delete(`/users/${target.id}`);
	expect(res.status).toBe(204);
});

it('blocks standard user from deleting', async () => {
	const { agent } = await authenticatedRequest({ role: 'standard' });
	const target = await createUser(testDb, { email: 'victim@example.com' });

	const res = await agent.delete(`/users/${target.id}`);
	expect(res.status).toBe(403);
});

Testing External HTTP APIs

For external services you don’t control, use msw (Mock Service Worker) to intercept HTTP at the network level — not at the import level:

npm install -D msw
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
	http.post('https://api.stripe.com/v1/charges', () => {
		return HttpResponse.json({
			id: 'ch_test123',
			status: 'succeeded',
			amount: 100_00
		});
	}),

	http.post('https://api.sendgrid.com/v3/mail/send', () => {
		return new HttpResponse(null, { status: 202 });
	})
];

// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// vitest.config.ts
export default defineConfig({
	test: {
		setupFiles: ['./src/test/msw-setup.ts']
	}
});

// src/test/msw-setup.ts
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override for specific test
it('handles Stripe payment failure', async () => {
	server.use(
		http.post('https://api.stripe.com/v1/charges', () => {
			return HttpResponse.json({ error: { code: 'card_declined' } }, { status: 402 });
		})
	);

	const result = await processPayment({ amount: 50_00, card: 'tok_declined' });
	expect(result.success).toBe(false);
	expect(result.error).toBe('card_declined');
});

Testing Message Queue Consumers

// Test the consumer function directly — no need to run a real queue
import { processOrderEvent } from './order-consumer';

it('marks order as shipped on ShipmentCreated event', async () => {
	const orderId = await createOrder(testDb, { userId: 'user-1', totalCents: 50_00 });

	await processOrderEvent(testDb, {
		type: 'ShipmentCreated',
		orderId,
		trackingNumber: 'TRACK123'
	});

	const order = await getOrder(testDb, orderId);
	expect(order.status).toBe('shipped');
	expect(order.trackingNumber).toBe('TRACK123');
});

// For testing that the right events are published, spy on the publisher
it('publishes OrderShipped event after processing', async () => {
	const publish = vi.fn();
	const orderId = await createOrder(testDb, { userId: 'user-1', totalCents: 50_00 });

	await processOrderEvent(testDb, { type: 'ShipmentCreated', orderId }, { publish });

	expect(publish).toHaveBeenCalledWith(
		'orders',
		expect.objectContaining({ type: 'OrderShipped', orderId })
	);
});

Database Seeding Helpers

// src/test/factories.ts
import { Pool } from 'pg';
import { randomUUID } from 'crypto';

export async function seedUser(db: Pool, overrides: Partial<User> = {}): Promise<User> {
	const { rows } = await db.query(
		`INSERT INTO users (id, email, name, role, created_at)
     VALUES ($1, $2, $3, $4, NOW())
     RETURNING *`,
		[
			randomUUID(),
			overrides.email ?? `user-${randomUUID()}@example.com`,
			overrides.name ?? 'Test User',
			overrides.role ?? 'standard'
		]
	);
	return rows[0];
}

export async function seedProduct(db: Pool, overrides: Partial<Product> = {}): Promise<Product> {
	const { rows } = await db.query(
		`INSERT INTO products (id, name, price_cents, in_stock)
     VALUES ($1, $2, $3, $4) RETURNING *`,
		[
			randomUUID(),
			overrides.name ?? 'Test Product',
			overrides.priceCents ?? 10_00,
			overrides.inStock ?? true
		]
	);
	return rows[0];
}

Factories keep test setup readable and maintainable — when schema changes, fix the factory once.