Understanding Integration Testing
While unit tests verify that individual components work correctly in isolation, integration tests verify that multiple components work together correctly. Integration testing sits between unit testing and end-to-end testing in the testing pyramid. It tests the interactions between modules, classes, or services - ensuring that when components are combined, they integrate properly and produce the expected results.
Integration tests are more complex than unit tests because they involve multiple components and often interact with external systems like databases, file systems, or APIs. They're slower and more brittle than unit tests, but they catch different types of bugs - particularly issues with interfaces, data flow, and system integration. A well-balanced test suite includes both unit tests for individual components and integration tests for their interactions.
Integration Test Characteristics:
- Multi-component: Tests multiple units working together
- Real dependencies: Uses real databases, APIs, or file systems (or realistic fakes)
- Slower: Takes longer than unit tests due to I/O operations
- Higher-level: Tests workflows and features, not just individual functions
- Valuable: Catches integration bugs that unit tests miss
Integration vs Unit Tests
Unit Test Example
// Unit test - tests UserService in isolation with mocked dependencies
describe('UserService', () => {
test('creates user with hashed password', () => {
const mockHasher = {
hash: jest.fn(() => 'hashed_password')
};
const mockRepo = {
save: jest.fn(user => user)
};
const service = new UserService(mockHasher, mockRepo);
const user = service.createUser('john@example.com', 'password123');
expect(mockHasher.hash).toHaveBeenCalledWith('password123');
expect(user.passwordHash).toBe('hashed_password');
});
});
Integration Test Example
// Integration test - tests UserService with real hasher and database
describe('UserService Integration', () => {
let db;
let service;
beforeEach(async () => {
db = await setupTestDatabase();
const hasher = new BcryptHasher();
const repo = new UserRepository(db);
service = new UserService(hasher, repo);
});
afterEach(async () => {
await db.close();
});
test('creates user and persists to database', async () => {
const user = await service.createUser('john@example.com', 'password123');
// Verify user was saved to database
const savedUser = await db.query('SELECT * FROM users WHERE id = ?', [user.id]);
expect(savedUser.email).toBe('john@example.com');
expect(savedUser.passwordHash).toBeDefined();
expect(savedUser.passwordHash).not.toBe('password123'); // Password should be hashed
});
});
Database Integration Testing
Test Database Setup
Integration tests need a real or realistic database. Common strategies include:
// Strategy 1: In-memory database (SQLite)
const setupTestDatabase = async () => {
const db = await sqlite.open({
filename: ':memory:',
driver: sqlite3.Database
});
// Run migrations
await db.migrate();
return db;
};
// Strategy 2: Separate test database
const setupTestDatabase = async () => {
const db = await mysql.createConnection({
host: 'localhost',
user: 'test_user',
password: 'test_password',
database: 'test_db'
});
// Clear all tables before tests
await db.query('SET FOREIGN_KEY_CHECKS=0');
const tables = await db.query('SHOW TABLES');
for (const table of tables) {
await db.query(`TRUNCATE TABLE ${table.name}`);
}
await db.query('SET FOREIGN_KEY_CHECKS=1');
return db;
};
// Strategy 3: Docker container database
const setupTestDatabase = async () => {
const container = await docker.createContainer({
Image: 'postgres:14',
Env: ['POSTGRES_PASSWORD=test'],
ExposedPorts: { '5432/tcp': {} }
});
await container.start();
// Wait for database to be ready...
// Connect and return connection
};
Database Test Patterns
describe('User Repository Integration', () => {
let db;
let repo;
beforeAll(async () => {
db = await setupTestDatabase();
});
afterAll(async () => {
await db.close();
});
beforeEach(async () => {
// Clear data before each test
await db.query('DELETE FROM users');
repo = new UserRepository(db);
});
test('saves and retrieves user', async () => {
const user = { email: 'john@example.com', name: 'John' };
const savedUser = await repo.save(user);
expect(savedUser.id).toBeDefined();
const foundUser = await repo.findById(savedUser.id);
expect(foundUser).toMatchObject(user);
});
test('updates existing user', async () => {
const user = await repo.save({ email: 'john@example.com', name: 'John' });
user.name = 'John Doe';
await repo.save(user);
const updated = await repo.findById(user.id);
expect(updated.name).toBe('John Doe');
});
test('deletes user', async () => {
const user = await repo.save({ email: 'john@example.com' });
await repo.delete(user.id);
const found = await repo.findById(user.id);
expect(found).toBeNull();
});
test('finds users by email', async () => {
await repo.save({ email: 'john@example.com', name: 'John' });
await repo.save({ email: 'jane@example.com', name: 'Jane' });
const users = await repo.findByEmail('john@example.com');
expect(users).toHaveLength(1);
expect(users[0].name).toBe('John');
});
});
Database Test Best Practices:
- Use transactions and rollback when possible for fast cleanup
- Clear or seed data before each test for isolation
- Use realistic data that matches production scenarios
- Test constraints, indexes, and database-specific features
- Consider using database fixtures for complex test scenarios
API Integration Testing
Testing HTTP Endpoints
const request = require('supertest');
const app = require('./app'); // Express app
describe('User API', () => {
let server;
beforeAll(() => {
server = app.listen(0); // Random port
});
afterAll((done) => {
server.close(done);
});
test('GET /api/users returns all users', async () => {
const response = await request(server)
.get('/api/users')
.expect(200)
.expect('Content-Type', /json/);
expect(response.body).toBeInstanceOf(Array);
});
test('POST /api/users creates new user', async () => {
const newUser = {
email: 'john@example.com',
name: 'John Doe'
};
const response = await request(server)
.post('/api/users')
.send(newUser)
.expect(201)
.expect('Content-Type', /json/);
expect(response.body).toMatchObject(newUser);
expect(response.body.id).toBeDefined();
});
test('GET /api/users/:id returns specific user', async () => {
// Create user first
const createResponse = await request(server)
.post('/api/users')
.send({ email: 'john@example.com', name: 'John' });
const userId = createResponse.body.id;
// Fetch user
const response = await request(server)
.get(`/api/users/${userId}`)
.expect(200);
expect(response.body.id).toBe(userId);
expect(response.body.email).toBe('john@example.com');
});
test('PUT /api/users/:id updates user', async () => {
const user = await createTestUser();
const response = await request(server)
.put(`/api/users/${user.id}`)
.send({ name: 'John Updated' })
.expect(200);
expect(response.body.name).toBe('John Updated');
});
test('DELETE /api/users/:id deletes user', async () => {
const user = await createTestUser();
await request(server)
.delete(`/api/users/${user.id}`)
.expect(204);
// Verify deletion
await request(server)
.get(`/api/users/${user.id}`)
.expect(404);
});
test('returns 404 for non-existent user', async () => {
await request(server)
.get('/api/users/99999')
.expect(404);
});
test('returns 400 for invalid user data', async () => {
const response = await request(server)
.post('/api/users')
.send({ email: 'invalid-email' }) // Missing name, invalid email
.expect(400);
expect(response.body.errors).toBeDefined();
});
});
Testing Authentication and Authorization
describe('Protected API Routes', () => {
let authToken;
beforeEach(async () => {
// Login and get auth token
const response = await request(server)
.post('/api/auth/login')
.send({ email: 'admin@example.com', password: 'password' });
authToken = response.body.token;
});
test('requires authentication', async () => {
await request(server)
.get('/api/admin/users')
.expect(401);
});
test('allows access with valid token', async () => {
await request(server)
.get('/api/admin/users')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
});
test('rejects invalid token', async () => {
await request(server)
.get('/api/admin/users')
.set('Authorization', 'Bearer invalid_token')
.expect(401);
});
test('enforces role-based access', async () => {
// Login as regular user
const userResponse = await request(server)
.post('/api/auth/login')
.send({ email: 'user@example.com', password: 'password' });
// Try to access admin route
await request(server)
.get('/api/admin/users')
.set('Authorization', `Bearer ${userResponse.body.token}`)
.expect(403); // Forbidden
});
});
Component Integration Testing
Testing React Components with Real Dependencies
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { ApiProvider } from './ApiContext';
describe('UserProfile Integration', () => {
test('fetches and displays user data', async () => {
// Setup real API provider (not mocked)
render(
<ApiProvider baseUrl="http://localhost:3000">
<UserProfile userId={1} />
</ApiProvider>
);
// Show loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
test('updates user when form is submitted', async () => {
const user = userEvent.setup();
render(
<ApiProvider baseUrl="http://localhost:3000">
<UserProfile userId={1} />
</ApiProvider>
);
// Wait for initial load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// Edit name
const nameInput = screen.getByLabelText('Name');
await user.clear(nameInput);
await user.type(nameInput, 'Jane Doe');
// Submit form
await user.click(screen.getByRole('button', { name: 'Save' }));
// Verify success message
await waitFor(() => {
expect(screen.getByText('Profile updated successfully')).toBeInTheDocument();
});
// Verify updated name
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
test('shows error when update fails', async () => {
const user = userEvent.setup();
render(
<ApiProvider baseUrl="http://localhost:3000">
<UserProfile userId={1} />
</ApiProvider>
);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// Submit invalid data
const emailInput = screen.getByLabelText('Email');
await user.clear(emailInput);
await user.type(emailInput, 'invalid-email');
await user.click(screen.getByRole('button', { name: 'Save' }));
// Verify error message
await waitFor(() => {
expect(screen.getByText('Invalid email address')).toBeInTheDocument();
});
});
});
Testing External Service Integration
Testing Third-Party API Integration
describe('Payment Service Integration', () => {
let paymentService;
beforeEach(() => {
// Use sandbox/test environment
paymentService = new StripePaymentService({
apiKey: process.env.STRIPE_TEST_KEY,
sandbox: true
});
});
test('processes payment successfully', async () => {
const payment = {
amount: 1000, // $10.00
currency: 'usd',
source: 'tok_visa', // Test token
description: 'Test payment'
};
const result = await paymentService.charge(payment);
expect(result.status).toBe('succeeded');
expect(result.amount).toBe(1000);
});
test('handles declined card', async () => {
const payment = {
amount: 1000,
currency: 'usd',
source: 'tok_chargeDeclined', // Test token for declined card
description: 'Test payment'
};
await expect(paymentService.charge(payment))
.rejects
.toThrow('Card was declined');
});
test('handles insufficient funds', async () => {
const payment = {
amount: 1000,
currency: 'usd',
source: 'tok_insufficientFunds',
description: 'Test payment'
};
await expect(paymentService.charge(payment))
.rejects
.toThrow('Insufficient funds');
});
});
Testing Real External Services:
Always use sandbox/test environments when testing external services. Never use production API keys or charge real cards in tests. Consider using contract testing or service virtualization for more reliable tests.
Integration Test Best Practices
1. Test Realistic Scenarios
// Good - tests realistic user workflow
test('complete user registration flow', async () => {
// Register user
const user = await userService.register({
email: 'john@example.com',
password: 'password123'
});
// Verify email sent
expect(emailService.getSentEmails()).toContainEqual(
expect.objectContaining({
to: 'john@example.com',
subject: 'Verify your email'
})
);
// Verify user in database
const savedUser = await db.query('SELECT * FROM users WHERE id = ?', [user.id]);
expect(savedUser).toBeDefined();
// User should not be active yet
expect(savedUser.isActive).toBe(false);
// Verify email
await userService.verifyEmail(user.verificationToken);
// User should now be active
const verifiedUser = await db.query('SELECT * FROM users WHERE id = ?', [user.id]);
expect(verifiedUser.isActive).toBe(true);
});
2. Isolate Test Data
// Use unique data per test to avoid conflicts
test('creates user with unique email', async () => {
const uniqueEmail = `test-${Date.now()}@example.com`;
const user = await userService.register({
email: uniqueEmail,
password: 'password123'
});
expect(user.email).toBe(uniqueEmail);
});
// Or use test fixtures
const createTestUser = async (overrides = {}) => {
return userService.register({
email: `test-${Math.random()}@example.com`,
name: 'Test User',
password: 'password123',
...overrides
});
};
3. Clean Up After Tests
describe('Order Service Integration', () => {
const createdOrders = [];
afterEach(async () => {
// Clean up all created orders
for (const order of createdOrders) {
await orderService.delete(order.id);
}
createdOrders.length = 0;
});
test('creates order', async () => {
const order = await orderService.create({
userId: 1,
items: [{ productId: 1, quantity: 2 }]
});
createdOrders.push(order);
expect(order.id).toBeDefined();
});
});
Practice Exercise:
- Create a blog system with Post, Comment, and User models
- Write integration tests for creating a post with a real database
- Write integration tests for the comment API endpoints (create, read, update, delete)
- Test the complete workflow: user registers → creates post → adds comments → deletes post (cascade deletes comments)
- Write integration tests for pagination and filtering of posts
- Ensure tests clean up data properly and can run in any order
Summary
Integration testing validates that multiple components work together correctly. Unlike unit tests that isolate components with mocks, integration tests use real dependencies - databases, APIs, file systems - to verify that integrations work as expected. Integration tests are essential for catching bugs in interfaces, data flow, and system interactions that unit tests can't detect.
While integration tests are slower and more complex than unit tests, they provide invaluable confidence that your system works as a whole. A balanced test strategy includes both: unit tests for fast feedback on individual components, and integration tests for confidence in system integration. Focus integration tests on critical workflows, external service integrations, and database operations where component interactions are most likely to fail.