Testing & TDD

Integration Testing: Testing Components Together

18 min Lesson 9 of 35

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:
  1. Create a blog system with Post, Comment, and User models
  2. Write integration tests for creating a post with a real database
  3. Write integration tests for the comment API endpoints (create, read, update, delete)
  4. Test the complete workflow: user registers → creates post → adds comments → deletes post (cascade deletes comments)
  5. Write integration tests for pagination and filtering of posts
  6. 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.