Component Tests
React Testing Library, Flutter widget tests, RN Testing Library — testing what the user sees and does
Component Tests
Component tests are where most of the testing value lives in a frontend codebase. They render a real component with real (or controlled) state, simulate the user's actions, and assert what the user would observe. They are slower than unit tests but far faster than end-to-end — and they catch the class of defects that matters most for UI work.
The guiding principle, attributed to Kent C. Dodds:
The more your tests resemble the way your software is used, the more confidence they can give you.
In practice: query by what the user sees (role, label, text), interact through events the user produces (click, type, tap), assert against the rendered DOM or widget tree.
React (Web): React Testing Library
The default for any modern React app. RTL renders the component into JSDOM and provides queries that mirror how a user finds elements.
Anatomy of a good test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderForm } from './OrderForm';
test('submits the order with the entered quantity', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<OrderForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/quantity/i), '3');
await user.click(screen.getByRole('button', { name: /place order/i }));
expect(onSubmit).toHaveBeenCalledWith({ quantity: 3 });
});Three things this test gets right:
- Queries by role and label, not by class name or test-id. If the markup is refactored from
<input>to a custom component, the test still passes as long as the input is still labeled "Quantity." userEventinstead offireEvent.userEventsimulates the full sequence (focus, key events, change events, blur);fireEventskips most of that and can hide bugs that only show up in the real flow.- Asserts the contract (
onSubmitwas called with the right payload), not the internal state of the form.
Query priority
RTL prioritizes queries from "most user-like" to "least":
getByRole— what assistive tech sees. Default for buttons, links, headings, inputs.getByLabelText— for form fields.getByPlaceholderText— for inputs without labels (rare; usually means the form needs a label).getByText— for non-interactive copy.getByDisplayValue— for finding an input by its current value.getByAltText/getByTitle— for images and titled elements.getByTestId— escape hatch when nothing above works.
If most assertions reach for getByTestId, the component is probably not accessible. Fix the markup, not the test.
Async queries
Anything that arrives after a render — data fetch, animation, debounce — is async.
test('shows the loaded user', async () => {
render(<UserProfile id={1} />);
// Wait for the async render to finish
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
test('shows an error when the request fails', async () => {
server.use(rest.get('/users/1', (req, res, ctx) => res(ctx.status(500))));
render(<UserProfile id={1} />);
expect(await screen.findByRole('alert')).toHaveTextContent(/failed to load/i);
});findBy* returns a promise that resolves when the element appears or rejects on timeout. Use it instead of waitFor + getBy* when the assertion is "this element should appear."
Mocking network: MSW
Mock at the network layer, not the data layer.
// test/setup.ts
import { setupServer } from 'msw/node';
import { rest } from 'msw';
export const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: Number(req.params.id), name: 'Alice' }));
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());onUnhandledRequest: 'error' catches the most common test bug: a real fetch escapes the test because no handler matched. Be strict.
Override a handler per test:
test('handles 404', async () => {
server.use(rest.get('/api/users/:id', (req, res, ctx) => res(ctx.status(404))));
render(<UserProfile id={1} />);
expect(await screen.findByText(/not found/i)).toBeInTheDocument();
});Common React testing pitfalls
Asserting on internal state. wrapper.state('count') is testing the implementation; switch to a Hooks-based component and the test breaks for no good reason. Assert on rendered output.
Selecting by class name. CSS modules, Tailwind, styled-components — class names are not stable. Use role/text queries.
Snapshot of the entire tree. A wall-sized snapshot that no one reads is documentation, not testing. Reserve snapshots for outputs that are otherwise awkward to assert (a generated SQL string, a long structural fragment) and keep them tight.
act warnings ignored. The "An update to X inside a test was not wrapped in act(...)" warning means an async state update happened outside a controlled point in the test. The fix is await user.click(...), await screen.findBy*(...), or await waitFor(...), depending on what triggered the update.
Flutter: widgetTest
flutter test runs widget tests in a headless test environment. The component is "pumped" into a tree and interacted with via WidgetTester.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/order/order_form.dart';
void main() {
testWidgets('submits the order with the entered quantity',
(WidgetTester tester) async {
OrderPayload? submitted;
await tester.pumpWidget(MaterialApp(
home: OrderForm(onSubmit: (p) { submitted = p; }),
));
await tester.enterText(find.byKey(const Key('quantity')), '3');
await tester.tap(find.text('Place order'));
await tester.pumpAndSettle();
expect(submitted, OrderPayload(quantity: 3));
});
}Finders
| Finder | What it matches |
|---|---|
find.text('...') | Any widget rendering that text |
find.byKey(Key('...')) | A widget with a specific Key |
find.byType(MyWidget) | All instances of a widget class |
find.byIcon(Icons.add) | An icon widget |
find.bySemanticsLabel('...') | By accessibility label |
Prefer text and semantic labels over Keys; reserve Keys for cases where text is ambiguous (two buttons labeled "Save"). The reasoning is the same as RTL's: text and semantic labels survive refactors that Keys do not.
Pump vs pumpAndSettle
await tester.pump()advances one frame.await tester.pump(Duration(milliseconds: 100))advances 100ms.await tester.pumpAndSettle()keeps pumping until no animations are pending.
pumpAndSettle is convenient but dangerous: if a widget animates forever (e.g., a CircularProgressIndicator), it will hang. For widgets with infinite animations, use explicit pump(duration) instead.
Mocking in widget tests
For network calls, swap the HTTP client at the boundary.
testWidgets('shows error when fetch fails', (tester) async {
final api = _MockApi();
when(() => api.fetchUser(1)).thenThrow(NetworkException());
await tester.pumpWidget(
ProviderScope(
overrides: [apiProvider.overrideWithValue(api)],
child: const MaterialApp(home: UserPage(id: 1)),
),
);
await tester.pumpAndSettle();
expect(find.text('Failed to load'), findsOneWidget);
});Riverpod's overrides, Provider's Provider.value, and Bloc's mock streams all provide the same shape: replace the dependency at the boundary, render with the substitution.
Golden tests
Flutter ships a built-in visual regression mechanism.
testWidgets('order summary matches golden', (tester) async {
await tester.pumpWidget(MaterialApp(home: OrderSummary(...)));
await expectLater(
find.byType(OrderSummary),
matchesGoldenFile('goldens/order_summary.png'),
);
});Regenerate goldens with flutter test --update-goldens. Two cautions:
- Goldens are platform-sensitive: a golden generated on macOS will fail on Linux CI. Generate them on the same OS that runs CI.
- Fonts must be loaded before the snapshot, or the rendered text falls back to Ahem (a square-glyph test font) and goldens look wrong. Use
loadAppFonts()fromgolden_toolkit.
React Native: RN Testing Library
The API is a port of RTL with platform-appropriate queries.
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { OrderForm } from './OrderForm';
test('submits the order with the entered quantity', async () => {
const onSubmit = jest.fn();
render(<OrderForm onSubmit={onSubmit} />);
fireEvent.changeText(screen.getByLabelText('Quantity'), '3');
fireEvent.press(screen.getByRole('button', { name: 'Place order' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ quantity: 3 });
});
});Differences from web RTL:
fireEvent.pressinstead ofclick.fireEvent.changeTextinstead of typing per character.- No
userEvent(yet); the simulated events are coarser than on web. - Animations are not driven; use
act(() => jest.advanceTimersByTime(...))for timing-dependent UI.
The query priority is the same: role → label → text → testID.
Common RN testing pitfalls
Native modules not mocked. RN Testing Library runs in JSDOM-like environment, not on a device. Anything that calls into a native module (camera, file system, push notifications) must be mocked.
// jest.setup.js
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');Reanimated and Gesture Handler. Both require their official mocks in jest.setup.js. Without them, components that use them throw on render.
act warnings around useEffect. Same root cause as web React: an async update happened outside a controlled point. Fix with await waitFor(...).
What to Assert
A useful checklist for any component test:
- Renders the expected primary content in the default state.
- Renders each documented state (loading, error, empty, populated).
- Handles user input (typing, clicking, scrolling) and asserts the result.
- Calls callbacks with the right payload on submit, change, etc.
- Recovers from errors the design specifies recovery for (retry button, error banner).
What not to assert:
- The exact CSS class name in the DOM.
- That a particular hook was called.
- The internal state shape of the component.
- Implementation details of the framework.
Pre-Commit Checklist
Before submitting a PR with component tests:
- Queries are user-centric (role, label, text) — testIDs only as escape hatch.
- Async work is awaited with
findBy*,waitFor, orpumpAndSettle— no sleeps. - Network is mocked at the transport layer (MSW, mock HTTP client).
- Each documented state has a test.
- No assertions against internal state, internal classes, or framework behavior.
-
actwarnings (if any) are diagnosed and fixed, not suppressed.