π Integration Testing JavaScript Applications
Unit tests prove each piece works alone; integration tests prove the pieces work together. This lesson shows where integration testing fits in the pyramid, then walks through a real frontend login flow tested with MSW and a backend registration API tested with Supertest.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Define integration testing and place it in the testing pyramid
- Decide what to test at the integration level β data flow, workflows, and boundaries
- Write a frontend integration test across components, context, and routing using MSW
- Write a backend integration test across controller, service, and data layers with Supertest
- Apply best practices for test independence, cleanup, and stability
Estimated Time: 45β55 minutes β’ Difficulty: IntermediateβAdvanced
Hands-on: Design integration tests for a shopping-cart checkout flow.
In This Lesson
What Is Integration Testing?
Integration testing verifies that separate parts of your application cooperate correctly. Where a unit test isolates one function or component behind mocks, an integration test deliberately lets several real units talk to each other β a form talking to a context talking to a service, or a controller talking to a service talking to a repository.
π‘ An analogy: Car manufacturing has three levels of checks. Unit tests confirm the engine, brakes, and transmission each work on the bench. Integration tests confirm the engine actually connects to the transmission. End-to-end tests take the finished car for a drive. Each level catches a different class of defect.
Integration tests catch the bugs that hide between units β the mismatched date format, the wrong header name, the state that never propagates β precisely the failures that green unit tests can't see.
The Testing Pyramid
A healthy suite has many fast unit tests, a solid middle band of integration tests, and a few high-value end-to-end tests. The higher you go, the more confidence per test β but the slower and more brittle they become, so you want fewer of them.
π‘ Why not test everything end-to-end?
E2E tests are the most realistic but also the slowest and flakiest. If you relied on them alone, a single failure could point anywhere in the stack. The pyramid pushes most checks down to fast, precise layers and reserves E2E for a handful of critical journeys.
What to Test & Which Tools
Integration tests earn their keep on the seams between units. Focus them on:
- Data flow β values passing between components, through context, or across service boundaries.
- Multi-step workflows β login, checkout, onboarding.
- API integration β how your code handles real request/response shapes and errors.
- Routing & navigation β the right view renders after an action.
- Error propagation β a failure deep in the stack surfaces correctly to the user.
| Tool | Layer | What it does |
|---|---|---|
| Jest / Vitest | Any | Runs tests and provides assertions & mocks |
| React Testing Library | Frontend | Renders and drives components from the user's view |
| MSW | Frontend | Mocks HTTP at the network level (no fetch stubbing) |
| Supertest | Backend | Fires real HTTP requests at an Express/Node app |
| Testcontainers | Backend | Spins up throwaway real databases in Docker |
Setting Up with Jest
It's common to keep integration tests separate from unit tests so you can run them independently and give them longer timeouts. Jest's projects feature makes this clean:
my-app/
βββ src/
β βββ components/
β βββ services/
β βββ ...
βββ tests/
β βββ unit/
β βββ integration/
βββ jest.config.js
// jest.config.js
module.exports = {
projects: [
{
displayName: 'unit',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/tests/unit/**/*.test.js'],
},
{
displayName: 'integration',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/tests/integration/**/*.test.js'],
testTimeout: 10000, // integration tests may need more time
},
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
# Run only the integration project
npx jest --selectProjects=integration
# Run one file
npx jest tests/integration/login-flow.test.js
Frontend Example: Login Flow
Let's test a login journey that spans four cooperating units. A unit test would mock the boundaries between them; an integration test wires the real ones together and mocks only the network.
The service layer talks to the API and stores a token:
// authService.js
export async function login(credentials) {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || 'Login failed');
}
const data = await res.json();
localStorage.setItem('token', data.token);
return data.user;
}
export async function getCurrentUser() {
const token = localStorage.getItem('token');
if (!token) return null;
const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
localStorage.removeItem('token');
return null;
}
return (await res.json()).user;
}
A context provides the current user; a protected route guards the dashboard. (Both are standard React β abbreviated here for focus.)
// UserContext.jsx (essentials)
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getCurrentUser().then(setUser).finally(() => setLoading(false));
}, []);
const value = {
user, loading,
login: async (creds) => setUser(await login(creds)),
logout: () => { logout(); setUser(null); },
};
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
// ProtectedRoute.jsx
function ProtectedRoute({ children }) {
const { user, loading } = useUser();
if (loading) return <div>Loading...</div>;
if (!user) return <Navigate to="/login" replace />;
return children;
}
The integration test
We mock the HTTP endpoints with MSW (v2), then drive the real form β service β context β router chain exactly as a user would:
// login-flow.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { UserProvider } from './UserContext';
import LoginPage from './LoginPage';
import Dashboard from './Dashboard';
import ProtectedRoute from './ProtectedRoute';
const server = setupServer(
http.post('/api/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'user@example.com' && password === 'password123') {
return HttpResponse.json({
token: 'fake-token-123',
user: { id: '123', name: 'Test User', email },
});
}
return HttpResponse.json({ message: 'Invalid credentials' }, { status: 401 });
}),
http.get('/api/me', ({ request }) => {
if (request.headers.get('Authorization') === 'Bearer fake-token-123') {
return HttpResponse.json({ user: { id: '123', name: 'Test User' } });
}
return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 });
})
);
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
localStorage.clear();
});
afterAll(() => server.close());
function renderApp(route = '/login') {
return render(
<MemoryRouter initialEntries={[route]}>
<UserProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={
<ProtectedRoute><Dashboard /></ProtectedRoute>
} />
</Routes>
</UserProvider>
</MemoryRouter>
);
}
describe('Login flow', () => {
test('redirects to the dashboard after a successful login', async () => {
const user = userEvent.setup();
renderApp();
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(await screen.findByRole('heading', { name: /dashboard/i })).toBeInTheDocument();
expect(screen.getByText(/welcome, test user/i)).toBeInTheDocument();
});
test('shows an error for invalid credentials', async () => {
const user = userEvent.setup();
renderApp();
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/invalid credentials/i);
expect(screen.getByRole('heading', { name: /login/i })).toBeInTheDocument();
});
test('redirects to login when visiting a protected route unauthenticated', async () => {
renderApp('/dashboard');
expect(await screen.findByRole('heading', { name: /login/i })).toBeInTheDocument();
});
});
β Why this is an integration test
Only the network is faked. The form's real submit handler calls the real service, which updates the real context, which the real router reads to guard the real dashboard. A regression anywhere along that chain fails the test β which is exactly the coverage a pile of isolated unit tests would leave open.
Backend Example: Registration API
On the server, integration means exercising the full request path: route β controller β service β repository. Supertest fires real HTTP requests at your Express app in-process β no need to bind a port.
The layers, condensed:
// userService.js
const bcrypt = require('bcrypt');
const repo = require('./userRepository');
async function registerUser({ name, email, password }) {
const existing = await repo.findByEmail(email);
if (existing.length > 0) throw new Error('User with this email already exists');
const passwordHash = await bcrypt.hash(password, 10);
const user = await repo.create({ name, email, passwordHash });
const { passwordHash: _omit, ...safe } = user;
return safe;
}
module.exports = { registerUser };
// userController.js
const service = require('./userService');
async function register(req, res) {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ message: 'All fields are required' });
}
const user = await service.registerUser({ name, email, password });
res.status(201).json({ user });
} catch (err) {
if (err.message === 'User with this email already exists') {
return res.status(409).json({ message: err.message });
}
res.status(500).json({ message: 'Failed to register user' });
}
}
module.exports = { register };
The integration test
Here we mock only the lowest layer β the database client β and let the controller and service run for real:
// user-registration.test.js
const request = require('supertest');
const app = require('./app');
const bcrypt = require('bcrypt');
const db = require('./database');
jest.mock('./database');
describe('POST /api/users', () => {
beforeEach(() => jest.clearAllMocks());
test('registers a new user (201)', async () => {
db.query.mockImplementation((sql) =>
sql.includes('SELECT') ? [] : { insertId: 1 }
);
jest.spyOn(bcrypt, 'hash').mockResolvedValue('hashed_password');
const res = await request(app)
.post('/api/users')
.send({ name: 'John Doe', email: 'john@example.com', password: 'password123' })
.expect(201);
expect(res.body.user).toMatchObject({ id: 1, name: 'John Doe', email: 'john@example.com' });
expect(res.body.user.passwordHash).toBeUndefined(); // never leak the hash
expect(db.query).toHaveBeenCalledTimes(2);
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
});
test('rejects a duplicate email (409)', async () => {
db.query.mockImplementation((sql) =>
sql.includes('SELECT') ? [{ id: 1, email: 'john@example.com' }] : undefined
);
const res = await request(app)
.post('/api/users')
.send({ name: 'John Doe', email: 'john@example.com', password: 'password123' })
.expect(409);
expect(res.body.message).toBe('User with this email already exists');
expect(db.query).toHaveBeenCalledTimes(1); // never reached the INSERT
});
test('rejects missing fields (400) without touching the database', async () => {
await request(app)
.post('/api/users')
.send({ email: 'john@example.com', password: 'password123' }) // no name
.expect(400);
expect(db.query).not.toHaveBeenCalled();
});
});
π Note the assertions on behaviour and collaboration
Beyond the status codes, we assert that the password hash never leaks, that a duplicate short-circuits before the INSERT, and that a validation failure never reaches the database. Integration tests can β and should β verify how the layers talk to each other, not just the final response.
Real Databases & Fixtures
Mocking the database is fast, but it can't catch real SQL errors, constraint violations, or transaction bugs. For those, spin up a genuine throwaway database with Testcontainers:
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
describe('with a real PostgreSQL', () => {
let container, db;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16').start();
db = createClient(container.getConnectionUri());
await db.query(`CREATE TABLE users (
id SERIAL PRIMARY KEY, name TEXT, email TEXT UNIQUE, password_hash TEXT
)`);
}, 60000); // pulling the image can be slow the first time
afterAll(async () => {
await db.end();
await container.stop();
});
test('enforces the unique email constraint', async () => {
await db.query('INSERT INTO users (email) VALUES ($1)', ['a@b.com']);
await expect(
db.query('INSERT INTO users (email) VALUES ($1)', ['a@b.com'])
).rejects.toThrow(/unique/i);
});
});
Fixtures & factories
Keep test data consistent and readable with a factory that fills in sensible defaults and lets each test override just what it cares about:
// factories.js
const { faker } = require('@faker-js/faker');
function makeUser(overrides = {}) {
return {
name: faker.person.fullName(),
email: faker.internet.email(),
password: 'password123',
...overrides,
};
}
module.exports = { makeUser };
// usage: const admin = makeUser({ role: 'admin' });
π‘ Frontend + backend, together
A full-stack integration test can start an in-memory database, register a user through the real API, then render the React app and log in β proving the whole slice works. It's powerful but slow, so reserve it for your one or two most critical journeys and lean on the cheaper layers for everything else.
Best Practices
β Do
- Test realistic journeys β the flows your users and business actually depend on.
- Mock at the boundary β the network (MSW) or the lowest layer β and keep the rest real.
- Reset state between tests β clear handlers, localStorage, and the database in
afterEach. - Await everything async and use
findBy/waitForinstead of arbitrary delays.
β οΈ Avoid
- Over-mocking. If you mock every collaborator, you've written a unit test wearing an integration test's coat.
- Flaky tests from race conditions β the usual cause is a missing
awaitor a fixedsetTimeout. - Shared mutable state that makes tests pass only in a particular order.
- Asserting on brittle internals instead of observable behaviour.
π‘ From the field: Intermittent CI failures in integration suites almost always trace back to unawaited async work or environment-specific timing. The durable fix is to await every asynchronous operation and query β not to sprinkle in retries and sleeps.
Hands-on Exercise
ποΈ Integration-test a checkout flow
Objective: Write a frontend integration test for a shopping-cart flow that spans a product list, a cart context, and an order service.
Instructions:
- Set up an MSW (v2) server mocking three endpoints:
GET /api/products,POST /api/discounts/validate, andPOST /api/orders. - Render the whole app inside its providers and router.
- Drive the flow as a user: wait for products, add two to the cart, apply the code
SAVE10, and submit the order. - Assert the running total updates after the discount and a confirmation appears after checkout.
π‘ Hint
Use await screen.findByText(...) to wait for the product list to load before interacting. Read the request body in each handler with await request.json() so you can return responses that depend on what was sent (e.g. reject any code other than SAVE10).
β Starter skeleton
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import App from './App';
const server = setupServer(
http.get('/api/products', () =>
HttpResponse.json({ products: [
{ id: 1, name: 'Product 1', price: 10.99 },
{ id: 2, name: 'Product 2', price: 24.99 },
] })
),
http.post('/api/discounts/validate', async ({ request }) => {
const { code } = await request.json();
return code === 'SAVE10'
? HttpResponse.json({ valid: true, discountPercent: 10 })
: HttpResponse.json({ valid: false, message: 'Invalid discount code' });
}),
http.post('/api/orders', async ({ request }) => {
const order = await request.json();
return HttpResponse.json({ id: 'order-1', ...order }, { status: 201 });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('completes the shopping flow from browsing to checkout', async () => {
const user = userEvent.setup();
render(<App />);
// 1. Wait for products, 2. add to cart, 3. apply SAVE10,
// 4. checkout, 5. assert the confirmation and discounted total.
});
π― Quick Quiz
Question 1: What does an integration test verify that a unit test typically cannot?
Question 2: In the login-flow test, why is MSW a better fit than stubbing fetch?
Question 3: Which tool lets you fire real HTTP requests at an Express app in-process for backend integration tests?
Summary & Quiz
π Key Takeaways
- Integration tests verify that real units cooperate β they catch the bugs between the pieces.
- Follow the pyramid: many unit tests, a solid band of integration tests, few E2E tests.
- On the frontend, mock only the network with MSW and drive real components, context, and routing.
- On the backend, use Supertest to exercise route β controller β service, mocking only the data layer.
- For real SQL and constraints, reach for Testcontainers; keep data tidy with factories.
π Further Reading
- Mock Service Worker β documentation
- Supertest β HTTP assertions for Node
- Testcontainers β throwaway real databases
π What's Next?
You've now tested JavaScript from the unit up to the integration level. Next we cross into the Python world with PyTest, and you'll see how many of these same ideas β fixtures, assertions, isolation β reappear in a different language.
π Excellent work!
You can now prove that whole slices of your app work end to end β the confidence that lets teams refactor and ship without fear.