EduFlow
Testing JavaScript Apps
0%

Section 3 · Testing the UI

Testing React Components and the Network

20 min3 min readCode playground

Lesson at a glance

Test React components through props, rendered output and user interaction with React Testing Library, and mock the network at its boundary with an injected fetch or MSW.

  1. 1Rendering a component
  2. 2Where to fake the network
  3. 3What not to test in a component
  4. 4Asserting on both sides of the request

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Everything in this section so far applies to React unchanged, because React Testing Library is a thin layer over the same queries and user-event you already know. The genuinely new part is the network: React components love to fetch, and how you fake that decides whether your tests catch real bugs.

Rendering a component

Splitwise-lite’s ExpenseList takes expenses as a prop and has one piece of state, a search filter:

src/ExpenseList.jsx
import { useState } from 'react';
import { formatMoney } from './money.js';

export function ExpenseList({ expenses }) {
  const [query, setQuery] = useState('');
  const visible = expenses.filter((e) =>
    e.description.toLowerCase().includes(query.trim().toLowerCase()),
  );

  return (
    <section aria-label="Expenses">
      <label>
        Search <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      {visible.length === 0 ? (
        <p>No matching expenses</p>
      ) : (
        <ul>
          {visible.map((e) => (
            <li key={e.id}>{e.description}: {formatMoney(e.amountCents)}</li>
          ))}
        </ul>
      )}
    </section>
  );
}

With @testing-library/react installed and environment: 'jsdom', tests read like the DOM tests you’ve written:

src/ExpenseList.test.jsx
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExpenseList } from './ExpenseList.jsx';

const expenses = [
  { id: 'e1', description: 'Dinner', amountCents: 4550 },
  { id: 'e2', description: 'Taxi', amountCents: 1800 },
];

test('filters expenses as you type', async () => {
  const user = userEvent.setup();
  render(<ExpenseList expenses={expenses} />);

  await user.type(screen.getByRole('textbox', { name: 'Search' }), 'tax');

  const items = screen.getAllByRole('listitem');
  expect(items.map((li) => li.textContent)).toEqual(['Taxi: $18.00']);
});

test('shows the new expense when the prop changes', () => {
  const { rerender } = render(<ExpenseList expenses={expenses} />);

  rerender(<ExpenseList expenses={[...expenses, { id: 'e3', description: 'Museum', amountCents: 2400 }]} />);

  expect(screen.getByText('Museum: $24.00')).toBeTruthy();
});

Props are tested by rendering with different values (and rerender for updates). State is tested by interacting and checking the result; there’s no way to read query from the test, and that’s a feature. render wraps updates in React’s act() for you, and React Testing Library unmounts components after each test when Vitest’s globals option is on (otherwise call cleanup in an afterEach).

Where to fake the network

A component that loads a group calls a data function, which calls fetch, which goes over the network. You can cut that chain at three points:

A chain from component to loadGroup to fetch to network. vi.mock cuts early and skips loadGroup; injecting fetch cuts after loadGroup; MSW intercepts at the network edge. Component loadGroup() fetch() Network vi.mock inject fetch MSW later cut = more real code tested
Cut the chain as late as you can
  • Mocking the data module (vi.mock('./load-group.js')) skips the URL building, status handling and response shaping. Those are exactly where the bugs are.
  • Injecting fetch (loadGroup(id, fetchImpl)) runs all of your code and fakes only the transport. Simple, no library, and what the exercise uses.
  • MSW (Mock Service Worker) intercepts requests at the network level in Node and in the browser. Your code calls the real fetch, unchanged:
src/test/server.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

export const server = setupServer(
  http.get('https://api.splitwise-lite.test/groups/:id', ({ params }) =>
    HttpResponse.json({ name: `Group ${params.id}`, members: [], expenses: [] }),
  ),
);

In a setup file, call server.listen({ onUnhandledRequest: 'error' }) before all tests, server.resetHandlers() after each, and server.close() at the end. A single test overrides a route with server.use(http.get(..., () => new HttpResponse(null, { status: 404 }))) to test the error path. The same handlers can power your app in development and in Storybook, so the fake API stays in one place.

What not to test in a component

Some component tests cost more than they catch. Skip them:

  • Styling details: whether a class is text-red-600 or the padding is 12px. Visual regressions are better caught by screenshot tests in a real browser, or a human review.
  • Third-party internals: you don’t need to prove that a date-picker library opens its calendar. Test that your component passes it the right value and reacts to its onChange.
  • Whole-tree snapshots: expect(container).toMatchSnapshot() on a large component fails on every harmless markup change, and reviewers learn to press “update snapshot” without reading. A small inline snapshot of one formatted value can be fine; a 300-line one is noise.

Asserting on both sides of the request

A good network test checks what you sent as well as what you did with the answer. A wrong URL is a real bug (/group/ instead of /groups/), and only an assertion on the request catches it, since your fake happily answers anything. With an injected fetch that’s expect(fetchMock).toHaveBeenCalledWith('https://api.splitwise-lite.test/groups/lisbon'); with MSW, the handler only matches the right path, and onUnhandledRequest: 'error' fails the test for anything else.

In the exercise you test loadGroup with an injected fetch that returns real Response objects, and catch four bugs in the request and the response shaping. That completes the UI section. Next comes the real browser, where layout, routing and the real server finally meet.

Key takeaways

  • React Testing Library’s render mounts a component into the jsdom document; you query and interact with it exactly as with plain DOM.
  • Test props by rendering with different props (or rerender) and state by interacting; never reach into state or hooks directly.
  • Mock the network at its edge, either by injecting fetch or by intercepting requests with MSW, so your own data-shaping code still runs in the test.
  • Assert on the request you sent (URL, method) as well as on what the UI or function does with the response, including error statuses.

Further reading

Check your understanding

3 questions

Question 1 of 3

How should a test check that ExpenseList filters as you type in the Search box?

Question 2 of 3

Why do many teams prefer MSW over vi.mock('./api.js') for components that fetch data?

Question 3 of 3

loadGroup forgot to check response.ok. For a 404, the JSON is { "error": "Not found" }, so the code throws Cannot read properties of undefined. Which assertion catches the missing check?

Practice

Code playground

Fake the network at its edge

loadGroup(groupId, fetchImpl) requests https://api.splitwise-lite.test/groups/<id>, converts the API’s JSON (dollars, snake_case) into Splitwise-lite’s shape (cents, camelCase), and rejects with Could not load group <id> (HTTP <status>) when the response isn’t ok. The exact shapes are in the subject’s comment.

The starter gives you jsonResponse(body, status), which builds a real Response, and an apiGroup sample. Pass a vi.fn().mockResolvedValue(jsonResponse(...)) as fetchImpl and add tests until your suite catches four hidden bugs: one ignores the HTTP status, one requests the wrong URL, one forgets to convert dollars to cents, and one reads paidBy from the wrong field.

const API = 'https://api.splitwise-lite.test';

// Loads a group: GET `${API}/groups/<id>` (id URL-encoded).
// The API answers with JSON like:
//   { "name": "Lisbon trip",
//     "members": [{ "name": "Ana" }, { "name": "Ben" }],
//     "expenses": [{ "description": "Dinner", "amount": 45.5,
//                    "paid_by": "Ana", "split_among": ["Ana", "Ben"] }] }
// and loadGroup resolves with Splitwise-lite's own shape:
//   { name: "Lisbon trip", members: ["Ana", "Ben"],
//     expenses: [{ description: "Dinner", amountCents: 4550,
//                  paidBy: "Ana", splitAmong: ["Ana", "Ben"] }] }
// If the response is not ok, it rejects with
//   "Could not load group <id> (HTTP <status>)".
async function loadGroup(groupId, fetchImpl = fetch) {
  const response = await fetchImpl(`${API}/groups/${encodeURIComponent(groupId)}`);
  if (!response.ok) {
    throw new Error(`Could not load group ${groupId} (HTTP ${response.status})`);
  }
  const data = await response.json();
  return {
    name: data.name,
    members: data.members.map((member) => member.name),
    expenses: data.expenses.map((e) => ({
      description: e.description,
      amountCents: Math.round(e.amount * 100),
      paidBy: e.paid_by,
      splitAmong: e.split_among,
    })),
  };
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real loadGroup
  • Catches the version that ignores the HTTP status
  • Catches the version that requests the wrong URL
  • Catches the version that keeps dollars instead of cents
  • Catches the version that reads paidBy from the wrong field