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.
- 1Rendering a component
- 2Where to fake the network
- 3What not to test in a component
- 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:
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:
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:
- 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:
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-600or 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
rendermounts 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
fetchor 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.