Testing React Apps
Roughly 10% of a typical loop — and 10% of the mock exam here.
Test behavior, not implementation
React Testing Library is built on one principle: the more a test resembles the way users use the software, the more confidence it buys. A test queries what a user perceives — an accessible role and name, visible text, a label — interacts the way a user would, and asserts on rendered output. It never reaches into state, props, or instance internals. The interview trap is any answer that inspects component state; the resolving rule is that a refactor which keeps behavior identical must keep the test green.
Query priority follows accessibility: getByRole first (with the name option), then getByLabelText, getByPlaceholderText, getByText, getByDisplayValue; getByAltText and getByTitle after those; getByTestId as the escape hatch of last resort. A line worth saying out loud: if getByRole cannot find your button, a screen reader probably cannot either.
getBy vs queryBy vs findBy
getBy*throws when nothing matches — assert presence synchronously.queryBy*returnsnull— the only correct family for asserting absence, paired withnot.toBeInTheDocument().findBy*returns a promise (roughlygetBywrapped inwaitFor) — for elements that appear after async work.- Each has an
AllByvariant for lists; the singular forms throw on multiple matches.
The classic trap: asserting absence with getBy — it throws before your matcher ever runs, so the test fails for the wrong reason.
user-event vs fireEvent
fireEvent.click dispatches one DOM event. userEvent simulates the full interaction a browser produces: type fires keydown, keypress, input, and keyup per character; click moves the pointer and fires hover events first; disabled elements and pointer-events: none are respected. Since user-event v14 you create a session with userEvent.setup() and every method returns a promise — always await it. Default to user-event; drop to fireEvent only for low-level events user-event does not model, such as scroll.
const user = userEvent.setup();
render(<Search />);
await user.type(screen.getByRole('searchbox'), 'hooks');
expect(await screen.findByRole('list')).toBeInTheDocument();
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
Async UI and mocking
findBy* covers the appear case; waitFor retries an assertion callback until it passes or times out — keep it to assertions with no side effects, because the callback runs repeatedly. waitForElementToBeRemoved is the idiomatic spinner-goes-away helper.
Mock at the network boundary when you can: MSW intercepts requests so your real fetch code still runs; otherwise stub global.fetch with vi.fn or jest.fn returning a Response-shaped object per test. For module mocks, remember vi.mock and jest.mock calls are hoisted above imports — a mock factory that references a top-level variable is a classic gotcha, which is why Jest exempts variables prefixed with mock.
Hooks, act, and jest-dom
renderHook — bundled into @testing-library/react since v13.1, replacing the separate react-hooks package — mounts a hook inside a throwaway component; read result.current, and wrap updates in act. Reach for it only when a hook has no reasonable component surface; otherwise test through a component.
In React 19, act is imported from react itself and react-dom/test-utils is deprecated. Testing Library already wraps render, fireEvent, and user-event calls in act, so a “not wrapped in act” warning almost always means an update fired outside your helpers — typically state set after an await the test never waited for. The fix is awaiting the UI with findBy or waitFor, not sprinkling manual act calls.
@testing-library/jest-dom supplies the readable matchers — toBeInTheDocument, toBeVisible, toBeDisabled, toHaveAccessibleName, toHaveValue — and works in Vitest via import '@testing-library/jest-dom/vitest' in the setup file. Prefer these semantic matchers over class or attribute string checks.
Sample questions
6 of the 20 questions this domain carries in practice mode — expand one to check yourself before drilling.
1. The guiding principle behind React Testing Library is:
- tests should assert on internal state and props to catch every regression at its exact source
- tests should snapshot the entire DOM so any markup change fails the suite
- the more tests resemble the way users use the software, the more confidence they give
- tests must run in a real browser because jsdom cannot execute React
Answer: C. That guiding principle is why RTL exposes no component instances — queries by role and text survive refactors that keep user-visible behavior identical.
2. According to Testing Library's query-priority guidance, the first query to reach for is:
- getByRole, because it mirrors how assistive technology exposes the element
- getByTestId, because test ids never change when markup is refactored
- querySelector with a CSS class, since classes already exist in the markup
- getByTitle, because every element is allowed to carry a title attribute
Answer: A. Role queries double as accessibility checks — if getByRole cannot find an element, assistive tech probably cannot either; test ids sit last in the priority list.
3. A search box filters a list on keyDown. fireEvent.change fills the input but the keyboard handler never runs. The best fix is:
- manually dispatch a synthetic KeyboardEvent for every single character of the search query
- wrap the fireEvent.change call in act() so the keyboard handler flushes
- type with userEvent.type, which fires keydown, keypress, input and keyup per character
- move the filtering logic to onChange because jsdom cannot deliver key events
Answer: C. fireEvent dispatches one isolated event; user-event replays the full sequence a real keystroke produces, so handlers listening on any of those events all fire.
4. After clicking Save, the test asserts the success toast synchronously and fails, though the toast shows up fine in the browser. The fix is:
- insert a fixed one-second setTimeout before the assertion to let the toast render
- read the toast flag from the component instance instead of querying the DOM
- call render a second time after the click so the DOM picks up the change
- wrap the assertion in await waitFor, which retries until it passes or times out
Answer: D. waitFor polls its callback — by default every 50 ms for up to 1000 ms — so the test resumes the moment the UI updates instead of sleeping a fixed interval.
5. With renderHook, a test destructures const { count, increment } = result.current, calls act(() => increment()), then asserts count is 1 — but count is still 0. Why?
- the destructured count is a stale primitive; read result.current.count again after the update
- increment must run outside of act because act suspends every state update until the test tears down
- renderHook memoizes returned primitives, so they never change between renders
- the assertion needs waitFor because every setState is deferred by one macrotask
Answer: A. renderHook swaps result.current for the hook's latest return value on every render; values captured by destructuring stay frozen at the render they came from.
6. vi.mock('./api', () => ({ fetchUser: mockFetchUser })) throws 'Cannot access mockFetchUser before initialization'. The reason is:
- vi.mock only accepts __mocks__ folder paths, never an inline factory function
- default imports cannot be mocked, so the factory is rejected at compile time
- mock variables must use var, because let and const are frozen inside factories
- vi.mock calls are hoisted above imports and declarations, so the factory runs first
Answer: D. jest.mock and vi.mock are both hoisted to the top of the module; define values inside the factory or with vi.hoisted so they exist when it executes.
Drill this domain in practice mode →
Independent community study resource — not affiliated with or endorsed by Meta Platforms, Inc. React is a trademark of Meta Platforms, Inc. All questions and study notes are original, written from the official React documentation. Everything runs in your browser; nothing you answer is stored or transmitted.