FSD Frontend System Design

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.

  • Time18 min
  • DiagramQuery priority ladder + async waiting rules
  • Chapter4 Β· Testing

Diagram

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.
diagram
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 async
diagram
THE 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 sequence

How it works

  1. Render the real component tree with its real providers β€” a custom renderWithProviders keeps router, store, and query client wired in one place.
  2. Interact with userEvent, not fireEvent: it fires the full pointer/keyboard sequence a browser would.
  3. Mock the network with MSW, so serialization, loading states, and error paths are genuinely exercised.
  4. Use fake timers for debounce, and remember to advance them inside act.
  5. Test the states, not the internals: loading, empty, error, success, and the disabled/edge case.
js
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

DoDon't
Assert on rendered outputAssert on useState values
Query by role and labelQuery by CSS class
Mock at the network (MSW)jest.mock every module
Test hooks through a componentTest 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 β€” never sleep.
  • Cover all four states β€” loading, empty, error, success β€” since the last three are where production bugs actually live.

Recap

  • Role and label queries first; getByTestId is an admission of defeat.
  • getBy now, queryBy for absence, findBy for async.
  • userEvent over fireEvent, MSW over module mocks, states over internals.