Skip to main content

πŸ”— 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.

The testing pyramid A pyramid with many fast unit tests at the base, fewer integration tests in the middle, and a small number of slow end-to-end tests at the top. E2E ~10% Integration ~20% Unit ~70% slower faster
Figure 1 β€” A rough, widely-cited split: many unit tests, a meaningful band of integration tests, few end-to-end tests. Treat the percentages as a shape to aim for, not a law.

πŸ’‘ 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.
ToolLayerWhat it does
Jest / VitestAnyRuns tests and provides assertions & mocks
React Testing LibraryFrontendRenders and drives components from the user's view
MSWFrontendMocks HTTP at the network level (no fetch stubbing)
SupertestBackendFires real HTTP requests at an Express/Node app
TestcontainersBackendSpins up throwaway real databases in Docker
mindmap root((Integration focus)) Data flow Component to component Through context / store Workflows Login Checkout Navigation External services API responses Error scenarios Auth Cross-cutting Error handling Loading states

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.

flowchart LR A[LoginForm] --> B[authService] B --> C[UserContext] C --> D[ProtectedRoute] D --> E[Dashboard]

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.

flowchart LR A[POST /api/users] --> B[Controller] B --> C[Service] C --> D[Repository] D --> E[(Database)]

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/waitFor instead 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 await or a fixed setTimeout.
  • 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:

  1. Set up an MSW (v2) server mocking three endpoints: GET /api/products, POST /api/discounts/validate, and POST /api/orders.
  2. Render the whole app inside its providers and router.
  3. Drive the flow as a user: wait for products, add two to the cart, apply the code SAVE10, and submit the order.
  4. 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

πŸš€ 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.