Integration tests verify that components work correctly together. This guide covers patterns for testing real interactions.
API Integration Tests#
Testing Express Routes#
1import request from 'supertest';
2import { app } from '../app';
3import { db } from '../db';
4
5describe('POST /api/users', () => {
6 beforeEach(async () => {
7 await db.users.deleteMany();
8 });
9
10 it('should create a new user', async () => {
11 const response = await request(app)
12 .post('/api/users')
13 .send({
14 email: 'test@example.com',
15 name: 'Test User',
16 password: 'securePassword123',
17 })
18 .expect(201);
19
20 expect(response.body).toMatchObject({
21 email: 'test@example.com',
22 name: 'Test User',
23 });
24 expect(response.body).not.toHaveProperty('password');
25
26 // Verify in database
27 const user = await db.users.findByEmail('test@example.com');
28 expect(user).toBeTruthy();
29 });
30
31 it('should return 400 for invalid email', async () => {
32 const response = await request(app)
33 .post('/api/users')
34 .send({
35 email: 'invalid-email',
36 name: 'Test',
37 password: 'password123',
38 })
39 .expect(400);
40
41 expect(response.body.error).toContain('email');
42 });
43
44 it('should return 409 for duplicate email', async () => {
45 // Create first user
46 await request(app)
47 .post('/api/users')
48 .send({
49 email: 'test@example.com',
50 name: 'First User',
51 password: 'password123',
52 });
53
54 // Try to create duplicate
55 const response = await request(app)
56 .post('/api/users')
57 .send({
58 email: 'test@example.com',
59 name: 'Second User',
60 password: 'password456',
61 })
62 .expect(409);
63
64 expect(response.body.error).toContain('already exists');
65 });
66});Testing Authentication#
1describe('Protected Routes', () => {
2 let authToken: string;
3
4 beforeAll(async () => {
5 // Create test user and get token
6 await request(app)
7 .post('/api/users')
8 .send({ email: 'auth@test.com', password: 'password123' });
9
10 const loginResponse = await request(app)
11 .post('/api/auth/login')
12 .send({ email: 'auth@test.com', password: 'password123' });
13
14 authToken = loginResponse.body.token;
15 });
16
17 it('should access protected route with valid token', async () => {
18 const response = await request(app)
19 .get('/api/profile')
20 .set('Authorization', `Bearer ${authToken}`)
21 .expect(200);
22
23 expect(response.body.email).toBe('auth@test.com');
24 });
25
26 it('should reject request without token', async () => {
27 await request(app)
28 .get('/api/profile')
29 .expect(401);
30 });
31
32 it('should reject request with invalid token', async () => {
33 await request(app)
34 .get('/api/profile')
35 .set('Authorization', 'Bearer invalid-token')
36 .expect(401);
37 });
38});Database Integration Tests#
Using Test Containers#
1import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
2import { PrismaClient } from '@prisma/client';
3
4describe('UserRepository', () => {
5 let container: StartedPostgreSqlContainer;
6 let prisma: PrismaClient;
7
8 beforeAll(async () => {
9 // Start PostgreSQL container
10 container = await new PostgreSqlContainer()
11 .withDatabase('test')
12 .start();
13
14 // Connect Prisma to test database
15 prisma = new PrismaClient({
16 datasources: {
17 db: { url: container.getConnectionUri() },
18 },
19 });
20
21 // Run migrations
22 await prisma.$executeRawUnsafe(`
23 CREATE TABLE users (
24 id SERIAL PRIMARY KEY,
25 email VARCHAR(255) UNIQUE NOT NULL,
26 name VARCHAR(255)
27 )
28 `);
29 });
30
31 afterAll(async () => {
32 await prisma.$disconnect();
33 await container.stop();
34 });
35
36 beforeEach(async () => {
37 await prisma.user.deleteMany();
38 });
39
40 it('should create and retrieve user', async () => {
41 const created = await prisma.user.create({
42 data: { email: 'test@example.com', name: 'Test' },
43 });
44
45 const found = await prisma.user.findUnique({
46 where: { id: created.id },
47 });
48
49 expect(found).toMatchObject({
50 email: 'test@example.com',
51 name: 'Test',
52 });
53 });
54});Transaction Testing#
1describe('Order Processing', () => {
2 it('should rollback on payment failure', async () => {
3 const user = await createTestUser();
4 const product = await createTestProduct({ inventory: 10 });
5
6 // Mock payment to fail
7 paymentService.charge.mockRejectedValue(new Error('Payment declined'));
8
9 await expect(
10 orderService.createOrder({
11 userId: user.id,
12 items: [{ productId: product.id, quantity: 2 }],
13 })
14 ).rejects.toThrow('Payment declined');
15
16 // Verify inventory wasn't deducted
17 const updatedProduct = await db.products.findById(product.id);
18 expect(updatedProduct.inventory).toBe(10);
19
20 // Verify no order was created
21 const orders = await db.orders.findByUser(user.id);
22 expect(orders).toHaveLength(0);
23 });
24});External Service Integration#
Using MSW for API Mocking#
1import { setupServer } from 'msw/node';
2import { http, HttpResponse } from 'msw';
3
4const server = setupServer(
5 http.get('https://api.stripe.com/v1/customers/:id', ({ params }) => {
6 return HttpResponse.json({
7 id: params.id,
8 email: 'customer@example.com',
9 name: 'Test Customer',
10 });
11 }),
12
13 http.post('https://api.stripe.com/v1/charges', async ({ request }) => {
14 const body = await request.json();
15
16 if (body.amount > 100000) {
17 return HttpResponse.json(
18 { error: { message: 'Amount too large' } },
19 { status: 400 }
20 );
21 }
22
23 return HttpResponse.json({
24 id: 'ch_test123',
25 amount: body.amount,
26 status: 'succeeded',
27 });
28 })
29);
30
31beforeAll(() => server.listen());
32afterEach(() => server.resetHandlers());
33afterAll(() => server.close());
34
35describe('PaymentService', () => {
36 it('should process payment successfully', async () => {
37 const result = await paymentService.charge({
38 customerId: 'cus_123',
39 amount: 5000,
40 });
41
42 expect(result.status).toBe('succeeded');
43 });
44
45 it('should handle large amount error', async () => {
46 await expect(
47 paymentService.charge({
48 customerId: 'cus_123',
49 amount: 200000,
50 })
51 ).rejects.toThrow('Amount too large');
52 });
53});Redis Integration#
1import Redis from 'ioredis';
2import { GenericContainer } from 'testcontainers';
3
4describe('CacheService', () => {
5 let redis: Redis;
6 let container: StartedTestContainer;
7
8 beforeAll(async () => {
9 container = await new GenericContainer('redis:7-alpine')
10 .withExposedPorts(6379)
11 .start();
12
13 redis = new Redis({
14 host: container.getHost(),
15 port: container.getMappedPort(6379),
16 });
17 });
18
19 afterAll(async () => {
20 await redis.quit();
21 await container.stop();
22 });
23
24 beforeEach(async () => {
25 await redis.flushall();
26 });
27
28 it('should cache and retrieve values', async () => {
29 const cacheService = new CacheService(redis);
30
31 await cacheService.set('user:1', { name: 'John' }, 3600);
32 const cached = await cacheService.get('user:1');
33
34 expect(cached).toEqual({ name: 'John' });
35 });
36
37 it('should return null for expired keys', async () => {
38 const cacheService = new CacheService(redis);
39
40 await cacheService.set('temp', 'value', 1); // 1 second TTL
41
42 await new Promise(resolve => setTimeout(resolve, 1100));
43
44 const result = await cacheService.get('temp');
45 expect(result).toBeNull();
46 });
47});Best Practices#
- Use real databases in containers: More realistic than mocks
- Clean up between tests: Prevent test pollution
- Test failure scenarios: Network errors, timeouts, invalid data
- Keep tests focused: One integration point per test
- Use factories for test data: Consistent, maintainable setup
Conclusion#
Integration tests verify real interactions between components. Use test containers for databases, MSW for external APIs, and ensure proper cleanup between tests. Balance coverage with test execution time.