React Interview Masterclass

Lesson 1 of 9

Testing React

"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds

You have spent seven parts of this Volume learning how React works — how it renders, holds state, runs effects, and recovers from errors. This part is about proving it still works tomorrow, after the next refactor, the next feature, the next tired Friday commit. That proof is what tests are. But there is a right way and a wrong way to write them, and the difference is the single most important idea in this chapter: a good test knows what the user does, not how the component is built. Interviewers love this topic precisely because it separates people who can write React from people who can ship it and sleep at night — and because the wrong instinct here is so common that saying the right thing out loud is a strong signal.

The question this chapter answers

You want tests that catch real bugs but survive honest refactors. So what exactly should a React test look at — the pixels a user sees, or the state and props inside the component — and why does that one choice decide whether your test suite is an asset or a liability?

Core Concepts

Start with the philosophy, because every API decision flows from it. Test behaviour, not implementation. A user of your app never knows you kept the count in useState versus useReducer, never sees a prop named onToggle, never reads a CSS class. They see text, they click buttons, they read what changed. So that is what a test should assert on too. Rename a state variable, swap a useState for a store, split one component into three — if the visible behaviour is unchanged, a behaviour-focused test stays green. That is the whole game: tests that break only when the user's experience breaks. A test that asserts wrapper.state().count === 1 breaks on a refactor that changed nothing the user can perceive. That test isn't protecting you; it's taxing you.

The toolkit

Four pieces, each with one job:

Tool Role
Vitest (or Jest) The runner — finds test files, executes them, reports pass/fail. Vitest is the modern default in Vite/Next apps: fast, ESM-native, Jest-compatible API.
React Testing Library (RTL) Renders your component into a DOM and gives you queries to find things the way a user would.
jsdom A fake browser DOM running in Node, so there's a document to render into without a real browser.
@testing-library/user-event Simulates realistic interaction — a real click dispatches pointer, mouse, and focus events, not just one synthetic onClick.

RTL deliberately gives you no way to reach a component's state or props. That constraint is the feature — it makes writing a bad test harder than writing a good one.

The core RTL loop: render, act, assert

Here is the component under test, live below so you can play with it. Its test sits in the snippet right after — read them together.

React — Try it Yourself
Loading editor…
Preview
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import { Counter } from './Counter';

test('increments the count when the button is clicked', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  // Query the way a user would: by the button's accessible role + name.
  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: /increment/i }));
  await user.click(screen.getByRole('button', { name: /increment/i }));

  expect(screen.getByText('Count: 2')).toBeInTheDocument();
});

Three moves, always in this order. Render the component into jsdom. Act — drive the interaction with user-event (note await: user-event v14 is async because real events are). Assert with expect(...) and a matcher like toBeInTheDocument() (that matcher comes from @testing-library/jest-dom). Notice what the test never mentions: no useState, no setCount, no internal count variable. It clicks a button and checks the screen — exactly what a user does.

Everyday analogy

Testing implementation is like inspecting a car by checking that a specific wire runs from the ignition to a specific relay. Rewire the car more cleanly and your "test" fails though the car starts perfectly. Testing behaviour is turning the key and confirming the engine runs. The mechanic who insists on the wire diagram will condemn every good repair; the one who turns the key catches every real breakdown. Write tests that turn the key.

Queries: prefer the ones a user (and a screen reader) can use

RTL offers many query methods, and their priority order is not arbitrary — it mirrors how accessible your UI is. Reach for them top-down:

Priority Query Why
1 getByRole (+ name) How assistive tech sees the page. A button, a heading, a textbox — with its accessible name. Almost everything should be reachable this way.
2 getByLabelText The right way to find form fields — by the visible <label> a user reads.
3 getByPlaceholderText / getByText For non-interactive content and labelless inputs.
4 getByTestId Last resort. A data-testid the user never sees; use only when nothing semantic identifies the element.

The lesson hides in the ranking: if you can't query by role or label, that's often a real accessibility bug your test just surfaced. Reaching for getByTestId too early hides that bug instead of fixing it.

The engineer's notebook

What experienced engineers actually reach for:

• getBy throws, queryBy returns null, findBy awaits. Use getBy for "must exist now", queryBy only to assert absence (expect(queryByText(...)).not.toBeInTheDocument()), and findBy for "will appear after async work".
• screen.debug() prints the current DOM — the fastest way to see why a query found nothing.
• The { name } option on getByRole is the accessible name — button text, aria-label, or associated label. It's what makes role queries precise.
• Always await user-event. Forgetting the await is the number-one cause of flaky, half-run interactions.

Async: things that appear after a tick

Real components fetch data. You can't assert on a result that hasn't arrived — so RTL gives you findBy* (a getBy that retries until the element shows up or times out) and waitFor (retry an arbitrary assertion). And you don't hit the network in a unit test; you mock it with vi.mock (Vitest) so the test is fast and deterministic.

import { render, screen } from '@testing-library/react';
import { vi, expect, test } from 'vitest';
import { UserCard } from './UserCard';

// Replace the real module with a fake for this test file.
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ name: 'Ada Lovelace' }),
}));

test('shows the user name after it loads', async () => {
  render(<UserCard id="42" />);

  // Present immediately while the promise is pending:
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // findBy retries until the awaited element appears (or times out ~1s):
  expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});
History changed here

Enzyme, the dominant React testing tool of the 2010s (built at Airbnb), let you reach straight into a component: wrapper.state(), wrapper.instance(), setProps(). It made implementation-coupled tests the norm — and made refactors terrifying. In 2018 Kent C. Dodds released React Testing Library with the opposite thesis baked into its API: give people no handle on internals, only on what the user sees. When React 18 arrived, Enzyme famously had no official adapter and effectively stalled, while RTL — betting on behaviour, not internals — became the recommended default in React's own docs. The philosophy won by outliving the tool that ignored it.

Testing custom hooks, and what lives above unit tests

Most logic should be tested through the component that uses it. But a genuinely reusable hook can be tested directly with renderHook, which mounts it inside a throwaway component and hands you its return value via result.current (wrap updates in act):

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('useCounter increments', () => {
  const { result } = renderHook(() => useCounter());
  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
});

RTL tests run in jsdom — fast, but not a real browser. For "does the whole flow work in Chrome, across pages, with real navigation and network," you climb to end-to-end tests with Playwright or Cypress, which drive an actual browser. The rule of thumb (the "testing trophy"): lots of fast integration tests in RTL, a thin layer of E2E for critical journeys like sign-up and checkout, few pure-unit tests.

Real-world example — chika.ng Academy

The Academy's enrollment form is the money path, so its RTL test reads like a user story: render the form, getByLabelText('Email'), user.type an invalid address, click Submit, and assert the error message appears — then a valid address, and assert the success state (with the submit API vi.mocked so no real charge fires). None of it touches component state. A later refactor moved that form from local useState to a reducer; every test stayed green because the user's experience never changed. On top of that thin RTL layer sits one Playwright journey — sign up, pay, land on the dashboard — run in a real browser before each deploy.

Common Mistakes

  • Asserting on state or props. expect(component.state.count).toBe(1) couples the test to the implementation. Assert on rendered output instead — it's what the user sees and what should actually be protected.
  • Overusing data-testid. Reaching for getByTestId first skips the accessible query it should have used, and quietly hides an accessibility gap. Test IDs are the escape hatch, not the front door.
  • Forgetting to await interactions. user.click(...) and findBy* return promises. Drop the await and assertions run against a half-updated DOM — the classic flaky test.
  • Using getBy to check something is absent. getBy throws when it finds nothing, failing the test before your assertion runs. Use queryBy with .not.toBeInTheDocument() for absence.
  • Testing third-party libraries. Don't write tests that verify React Router navigates or that a date library formats correctly — that's their job, already done. Test your code's use of them.
  • Chasing 100% coverage. Coverage counts lines executed, not behaviours verified. A suite can be 100% green, 100% covered, and still assert nothing meaningful. Test the behaviours that matter, not every line.
Thought experiment

You have two test suites for the same component. Suite A asserts that clicking "Add to cart" makes the text "1 item" appear. Suite B asserts that clicking calls setCartCount(1) and that an internal isAdded flag flips to true. Now a teammate refactors the cart from local state into a shared store — same buttons, same visible result. Which suite still passes? Suite A, untouched, because the user's experience never changed. Suite B fails on every test though nothing broke, so your teammate "fixes" the tests to match the new internals — learning that green means "the code hasn't changed", not "the app still works". Which suite would you rather have owned during that refactor?

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
"How would you test that a button click updates the screen in React?"
Model answer — "I'd use React Testing Library: render the component, find the button with screen.getByRole('button', { name: /…/ }), click it with await userEvent.click(...), then assert the new text is on screen with expect(screen.getByText(...)).toBeInTheDocument(). I query by role and assert on visible output because that's what the user actually experiences."
Follow-up they'll throw: "Why getByRole over getByTestId?" → Role queries mirror how a user and a screen reader find the element; if I can't use one, that's often a real accessibility problem the test just caught.
Intermediate
"What does 'test behaviour, not implementation' mean in practice, and how do you test async data loading?"
Model answer — "It means assert on what the user sees and does — rendered output, interactions — never on internal state, props, or class names. A refactor that keeps behaviour identical should keep tests green. For async, I mock the data source with vi.mock so the test is fast and deterministic, assert the loading state is shown immediately, then use findBy* — which retries until the element appears — to assert the loaded result. I avoid waitFor around a simple element when a findBy query says the same thing more clearly."
Follow-up: "When would you reach for waitFor then?" → When the thing I'm waiting on isn't an element — e.g. waiting for a mocked function to have been called, or for text to disappear.
Senior
"Our React test suite is slow, flaky, and breaks on every refactor. Diagnose it and set a testing strategy for the team."
Model answer — "Three symptoms, usually one root cause: tests coupled to implementation. Breaking-on-refactor means they assert on state/props or DOM structure instead of behaviour — I'd migrate those to role/label queries and visible-output assertions. Flakiness is almost always missing await on interactions and async queries, or real timers/network leaking in — I'd enforce user-event setup, mock the network at the module boundary, and use fake timers deliberately. Slowness means too many heavy E2E or over-mounted trees doing integration's job — I'd rebalance toward the testing trophy: many fast RTL integration tests, a thin Playwright layer for the few critical journeys. Then I'd wire it into CI as a required gate so the standard holds without me policing PRs."
The senior move: treat the flaky suite as a design problem, not a whack-a-mole of retries. Naming the trophy shape and the CI gate shows you can set a standard a whole team runs on.
Company lens — how the same topic gets asked
micro1Live and conversational — reason out loud. The AI interviewer wants to hear you name the philosophy ("behaviour, not implementation") and the concrete API in one breath. State the render→act→assert loop as you'd type it.
MetaMeta created React (and Jest). Expect first-principles depth: why RTL forbids reaching into internals, how act relates to React's batching and effect flushing, and where the query priority comes from. Be ready to defend a testing philosophy, not just recite an API.
GoogleWants the trade-off ladder — unit vs integration vs E2E, what each is for, and how you'd decide the mix for a given feature. Expect "what would you not test, and why?"
Amazon / AWSOperational lens: tests as a deployment gate. How do flaky tests threaten the pipeline, how do you keep the suite fast enough to run on every commit, and what's your bar for blocking a release?
Hands-on exercise
  1. Take the Counter in the editor above and, on paper, write the test for the Reset button: increment twice, click Reset, assert the screen shows Count: 0 — using only role and text queries.
  2. Rewrite one assertion two ways: prove the count is 2 with getByText, then prove a "Count: 3" element is absent with queryByText and .not.toBeInTheDocument(). Say why getByText would be the wrong tool for the absence check.
  3. Sketch the test for a component that fetches a username: vi.mock the API, assert the loading text with getByText, then the loaded name with findByText. Note exactly which lines need await.
  4. Out loud (as in a micro1 screen), explain in two sentences why a test that reads a component's internal state is a liability during a refactor.

Chapter Summary

  • The guiding principle: test behaviour, not implementation — assert on what the user sees and does, never on internal state, props, or structure. Such tests break only when the experience breaks.
  • The toolkit: Vitest/Jest (runner) + React Testing Library (render + queries) + jsdom (fake DOM) + user-event (realistic interaction).
  • The loop is always render → act → assert: render(), drive with await userEvent..., check with expect(...).toBeInTheDocument().
  • Query priority — role > label > text > testid — mirrors accessibility; needing getByTestId is often a sign of a real a11y gap.
  • Async: findBy* retries until an element appears, waitFor retries an assertion, and vi.mock fakes the network so tests are fast and deterministic. Always await interactions and async queries.
  • Test hooks directly with renderHook only when they're truly reusable; climb to Playwright/Cypress E2E for a thin layer of critical, real-browser journeys.
  • Don't test third-party libraries, don't chase 100% coverage, and never assert on internals.
Narrative bridge

Notice what kept surfacing as we chose queries: role, label, accessible name. That wasn't a coincidence of the API — it was accessibility leaking into every good test we wrote, because a UI a screen reader can navigate is a UI a test can query. The next chapter, Accessibility in React, turns that from a happy side effect into a discipline: semantic HTML, ARIA where it's genuinely needed, focus management, and keyboard flows — the craft that makes your app usable by everyone and, as a bonus you've now seen, eminently testable.

Editorial reflection — the deepest lesson of testing isn't a library; it's a stance. Every time you choose to assert on what the user sees rather than how you built it, you are quietly deciding that your code exists to serve people, not to preserve its own structure. That stance outlives Vitest, outlived Enzyme, and will outlive whatever replaces RTL — because it is really just empathy, written down in a form the machine can check. Learn the API, yes. But carry the stance; it is the part that makes you senior.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.