Part II β The Shield Β· Lesson 4.8
[BONUS] Testing React Components
In one lineQuery the way a user finds things, act the way a user acts, assert on what a user sees.
Where you've seen itA search box test that types three characters, waits out the debounce, and asserts on rendered results β without ever reaching into state.
Diagram
QUERY PRIORITY LADDER β go down only when you must
1. getByRole('button', {name:'Save'}) β how AT and users find it
2. getByLabelText('Email') β form fields
3. getByPlaceholderText / getByText β visible content
4. getByDisplayValue / getByAltText
5. getByTestId('row-9') β escape hatch, last resort
Using level 1 also proves your markup is accessible.
A component you can't query by role usually has an a11y bug.get vs query vs find β three different questions
getBy* must exist NOW throws if missing
queryBy* may not exist returns null β the only one for
asserting absence
findBy* will exist SOON returns a promise, retries ~1s
β anything asyncTHE THREE ASYNC MISTAKES
β expect(screen.getByText('Done')) not rendered yet β fail
β expect(await screen.findByText('Done')).toBeVisible()
β await new Promise(r => setTimeout(r, 500)) guessing
β await waitFor(() => expect(fn).toHaveBeenCalled())
β fireEvent.change(input, {target:{value:'a'}}) skips focus, keydown
β await user.type(input, 'a') full event sequenceHow it works
- Render the real component tree with its real providers β a custom
renderWithProviderskeeps router, store, and query client wired in one place. - Interact with
userEvent, notfireEvent: it fires the full pointer/keyboard sequence a browser would. - Mock the network with MSW, so serialization, loading states, and error paths are genuinely exercised.
- Use fake timers for debounce, and remember to advance them inside
act. - Test the states, not the internals: loading, empty, error, success, and the disabled/edge case.
const renderWithProviders = (ui) =>
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
);
test('shows results after debounce', async () => {
const user = userEvent.setup();
renderWithProviders(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'pizza');
expect(screen.queryByRole('listitem')).toBeNull(); // still debouncing
expect(await screen.findAllByRole('listitem')).toHaveLength(3);
});Trade-offs
| Do | Don't |
|---|---|
| Assert on rendered output | Assert on useState values |
| Query by role and label | Query by CSS class |
| Mock at the network (MSW) | jest.mock every module |
| Test hooks through a component | Test hooks in isolation by default |
Custom hooks are the one exception: renderHook is fine for genuinely reusable, logic-heavy hooks. For hooks used by exactly one component, test the component.
Interview angle
"How do you test a component that fetches data?"
- MSW intercepts at the network layer, so the real fetch, real serialization, and real error branches all run.
findBy*for the resolved state,queryBy*to assert the loading state is gone β neversleep.- Cover all four states β loading, empty, error, success β since the last three are where production bugs actually live.
Recap
- Role and label queries first;
getByTestIdis an admission of defeat. getBynow,queryByfor absence,findByfor async.userEventoverfireEvent, MSW over module mocks, states over internals.