What is Integration Testing?
Integration testing verifies that different parts of your application work together correctly. While unit tests focus on isolated components, integration tests examine the interactions between multiple components, providing confidence that your application functions as a whole.
Real-world analogy: Think of a car manufacturing process. Unit tests verify that individual parts (engine, brakes, transmission) work correctly in isolation. Integration tests check that these parts work together properly—for example, that the engine connects correctly to the transmission. End-to-end tests verify the complete car functions as expected on the road.
Why Integration Testing Matters
- Catches interface issues that unit tests might miss
- Verifies data flow between components
- Tests component composition and interactions
- Validates business workflows across multiple components
- Provides confidence that your application works as a whole
Real-world example: Spotify's engineering team discovered that while their microservices passed all unit tests individually, integration tests revealed issues when they interacted. For instance, one service expected dates in ISO format, while another sent them in Unix timestamp format. Unit tests didn't catch this mismatch, but integration tests immediately highlighted the issue.
Integration Testing Approaches
Top-Down Approach
Start with high-level components and gradually test lower-level components.
- Advantages: Focuses on critical user flows first
- Challenges: Requires mocks for lower-level components not yet tested
Bottom-Up Approach
Start with lower-level components and gradually test higher-level components.
- Advantages: Build on solid foundation of tested components
- Challenges: May not address critical user flows until later
Sandwich Approach (Hybrid)
Combine both approaches, testing both high-level and low-level components simultaneously.
What to Test in Integration Tests
Focus Areas for Integration Testing
- Component interactions - How components communicate
- Data flow - How data passes between components
- API integrations - Interaction with external services
- State management - How application state affects multiple components
- Routing - Navigation between different views
- Form submissions - Complete form workflows
- Error handling - How errors propagate across components
Tools for JavaScript Integration Testing
Primary Testing Tools
- Jest - Testing framework for running tests and assertions
- React Testing Library - Testing DOM nodes from user perspective
- Mock Service Worker (MSW) - API mocking at network level
- Cypress - End-to-end testing framework that can also be used for integration testing
- Supertest - HTTP assertion library for testing Node.js APIs
Supporting Tools
- Storybook - Component development environment that can integrate with testing
- Mirage JS - API mocking library
- nock - HTTP server mocking and expectations library
- TestContainers - Provides lightweight, throwaway instances of databases and other services
Setting Up Integration Tests with Jest
Project Structure
It's helpful to separate integration tests from unit tests:
my-app/
├── src/
│ ├── components/
│ ├── pages/
│ ├── services/
│ └── ...
├── tests/
│ ├── unit/
│ └── integration/
└── jest.config.js
Jest Configuration
// jest.config.js
module.exports = {
// Common configuration
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./jest.setup.js'],
// Projects for different test types
projects: [
{
displayName: 'unit',
testMatch: ['/tests/unit/**/*.test.js'],
// Unit test specific settings
},
{
displayName: 'integration',
testMatch: ['/tests/integration/**/*.test.js'],
// May have longer timeouts for integration tests
testTimeout: 10000,
}
]
};
// jest.setup.js
import '@testing-library/jest-dom';
// Other global setup
Running Integration Tests
// Run only integration tests
npm test -- --selectProjects=integration
// Run specific integration test file
npm test -- tests/integration/checkout-flow.test.js
Frontend Integration Testing Example
Let's test a user authentication flow with multiple components:
Components we'll integrate:
// LoginForm.jsx
function LoginForm({ onLogin }) {
const [credentials, setCredentials] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const handleChange = (e) => {
setCredentials({
...credentials,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
await onLogin(credentials);
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
{error && <div role="alert">{error}</div>}
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={credentials.email}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={credentials.password}
onChange={handleChange}
required
/>
</div>
<button type="submit">Log In</button>
</form>
);
}
// authService.js
export async function login(credentials) {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
const data = await response.json();
localStorage.setItem('token', data.token);
return data.user;
}
export function logout() {
localStorage.removeItem('token');
}
export async function getCurrentUser() {
const token = localStorage.getItem('token');
if (!token) {
return null;
}
const response = await fetch('/api/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
localStorage.removeItem('token');
return null;
}
const data = await response.json();
return data.user;
}
// UserContext.jsx
import React, { createContext, useState, useEffect, useContext } from 'react';
import { getCurrentUser, login, logout } from './authService';
const UserContext = createContext();
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadUser() {
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch (error) {
console.error('Failed to load user:', error);
} finally {
setLoading(false);
}
}
loadUser();
}, []);
const handleLogin = async (credentials) => {
const loggedInUser = await login(credentials);
setUser(loggedInUser);
return loggedInUser;
};
const handleLogout = () => {
logout();
setUser(null);
};
const value = {
user,
loading,
login: handleLogin,
logout: handleLogout
};
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
return useContext(UserContext);
}
// ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useUser } from './UserContext';
function ProtectedRoute({ children }) {
const { user, loading } = useUser();
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
// LoginPage.jsx
import { useNavigate } from 'react-router-dom';
import { useUser } from './UserContext';
import LoginForm from './LoginForm';
function LoginPage() {
const { login } = useUser();
const navigate = useNavigate();
const handleLogin = async (credentials) => {
await login(credentials);
navigate('/dashboard');
};
return (
<div>
<h1>Login</h1>
<LoginForm onLogin={handleLogin} />
</div>
);
}
Integration Test for the Login Flow
// login-flow.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UserProvider } from './UserContext';
import LoginPage from './LoginPage';
import Dashboard from './Dashboard';
import ProtectedRoute from './ProtectedRoute';
// Mock API with MSW
const server = setupServer(
// Mock login endpoint
rest.post('/api/login', (req, res, ctx) => {
const { email, password } = req.body;
if (email === 'user@example.com' && password === 'password123') {
return res(
ctx.json({
token: 'fake-token-123',
user: {
id: '123',
name: 'Test User',
email: 'user@example.com',
role: 'user'
}
})
);
}
return res(
ctx.status(401),
ctx.json({ message: 'Invalid credentials' })
);
}),
// Mock current user endpoint
rest.get('/api/me', (req, res, ctx) => {
const authHeader = req.headers.get('Authorization');
if (authHeader === 'Bearer fake-token-123') {
return res(
ctx.json({
user: {
id: '123',
name: 'Test User',
email: 'user@example.com',
role: 'user'
}
})
);
}
return res(
ctx.status(401),
ctx.json({ message: 'Unauthorized' })
);
})
);
// Setup and teardown MSW
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
window.localStorage.clear();
});
afterAll(() => server.close());
// Helper to render the app with routing and context
function renderApp(initialRoute = '/login') {
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<UserProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</UserProvider>
</MemoryRouter>
);
}
describe('Login Flow', () => {
test('redirects to dashboard after successful login', async () => {
const user = userEvent.setup();
renderApp();
// Assert that we're on the login page
expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
// Fill in the login form
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit the form
await user.click(screen.getByRole('button', { name: /log in/i }));
// Wait for redirection to dashboard
await waitFor(() => {
expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument();
});
// Check that user data is displayed on dashboard
expect(screen.getByText(/welcome, test user/i)).toBeInTheDocument();
});
test('shows error message with invalid credentials', async () => {
const user = userEvent.setup();
renderApp();
// Fill in the login form with wrong password
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
// Submit the form
await user.click(screen.getByRole('button', { name: /log in/i }));
// Check that error message is displayed
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/invalid credentials/i);
});
// Verify we're still on the login page
expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
});
test('redirects to login when accessing protected route without authentication', async () => {
renderApp('/dashboard');
// First, should show loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Then, should redirect to login page
await waitFor(() => {
expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
});
});
test('stays on dashboard if user is already logged in', async () => {
// Pre-set token in localStorage
localStorage.setItem('token', 'fake-token-123');
renderApp('/dashboard');
// First, should show loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Then, should show dashboard
await waitFor(() => {
expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument();
});
});
});
Backend Integration Testing Example
Let's test a backend workflow integrating controllers, services, and database access:
First, the components we'll test:
// userRepository.js
const db = require('./database');
async function findByEmail(email) {
return db.query('SELECT * FROM users WHERE email = ?', [email]);
}
async function create(user) {
const result = await db.query(
'INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)',
[user.name, user.email, user.passwordHash]
);
return { id: result.insertId, ...user };
}
module.exports = {
findByEmail,
create
};
// userService.js
const bcrypt = require('bcrypt');
const userRepository = require('./userRepository');
async function registerUser({ name, email, password }) {
// Check if user already exists
const existingUser = await userRepository.findByEmail(email);
if (existingUser.length > 0) {
throw new Error('User with this email already exists');
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user
const user = await userRepository.create({
name,
email,
passwordHash
});
// Remove sensitive data
const { passwordHash: _, ...userWithoutPassword } = user;
return userWithoutPassword;
}
module.exports = {
registerUser
};
// userController.js
const userService = require('./userService');
async function register(req, res) {
try {
const { name, email, password } = req.body;
// Validate input
if (!name || !email || !password) {
return res.status(400).json({ message: 'All fields are required' });
}
// Register user
const user = await userService.registerUser({ name, email, password });
res.status(201).json({ user });
} catch (error) {
if (error.message === 'User with this email already exists') {
return res.status(409).json({ message: error.message });
}
res.status(500).json({ message: 'Failed to register user' });
}
}
module.exports = {
register
};
// routes.js
const express = require('express');
const userController = require('./userController');
const router = express.Router();
router.post('/users', userController.register);
module.exports = router;
// app.js
const express = require('express');
const routes = require('./routes');
const app = express();
app.use(express.json());
app.use('/api', routes);
module.exports = app;
Integration Test for User Registration
// user-registration.test.js
const request = require('supertest');
const app = require('./app');
const bcrypt = require('bcrypt');
const db = require('./database');
// Mock the database module
jest.mock('./database');
describe('User Registration API', () => {
beforeEach(() => {
// Clear mocks before each test
jest.clearAllMocks();
});
test('successfully registers a user', async () => {
// Mock database responses
db.query.mockImplementation((query, params) => {
if (query.includes('SELECT')) {
// No existing user found
return [];
} else if (query.includes('INSERT')) {
// User was created successfully
return { insertId: 1 };
}
});
// Mock bcrypt hash
bcrypt.hash = jest.fn().mockResolvedValue('hashed_password');
// Test data
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
};
// Make API request
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
// Verify response
expect(response.body.user).toEqual(expect.objectContaining({
id: 1,
name: 'John Doe',
email: 'john@example.com'
}));
// Password should not be in the response
expect(response.body.user.password).toBeUndefined();
expect(response.body.user.passwordHash).toBeUndefined();
// Verify database was called correctly
expect(db.query).toHaveBeenCalledTimes(2);
expect(db.query).toHaveBeenNthCalledWith(
1,
expect.stringContaining('SELECT'),
['john@example.com']
);
expect(db.query).toHaveBeenNthCalledWith(
2,
expect.stringContaining('INSERT'),
['John Doe', 'john@example.com', 'hashed_password']
);
// Verify bcrypt was called correctly
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
});
test('returns 409 when user with email already exists', async () => {
// Mock database to return existing user
db.query.mockImplementation((query) => {
if (query.includes('SELECT')) {
return [{ id: 1, email: 'john@example.com', name: 'Existing User' }];
}
});
// Test data
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
};
// Make API request
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(409);
// Verify response
expect(response.body.message).toBe('User with this email already exists');
// Verify only the find query was executed, not the insert
expect(db.query).toHaveBeenCalledTimes(1);
});
test('returns 400 when required fields are missing', async () => {
// Test with missing name
const response1 = await request(app)
.post('/api/users')
.send({
email: 'john@example.com',
password: 'password123'
})
.expect(400);
expect(response1.body.message).toBe('All fields are required');
// Test with missing email
const response2 = await request(app)
.post('/api/users')
.send({
name: 'John Doe',
password: 'password123'
})
.expect(400);
expect(response2.body.message).toBe('All fields are required');
// Test with missing password
const response3 = await request(app)
.post('/api/users')
.send({
name: 'John Doe',
email: 'john@example.com'
})
.expect(400);
expect(response3.body.message).toBe('All fields are required');
// Verify the database was never queried
expect(db.query).not.toHaveBeenCalled();
});
});
Full-Stack Integration Testing
To test the complete stack, we can combine frontend and backend testing approaches:
// full-stack-integration.test.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
const request = require('supertest');
const { render, screen, waitFor } = require('@testing-library/react');
const userEvent = require('@testing-library/user-event');
const { BrowserRouter } = require('react-router-dom');
const server = require('./server');
const App = require('./App');
describe('Full-Stack Integration', () => {
let mongoServer;
// Setup in-memory MongoDB server
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
await mongoose.connect(uri);
});
// Clean up after tests
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
server.close();
});
// Clean database between tests
beforeEach(async () => {
await mongoose.connection.db.dropDatabase();
});
test('user can register and then login', async () => {
const user = userEvent.setup();
// First, register a user through the API
await request(server)
.post('/api/users')
.send({
name: 'Test User',
email: 'test@example.com',
password: 'password123'
})
.expect(201);
// Now render the React app
render(
<BrowserRouter>
<App />
</BrowserRouter>
);
// Navigate to login page if not already there
const loginLink = screen.getByRole('link', { name: /login/i });
await user.click(loginLink);
// Fill in the login form
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit the form
await user.click(screen.getByRole('button', { name: /log in/i }));
// Verify user is logged in and redirected to dashboard
await waitFor(() => {
expect(screen.getByText(/welcome, test user/i)).toBeInTheDocument();
});
});
});
Advanced Integration Testing Techniques
Testing with Real Databases
While mocking the database works for many cases, sometimes you need to test with a real database:
// Using test containers for real database tests
const { GenericContainer } = require('testcontainers');
describe('Integration with real PostgreSQL', () => {
let container;
let db;
beforeAll(async () => {
// Start PostgreSQL container
container = await new GenericContainer('postgres:13')
.withExposedPorts(5432)
.withEnv('POSTGRES_USER', 'test')
.withEnv('POSTGRES_PASSWORD', 'test')
.withEnv('POSTGRES_DB', 'testdb')
.start();
// Connect to database
const connectionString = `postgresql://test:test@localhost:${container.getMappedPort(5432)}/testdb`;
db = require('your-db-client')(connectionString);
// Set up schema
await db.query(`CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT,
email TEXT UNIQUE,
password_hash TEXT
)`);
});
afterAll(async () => {
await db.end();
await container.stop();
});
test('can create and find user', async () => {
// Your test using real database
});
});
Testing Microservices Integration
When testing microservices, you may need to orchestrate multiple services:
// Using docker-compose for microservices integration tests
const { DockerComposeEnvironment } = require('testcontainers');
const path = require('path');
describe('Microservices Integration', () => {
let environment;
let authApiUrl;
let productApiUrl;
beforeAll(async () => {
// Start all services defined in docker-compose.yml
environment = await new DockerComposeEnvironment(
path.resolve(__dirname, '..'),
'docker-compose.test.yml'
).up();
// Get service URLs
const authService = environment.getContainer('auth-service');
const productService = environment.getContainer('product-service');
authApiUrl = `http://localhost:${authService.getMappedPort(3000)}`;
productApiUrl = `http://localhost:${productService.getMappedPort(3001)}`;
});
afterAll(async () => {
await environment.down();
});
test('authenticated user can access product API', async () => {
// First login through auth service
const loginResponse = await request(authApiUrl)
.post('/api/login')
.send({ email: 'test@example.com', password: 'password' });
const { token } = loginResponse.body;
// Then use token to access product API
const productResponse = await request(productApiUrl)
.get('/api/products')
.set('Authorization', `Bearer ${token}`);
expect(productResponse.status).toBe(200);
expect(productResponse.body).toHaveProperty('products');
});
});
Test Fixtures and Factories
Create consistent test data with factories:
// factories.js
const { faker } = require('@faker-js/faker');
const bcrypt = require('bcrypt');
function createUser(overrides = {}) {
return {
name: faker.person.fullName(),
email: faker.internet.email(),
passwordHash: bcrypt.hashSync('password123', 10),
...overrides
};
}
function createProduct(overrides = {}) {
return {
name: faker.commerce.productName(),
price: parseFloat(faker.commerce.price()),
description: faker.commerce.productDescription(),
...overrides
};
}
module.exports = {
createUser,
createProduct
};
// Usage in tests
const { createUser } = require('./factories');
test('user can update profile', async () => {
const user = createUser();
// Insert user into test database
// Test profile update logic
});
Best Practices for Integration Testing
General Best Practices
- Test realistic scenarios - Focus on common user flows and critical business processes
- Maintain test independence - Each test should be self-contained and not depend on others
- Control external dependencies - Use reliable test doubles or containers for external services
- Clean up after tests - Restore the system to its initial state after each test
- Use meaningful assertions - Clearly state what you're testing and what you expect
Performance Considerations
- Optimize test setup - Share expensive setup operations between tests
- Run in parallel - Configure tests to run in parallel where possible
- Use in-memory databases - They're faster than real databases for many tests
- Selective integration testing - Not every component needs full integration tests
Avoiding Common Pitfalls
- Flaky tests - Address timing issues, race conditions, and environmental dependencies
- Over-mocking - Don't mock everything; the point of integration tests is to test real interactions
- Brittle tests - Avoid testing implementation details that might change
- Test data management - Use factories and fixtures for consistent, maintainable test data
Real-world example: Netflix's engineering team discovered that their integration tests were failing inconsistently in their CI environment. They traced this to timing issues with asynchronous operations. Their solution was to create custom test helpers that ensured stable test execution by properly awaiting all asynchronous operations and implementing retry mechanisms for network requests.
Practical Exercise
Exercise: Integration Testing a Shopping Cart Workflow
Let's practice by writing integration tests for a shopping cart workflow that includes:
- Adding products to the cart
- Updating quantities
- Applying discount codes
- Calculating totals
- Processing checkout
You'll need to integrate these components:
- ProductList component that fetches products from an API
- CartContext that manages cart state
- CartSummary component that displays the cart contents
- CheckoutForm component for submitting the order
- OrderService that handles order submission to the API
Start with this skeleton:
// shopping-cart-flow.test.js
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import App from './App';
// Set up MSW server for API mocking
const server = setupServer(
// Mock product list endpoint
rest.get('/api/products', (req, res, ctx) => {
return res(ctx.json({
products: [
{ id: 1, name: 'Product 1', price: 10.99 },
{ id: 2, name: 'Product 2', price: 24.99 },
{ id: 3, name: 'Product 3', price: 5.99 }
]
}));
}),
// Mock discount code validation
rest.post('/api/discounts/validate', (req, res, ctx) => {
const { code } = req.body;
if (code === 'SAVE10') {
return res(ctx.json({
valid: true,
discountPercent: 10
}));
}
return res(ctx.json({
valid: false,
message: 'Invalid discount code'
}));
}),
// Mock order submission
rest.post('/api/orders', (req, res, ctx) => {
// Your mock implementation
})
);
// Setup and teardown MSW
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('Shopping Cart Flow', () => {
test('complete shopping flow from browsing to checkout', async () => {
// Your test implementation
});
});
Summary
- Integration testing verifies that different parts of your application work together correctly
- Focus on testing component interactions, data flow, and complete workflows
- Use tools like Jest, React Testing Library, and MSW to facilitate integration testing
- For frontend integration tests, test user flows across multiple components
- For backend integration tests, test the complete request/response cycle across controllers, services, and data access layers
- For full-stack tests, combine frontend and backend testing approaches
- Consider performance and stability by optimizing test setup and avoiding common pitfalls
Remember: While unit tests verify that components work correctly in isolation, integration tests give you confidence that they work together as a system.
Assignment
Create a complete integration test suite for an e-commerce workflow:
- Create a simplified e-commerce application with these features:
- Product listing and filtering
- Product details view
- Cart management (add, update, remove)
- Checkout process with form validation
- Order confirmation
- Write integration tests that verify:
- Products load correctly and can be filtered
- Users can navigate to product details
- Products can be added to cart from both listing and details pages
- Cart quantities can be updated and items removed
- Checkout form validates user input
- Order is submitted correctly and confirmation is displayed
- Implement proper API mocking with MSW
- Include error handling tests (network errors, validation errors)
- Document your testing strategy and approach
Bonus challenge: Add user authentication and test authenticated workflows.