Skip to main content

πŸ”— Integration Testing

A car with a flawless engine, flawless wheels, and flawless steering can still fail β€” if the steering isn't connected to the wheels. Unit tests check each part alone; integration tests check that the parts work together. In this lesson you'll test whole React features: several components sharing state, talking to context, and reacting to realistic network responses.

Week 5 · Day 5 (Friday: Testing React Components) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the difference between a unit test and an integration test, and where each pays off
  • Test a feature made of several components communicating through a shared parent
  • Render components inside their context providers with a custom render helper
  • Mock the network realistically with Mock Service Worker (MSW)
  • Drive a full user flow end-to-end and assert on the resulting UI
  • Keep integration tests resilient by asserting on behavior, not structure

Estimated Time: 75 minutes

Practice: Test a shopping-cart feature and a login flow backed by a mocked API.

In This Lesson

Unit vs Integration

A unit test checks one piece in isolation β€” a single component, a single hook. An integration test checks several pieces wired together: a parent that owns state, the children that render it, and the events that flow back up. Because React apps are fundamentally about components collaborating, integration tests often give the most confidence per line of test code.

The distinction is a spectrum, not a wall. RTL uses the same API for both β€” the difference is how much you render. Render one component: unit. Render a feature's whole component tree and drive it: integration.

Unit testIntegration test
Scopeone component / hooka feature: many components together
Mocksmost collaboratorsonly the network / true boundaries
Catcheslogic bugs in a partwiring bugs between parts
Confidencemoderatehigh

The Testing Trophy

The older "testing pyramid" said write mostly unit tests. Kent C. Dodds' Testing Trophy updates that for component apps: put the bulk of your effort into integration tests, which hit the sweet spot of confidence versus cost.

graph TD A[End-to-End
few, slow, high confidence] --> B[Integration
the bulk β€” best value] B --> C[Unit
fast, for tricky logic] C --> D[Static
TypeScript, ESLint]

Integration tests sit in the widest band because a test that renders a real feature and clicks through it resembles actual usage closely β€” so when it passes, you can ship with confidence, and it rarely breaks on harmless refactors.

Testing Component Interaction

Here's a classic multi-component feature: a product list and a cart, coordinated by a parent that owns the cart state. A unit test of <Cart> alone wouldn't prove that clicking "Add to Cart" on a product actually updates the cart β€” that's the wiring an integration test verifies.

function Product({ product, onAddToCart }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product)}>Add {product.name}</button>
    </div>
  );
}

function Cart({ items, onRemove }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return (
    <div>
      <h2>Shopping Cart</h2>
      {items.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <>
          <ul>
            {items.map((item) => (
              <li key={item.id}>
                {item.name} β€” ${item.price}
                <button onClick={() => onRemove(item.id)}>Remove {item.name}</button>
              </li>
            ))}
          </ul>
          <p>Total: ${total}</p>
        </>
      )}
    </div>
  );
}

function ShoppingApp() {
  const [cart, setCart] = useState([]);
  const products = [
    { id: 1, name: 'Keyboard', price: 40 },
    { id: 2, name: 'Mouse', price: 20 },
  ];
  const addToCart = (p) => setCart((prev) => [...prev, p]);
  const removeFromCart = (id) =>
    setCart((prev) => prev.filter((i) => i.id !== id));

  return (
    <div>
      <h1>Shop</h1>
      {products.map((p) => (
        <Product key={p.id} product={p} onAddToCart={addToCart} />
      ))}
      <Cart items={cart} onRemove={removeFromCart} />
    </div>
  );
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('adds and removes products across the whole feature', async () => {
  const user = userEvent.setup();
  render(<ShoppingApp />);

  // starts empty
  expect(screen.getByText('Your cart is empty')).toBeInTheDocument();

  // add two products β€” a Product click updates the Cart
  await user.click(screen.getByRole('button', { name: 'Add Keyboard' }));
  await user.click(screen.getByRole('button', { name: 'Add Mouse' }));

  expect(screen.getByText('Keyboard β€” $40')).toBeInTheDocument();
  expect(screen.getByText('Mouse β€” $20')).toBeInTheDocument();
  expect(screen.getByText('Total: $60')).toBeInTheDocument();

  // remove one β€” the total recalculates
  await user.click(screen.getByRole('button', { name: 'Remove Keyboard' }));

  expect(screen.queryByText('Keyboard β€” $40')).not.toBeInTheDocument();
  expect(screen.getByText('Total: $20')).toBeInTheDocument();
});

One test, three components, real state flow. It reads like a shopper's session and would catch a broken onAddToCart prop, a bad reducer, or a total that doesn't recompute β€” bugs no single-component unit test would see.

πŸ“– Distinct accessible names help

Notice the buttons say "Add Keyboard" and "Remove Keyboard" rather than a generic "Add". Distinct accessible names let getByRole('button', { name: ... }) target exactly one element. When you truly have repeated names, getAllByRole returns an array you can index.

Testing with Context

Components that read from a Context provider will crash if you render them bare. You must wrap them in their provider β€” just like the real app does. Here's a theme toggle.

const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () =>
    setTheme((t) => (t === 'light' ? 'dark' : 'light'));
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function ThemedButton() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  return (
    <button onClick={toggleTheme}>Current theme: {theme}</button>
  );
}
test('toggles the theme through context', async () => {
  const user = userEvent.setup();
  render(
    <ThemeProvider>
      <ThemedButton />
    </ThemeProvider>
  );

  const button = screen.getByRole('button');
  expect(button).toHaveTextContent('Current theme: light');

  await user.click(button);
  expect(button).toHaveTextContent('Current theme: dark');

  await user.click(button);
  expect(button).toHaveTextContent('Current theme: light');
});

A Custom render Helper

Real apps stack several providers β€” theme, router, a data client, auth. Wrapping every test by hand gets noisy. The standard fix is a custom render that bundles the providers once and re-exports everything from RTL. Put it in a test-utils module and import that instead of @testing-library/react.

// test-utils.jsx
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeProvider';

function AllProviders({ children }) {
  return <ThemeProvider>{children}</ThemeProvider>;
}

function renderWithProviders(ui, options) {
  return render(ui, { wrapper: AllProviders, ...options });
}

// re-export everything, then override render
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';
export { renderWithProviders as render };
// ThemedButton.test.jsx β€” clean, no manual wrapping
import { render, screen, userEvent } from './test-utils';
import { ThemedButton } from './ThemedButton';

test('renders inside all providers automatically', async () => {
  const user = userEvent.setup();
  render(<ThemedButton />);          // providers applied for free
  await user.click(screen.getByRole('button'));
  expect(screen.getByRole('button')).toHaveTextContent('dark');
});

βœ… Why this pattern scales

Add a new global provider (say a Router) in one place β€” AllProviders β€” and every test picks it up. The wrapper option is the officially recommended way to supply context to a render.

Realistic Network Mocking with MSW

Hand-mocking fetch works for one hook, but in an integration test your feature may fire several requests, and you want your real fetching code to run untouched. Mock Service Worker (MSW) intercepts requests at the network layer, so your components call real fetch and MSW answers β€” the closest thing to a real backend without one.

sequenceDiagram participant C as Component participant F as fetch() participant M as MSW handler C->>F: fetch('/api/users/1') F->>M: request intercepted M-->>F: mocked JSON response F-->>C: resolves with data C->>C: re-renders with user

MSW v2 uses http handlers and the HttpResponse helper. Set up a server once, wire it into your test lifecycle, and override handlers per test when you need an error case.

// server.js
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

export const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Ada Lovelace',
      email: 'ada@example.com',
    });
  })
);
// setupTests.js β€” start/stop the server around the whole suite
import { server } from './server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // undo per-test overrides
afterAll(() => server.close());
import { http, HttpResponse } from 'msw';
import { render, screen } from '@testing-library/react';
import { server } from './server';
import { UserProfile } from './UserProfile';

test('loads and displays a user', async () => {
  render(<UserProfile userId="1" />);

  // loading first
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // findBy waits for the network round-trip
  expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
  expect(screen.getByText('Email: ada@example.com')).toBeInTheDocument();
});

test('shows an error when the server fails', async () => {
  // override just for this test
  server.use(
    http.get('/api/users/:id', () =>
      new HttpResponse(null, { status: 500 })
    )
  );

  render(<UserProfile userId="1" />);

  expect(await screen.findByText(/Error/i)).toBeInTheDocument();
});

πŸ’‘ One source of truth for responses

Because MSW sits at the network boundary, the same handlers can back your tests and your local dev environment (via a browser service worker). Your mocks and your real API contract stay in one place.

A Full Login Flow

Let's tie it together: a login form that posts credentials, shows a success message on 200 and an error on 401. This is an integration test in the truest sense β€” form input, an async request, and conditional UI, all exercised as one user journey.

function LoginForm() {
  const [status, setStatus] = useState('idle');
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setStatus('loading');
    const data = new FormData(e.target);
    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email: data.get('email'),
          password: data.get('password'),
        }),
      });
      const body = await res.json();
      if (!res.ok) throw new Error(body.message);
      setStatus('success');
    } catch (err) {
      setStatus('error');
      setError(err.message);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Logging in…' : 'Login'}
      </button>
      {status === 'error' && <div role="alert">{error}</div>}
      {status === 'success' && <p>Login successful!</p>}
    </form>
  );
}
import { http, HttpResponse } from 'msw';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from './server';
import { LoginForm } from './LoginForm';

const login = http.post('/api/login', async ({ request }) => {
  const { email, password } = await request.json();
  if (email === 'ada@example.com' && password === 's3cret!') {
    return HttpResponse.json({ token: 'abc123' });
  }
  return HttpResponse.json({ message: 'Invalid credentials' }, { status: 401 });
});

test('logs in successfully with valid credentials', async () => {
  server.use(login);
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.type(screen.getByLabelText('Email'), 'ada@example.com');
  await user.type(screen.getByLabelText('Password'), 's3cret!');
  await user.click(screen.getByRole('button', { name: 'Login' }));

  expect(await screen.findByText('Login successful!')).toBeInTheDocument();
});

test('shows an error for bad credentials', async () => {
  server.use(login);
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.type(screen.getByLabelText('Email'), 'wrong@example.com');
  await user.type(screen.getByLabelText('Password'), 'nope');
  await user.click(screen.getByRole('button', { name: 'Login' }));

  expect(await screen.findByRole('alert'))
    .toHaveTextContent('Invalid credentials');
});

Practice & Quiz

πŸ‹οΈ Exercise 1: A todo feature

Goal: A <TodoApp> renders an input, an "Add" button, and a list. Write one integration test that types a todo, adds it, sees it in the list, then marks it complete (a checkbox) and asserts it's checked.

πŸ’‘ Hint

Use getByRole('textbox') for the input, getByRole('button', { name: 'Add' }) to add, then getByRole('checkbox', { name: /buy milk/i }). Assert with toBeChecked() after clicking.

βœ… Solution
test('adds and completes a todo', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);

  await user.type(screen.getByRole('textbox'), 'Buy milk');
  await user.click(screen.getByRole('button', { name: 'Add' }));

  expect(screen.getByText('Buy milk')).toBeInTheDocument();

  const checkbox = screen.getByRole('checkbox', { name: /buy milk/i });
  expect(checkbox).not.toBeChecked();

  await user.click(checkbox);
  expect(checkbox).toBeChecked();
});

πŸ‹οΈ Exercise 2: An error path with MSW

Goal: Given a <PostList> that fetches /api/posts, write a test that overrides the MSW handler to return a 500 and asserts an error message appears. Which query waits for the async error to render?

βœ… Solution
test('renders an error when the API fails', async () => {
  server.use(
    http.get('/api/posts', () => new HttpResponse(null, { status: 500 }))
  );

  render(<PostList />);

  // findBy waits for the error UI after the failed request
  expect(await screen.findByText(/something went wrong/i))
    .toBeInTheDocument();
});

🎯 Quick Quiz

Question 1: What is the main thing an integration test catches that a unit test often misses?

Question 2: A component reads from a Context provider. What must your test do?

Question 3: Why prefer MSW over mocking fetch for integration tests?

Best Practices & Pitfalls

βœ… Do

  • Render the whole feature's tree and drive it as a user would
  • Mock only true boundaries (the network); let your real components collaborate
  • Use a custom render with a wrapper to supply providers once
  • Give elements distinct accessible names so role queries stay unambiguous
  • Reset MSW handlers in afterEach so per-test overrides don't leak

❌ Don't

  • Mock the child components you're trying to integrate β€” you'd test nothing real
  • Assert on internal state or class names; assert on visible behavior
  • Forget to await async UI β€” use findBy/waitFor
  • Let MSW's server.listen() stay open past the suite (always server.close())

⚠️ Don't over-mock

The temptation in an integration test is to jest.mock('./Cart') so the test is "simpler." Resist it β€” mocking the very components you're integrating defeats the purpose. Mock the network and truly external services; keep your own components real.

Summary

πŸŽ‰ Key Takeaways

  • Integration tests verify components working together β€” the wiring, not just the parts
  • The Testing Trophy favors integration tests for the best confidence-to-cost ratio
  • Render context-dependent components inside their providers, ideally via a custom render
  • MSW mocks the network realistically so your real fetching code runs
  • Drive whole flows with userEvent and assert on visible behavior
  • Don't mock the components you're integrating β€” mock only the boundaries

πŸ“š Additional Resources

πŸš€ What's Next?

You've completed the testing arc β€” unit, hooks, async, and integration. Time to put your whole Advanced React toolkit to work by shipping a real project: Build a Multi-Page React Application with routing and the performance optimizations from this week.

πŸ”— Everything connected!

You can now test a feature the way your users experience it β€” the highest-confidence tests in your toolkit.