React NotesRohit’s interview study guide
Chapter 20

Testing

How to test React the way users use it: Vitest and React Testing Library for components and hooks, user-event for interactions, MSW for the network, and Cypress or Playwright for whole flows in a real browser.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Testing philosophy and tools

Added

A test is only worth what it tells you when it fails. A good React test fails when something a user would notice breaks: the button stops saving, the error message never appears, the list shows the wrong items. A bad test fails when you rename a state variable, swap useState for useReducer, or split a component in two, even though nothing changed for the user. Tests like that make refactoring slower instead of safer.

Here's the mental model: test from the outside, through the screen. Pretend the component is a black box and the only way in is what a user (or a screen reader) has: text, labels, roles, clicks and key presses. State, hook names, props of child components and CSS class names are implementation details. They aren't the contract, so tests shouldn't depend on them. React Testing Library is built around this idea. It deliberately gives you no way to read a component's state.

Tests come in layers, and each one buys a different kind of confidence:

LayerWhat it checksTools (2026)Speed
StaticTypes, typos, rules of hooksTypeScript, ESLint (eslint-plugin-react-hooks)Instant
UnitA pure function or a single hookVitestMilliseconds
IntegrationA component with its children, providers and a mocked networkVitest + React Testing Library + user-event + MSWTens of milliseconds
End-to-endThe real app in a real browser, often against a real backendPlaywright, CypressSeconds
VisualThat components still look rightStorybook + a screenshot service, or Playwright screenshotsSeconds

The "testing trophy" (Kent C. Dodds' twist on the old pyramid) puts the most weight on integration tests: render a real feature with its real children, mock only the network, and drive it like a user. They're nearly as fast as unit tests and catch the bugs that matter.

The tools, old and new

JobModern (2026)Older, still in codebases
Test runnerVitest 5: uses your Vite config, native ESM, Jest-compatible API (describe, test, expect, vi.fn)Jest (now 30): the default in the Create React App era, run by react-scripts test
DOM in Nodejsdom (or happy-dom)jsdom
Rendering and queriesReact Testing Library 16Enzyme (shallow rendering, reading state; there is no official adapter for React 18 or 19), react-test-renderer (deprecated in React 19)
Interactionsuser-event 14fireEvent
Network mockingMSW 3 (Mock Service Worker)jest.fn() on fetch, axios mock adapters
End-to-endPlaywright 1.64, Cypress 16Selenium, Protractor

This is the setup used for every example on this site: Vite's config file gets a test block, and a setup file adds the jest-dom matchers.

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/setup.ts"],
  },
});
What’s happening
  1. plugins: [react()] is the same plugin the app uses, so test files get the same JSX transform and module resolution as production code. That's Vitest's big advantage over Jest: no second Babel config to keep in sync.
  2. environment: "jsdom" gives every test file a fake browser: document, window, events and layout-free DOM APIs. Without it, render fails because there's no document to render into.
  3. globals: true makes describe, test, expect, vi, beforeEach and afterEach available without importing them, like Jest. It also matters for React Testing Library: RTL registers its automatic cleanup with the global afterEach, so without globals you'd have to call cleanup() yourself.
  4. setupFiles runs before every test file. Ours contains one line, import "@testing-library/jest-dom/vitest";, which adds matchers like toBeInTheDocument() and toBeDisabled() to Vitest's expect.

The first test, for the reusable Button from my notes (the notes' "Testing a button component" section was a screenshot that couldn't be read, so this one is mine):

Button.jsx
export function Button({ label, onClick, disabled = false, variant = "primary" }) {
  return (
    <button type="button" className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}
Button.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";

describe("Button", () => {
  test("shows its label", () => {
    render(<Button label="Save" />);
    expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
  });

  test("calls onClick when clicked", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    render(<Button label="Save" onClick={onClick} />);

    await user.click(screen.getByRole("button", { name: "Save" }));

    expect(onClick).toHaveBeenCalledTimes(1);
  });

  test("does not call onClick when disabled", async () => {
    const user = userEvent.setup();
    const onClick = vi.fn();
    render(<Button label="Save" onClick={onClick} disabled />);

    const button = screen.getByRole("button", { name: "Save" });
    expect(button).toBeDisabled();
    await user.click(button);

    expect(onClick).not.toHaveBeenCalled();
  });

  test("applies the variant class", () => {
    render(<Button label="Delete" variant="danger" />);
    expect(screen.getByRole("button")).toHaveClass("btn", "btn-danger");
  });
});
Output
$ npx vitest run src/ch20/Button.test.jsx
 RUN  v5.0.3 /…/.sandbox/react/ch20-21

 ✓ src/ch20/Button.test.jsx (4 tests) 73ms

 Test Files  1 passed (1)
      Tests  4 passed (4)
What’s happening
  1. Each test follows arrange, act, assert: render the component with some props, do what a user would do, then check what the user would see. render mounts the button into a real (jsdom) DOM node attached to document.body.
  2. screen.getByRole("button", { name: "Save" }) finds the element the way assistive technology does: by its role and its accessible name (here, the button's text). If someone changed the <button> to a <div onClick>, this query would fail, and it should, because keyboard and screen-reader users could no longer use it.
  3. vi.fn() creates a mock function that records its calls. toHaveBeenCalledTimes(1) checks the prop was called once per click. That's the button's whole contract with its parent.
  4. In the disabled test, toBeDisabled() checks the attribute, then user.click tries to click anyway. user-event behaves like a browser: a disabled button doesn't fire click, so onClick is never called. No error is thrown either, which is also what happens when a real user clicks a disabled button.
  5. The class test is the borderline one. Classes are styling details, but here variant is part of the public API and the class is the only observable result in jsdom (there's no real CSS). It's fine for a design-system component; for a feature component you'd assert on behaviour instead.

Here's the point about implementation details, made concrete. The same toggle written two ways, and one test file that runs against both:

Toggle.jsx
import { useReducer, useState } from "react";

// Version 1: useState
export function ToggleState() {
  const [on, setOn] = useState(false);
  return (
    <button aria-pressed={on} onClick={() => setOn(!on)}>
      {on ? "On" : "Off"}
    </button>
  );
}

// Version 2: the same component refactored to useReducer
export function ToggleReducer() {
  const [on, toggle] = useReducer((s) => !s, false);
  return (
    <button aria-pressed={on} onClick={toggle}>
      {on ? "On" : "Off"}
    </button>
  );
}
Toggle.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ToggleState, ToggleReducer } from "./Toggle";

describe.each([
  ["useState", ToggleState],
  ["useReducer", ToggleReducer],
])("Toggle (%s)", (_, Toggle) => {
  test("switches on and off", async () => {
    const user = userEvent.setup();
    render(<Toggle />);
    const button = screen.getByRole("button", { name: "Off" });
    expect(button).toHaveAttribute("aria-pressed", "false");

    await user.click(button);
    expect(button).toHaveTextContent("On");
    expect(button).toHaveAttribute("aria-pressed", "true");

    await user.click(button);
    expect(button).toHaveTextContent("Off");
  });
});
Output
 ✓ src/ch20/Toggle.test.jsx > Toggle (useState) > switches on and off 67ms
 ✓ src/ch20/Toggle.test.jsx > Toggle (useReducer) > switches on and off 16ms
What’s happening
  1. describe.each runs the same block once per row, so the identical test runs against ToggleState and then ToggleReducer. %s in the title is replaced by the first column.
  2. The test only touches what a user can perceive: the button's text and its aria-pressed state (which a screen reader announces as "toggle button, pressed").
  3. button is found once and reused after the clicks. That works because React updates the same DOM node: it changes the text and attribute in place instead of creating a new button.
  4. Both versions pass. The refactor from useState to useReducer changed the implementation completely and the test didn't notice, which is exactly what you want from a test.
  5. An Enzyme-era test written as expect(wrapper.state("on")).toBe(true) couldn't have done this. It reads state directly, so it would break on the refactor (and .state() never worked on function components at all).
AdvancedThe older way: Enzyme and Jest with Create React App

In a 2016–2020 codebase you'll see tests like this. It's Enzyme, for React 16 class components, and it doesn't run on React 18 or 19 (Enzyme has no official adapter for them), so it wasn't executed here:

Toggle.enzyme.test.js
import { shallow } from "enzyme";
import Toggle from "./Toggle";

it("toggles", () => {
  const wrapper = shallow(<Toggle />);       // renders one level deep, children are stubs
  wrapper.find("button").simulate("click");   // calls the onClick prop directly, no DOM event
  expect(wrapper.state("on")).toBe(true);     // reads the class component's state
});
What’s happening
  1. shallow rendered only the top component, leaving children as placeholders. Fast, but you never tested the components working together.
  2. simulate("click") didn't dispatch a real event. It just called the onClick prop, so it skipped event bubbling, disabled and everything else a browser does.
  3. wrapper.state("on") tied the test to the state's name and shape. Rename it, or convert the class to hooks, and the test fails even though the UI is identical. This is the habit React Testing Library was created to break.

Jest with Create React App. CRA shipped Jest preconfigured: npm test ran react-scripts test (Jest in watch mode with jsdom), and src/setupTests.js imported @testing-library/jest-dom. Outside CRA, Jest needs testEnvironment: "jsdom" (the jest-environment-jsdom package, separate since Jest 28) and babel-jest or ts-jest to transform JSX. The API maps almost one to one onto Vitest:

JestVitest
jest.fn(), jest.spyOn()vi.fn(), vi.spyOn()
jest.mock("./api")vi.mock("./api")
jest.useFakeTimers(), jest.advanceTimersByTime()vi.useFakeTimers(), vi.advanceTimersByTime()
import "@testing-library/jest-dom"import "@testing-library/jest-dom/vitest"
jest.config.jsthe test block in vite.config.ts

One difference bites people later: Testing Library has built-in support for Jest's fake timers but not Vitest's. See Testing asynchronous behaviour.

Where tests live: my notes had a project-structure screenshot ("Tests can be like this") that couldn't be read. The common layout is to co-locate a component's test next to it (Button.jsx and Button.test.jsx), keep shared setup and a custom render in src/test/, and put browser tests in a top-level e2e/ (Playwright) or cypress/ folder.

What to test, in practice:

  • Behaviour users rely on: what renders for each state (loading, empty, error, success), what happens on click, type and submit, what's sent to the server.
  • Edge cases: empty lists, long text, a failed request, a double click on "Pay".
  • Accessibility for free: querying by role and label fails when markup isn't accessible.
  • Not: third-party libraries themselves, CSS details, which hook a component uses, or large snapshots. toMatchSnapshot() on a whole page breaks on every harmless change and people start updating snapshots without reading them.

#React Testing Library basics

React Testing Library (RTL) is a thin layer over React and the DOM. render mounts your component into a real DOM node (in jsdom), and queries find elements the way a user would: by role, label or text. There is no wrapper.state(), no instance() and no shallow rendering. If a user can't see it or interact with it, RTL makes it hard to test, on purpose.

The mental model: RTL gives you a browser tab and a pair of eyes. render loads the page, screen is what's visible, and the queries are you scanning the page for "the Save button" or "the Email field". Everything you assert is something you could have checked by looking.

The pieces you use in every test:

  • render(ui, options) mounts the element into a <div> appended to document.body and returns helpers (rerender, unmount, container).
  • screen holds every query, bound to document.body. Prefer it over destructuring queries from render: it's always the whole page and reads well.
  • jest-dom matchers (toBeInTheDocument, toHaveTextContent, toBeDisabled, toHaveValue, toHaveAttribute, toHaveClass, toBeVisible) make assertions read like the requirement and produce clear failure messages.
  • Automatic cleanup: after each test RTL unmounts everything and empties document.body, so tests don't leak into each other.
ButtonBasics.test.jsx
import { render, screen } from "@testing-library/react";
import { Button } from "./Button";

test("render returns helpers; screen is the whole document", () => {
  const { container, rerender, unmount } = render(<Button label="Save" />);
  screen.debug();
  expect(container.querySelector("button")).toBe(screen.getByRole("button"));

  rerender(<Button label="Saving…" disabled />);
  expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();

  unmount();
  expect(screen.queryByRole("button")).toBeNull();
});

test("the DOM is cleaned up between tests", () => {
  expect(document.body.innerHTML).toBe("");
});
Output
stdout | src/ch20/ButtonBasics.test.jsx > render returns helpers; screen is the whole document
<body>
  <div>
    <button
      class="btn btn-primary"
      type="button"
    >
      Save
    </button>
  </div>
</body>

 ✓ src/ch20/ButtonBasics.test.jsx (2 tests) 71ms
What’s happening
  1. render creates a <div>, appends it to document.body and mounts <Button> into it with createRoot, inside act so the first render and its effects have finished before the next line runs. container is that <div>.
  2. screen.debug() pretty-prints the current DOM, shown in the output. It's the first thing to reach for when a query can't find something: you see what the test sees, not what you assumed.
  3. container.querySelector("button") and screen.getByRole("button") return the very same node. container is an escape hatch for the rare case no query fits; reaching for it often means the test is drifting towards implementation details.
  4. rerender renders a new element into the same root, like a parent passing new props. React updates the existing button (new text, disabled added) instead of mounting a fresh one, so this is how you test a component's reaction to prop changes.
  5. unmount removes it, so queryByRole returns null. The second test then sees an empty <body> because RTL's automatic cleanup ran after the first test, even though the first test unmounted on its own.
Advancedact(), and where it lives in React 19

act(callback) tells React "run this, then flush every state update, render and effect it caused before returning". It's what makes expect see the result of a click instead of a half-finished render. RTL wraps render, rerender, fireEvent and user-event in act for you, which is why you rarely write it. You need it yourself when you trigger an update outside RTL: calling a hook's function from renderHook, dispatching to a Redux store from the test, or advancing fake timers.

Where to import it changed:

  • React 18.3 and 19: import { act } from "react" (RTL re-exports it, so import { act } from "@testing-library/react" works too).
  • React 18 and earlier: import { act } from "react-dom/test-utils". In React 19.3 that module exports only act, and using it logs: "ReactDOMTestUtils.act is deprecated in favor of React.act. Import act from react instead of react-dom/test-utils."
  • The rest of react-dom/test-utils (renderIntoDocument, Simulate) was removed in React 19, and react-test-renderer is deprecated in React 19 (it logs a deprecation warning when used). Both are signs of an older test suite.

When an update escapes act, React warns with the "An update to X inside a test was not wrapped in act(...)" message. The real output, and what it means, are in Testing asynchronous behaviour.

#Queries: getBy, queryBy and findBy

My notes had the key distinction: getByText is synchronous, findByText is asynchronous, and with findBy you must await. The full picture is three prefixes times "one or all", and they differ in what they do when the element isn't there:

Query0 matches1 match2+ matchesWaits?
getBy…throwsreturns itthrowsno
queryBy…returns nullreturns itthrowsno
findBy…rejects after 1000 msresolves to itrejectsyes, retries every 50 ms
getAllBy…throws[el]arrayno
queryAllBy…[][el]arrayno
findAllBy…rejects after 1000 ms[el]arrayyes

So the rule of thumb: getBy for "it's there now", queryBy for "it's not there", findBy for "it will appear".

The second half of a query's name is how it matches. Testing Library recommends this order, best first, because it mirrors how people find things:

  1. ByRole (with { name }): buttons, links, headings, textboxes, checkboxes, dialogs. Works for everyone, including screen readers.
  2. ByLabelText: form fields, the way users find them by their label.
  3. ByPlaceholderText, ByText, ByDisplayValue: when there's no better handle.
  4. ByAltText, ByTitle: images and tooltips.
  5. ByTestId: the last resort (data-testid), for things with no accessible handle.
Cart.jsx
import { useEffect, useState } from "react";

export function Cart({ items }) {
  const [promo, setPromo] = useState(null);

  useEffect(() => {
    // Pretend the promo comes from the server 100 ms later.
    const id = setTimeout(() => setPromo("10% off today"), 100);
    return () => clearTimeout(id);
  }, []);

  return (
    <section>
      <h2>Your cart</h2>
      {items.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <ul>
          {items.map((item) => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
      )}
      {promo && <p role="status">{promo}</p>}
      <label>
        Promo code
        <input name="code" />
      </label>
    </section>
  );
}
Cart.test.jsx
import { render, screen } from "@testing-library/react";
import { Cart } from "./Cart";

const items = [
  { id: 1, name: "Keyboard" },
  { id: 2, name: "Mouse" },
];

test("getBy: the element must be there now", () => {
  render(<Cart items={items} />);
  expect(screen.getByRole("heading", { name: "Your cart" })).toBeInTheDocument();
  expect(screen.getAllByRole("listitem")).toHaveLength(2);
  expect(screen.getByLabelText("Promo code")).toHaveAttribute("name", "code");
});

test("queryBy: assert that something is absent", () => {
  render(<Cart items={items} />);
  expect(screen.queryByText("Your cart is empty")).toBeNull();
  expect(screen.queryByText("Your cart is empty")).not.toBeInTheDocument();
});

test("findBy: wait for something that appears later", async () => {
  render(<Cart items={items} />);
  expect(screen.queryByRole("status")).toBeNull(); // not yet
  expect(await screen.findByRole("status")).toHaveTextContent("10% off today");
});
What’s happening
  1. getByRole("heading", { name: "Your cart" }) matches the <h2>: every h1–h6 has the implicit role heading, and its accessible name is its text. getAllByRole("listitem") returns both <li>s, so toHaveLength(2) checks the list rendered one row per item.
  2. getByLabelText("Promo code") finds the <input> because it's wrapped in the <label>, which gives it that accessible name. If the label were missing, this query would fail, which is a real accessibility bug.
  3. With items in the cart, "Your cart is empty" isn't rendered. getByText would throw, so the absence check has to use queryByText, which returns null. Both assertions on that line say the same thing; not.toBeInTheDocument() reads better and gives a clearer message.
  4. The promo is set by a 100 ms timer in an effect. Right after render it isn't there (queryByRole → null). await findByRole("status") polls every 50 ms; once the timer fires and React re-renders, the query succeeds and the promise resolves with the <p>, about 100 ms in.
  5. The mapping to the table: getBy checks the present, queryBy checks the absent, findBy waits for the future. Picking the wrong one either throws too early or passes for the wrong reason.

When a query fails, the error tells you why. These messages are real, captured from the same Cart:

CartErrors.test.jsx
test("getBy throws when nothing matches", () => {
  render(<Cart items={items} />);
  screen.getByText("Monitor");
  // TestingLibraryElementError: Unable to find an element with the text: Monitor. This could be because
  // the text is broken up by multiple elements. In this case, you can provide a function for your text
  // matcher to make your matcher more flexible.   (followed by a dump of the DOM)
});

test("getBy throws when several match", () => {
  render(<Cart items={items} />);
  screen.getByRole("listitem");
  // Found multiple elements with the role "listitem"
});

test("findBy gives up after 1000 ms", async () => {
  render(<Cart items={items} />);
  await screen.findByText("Free shipping");
  // rejects after ~1005 ms with the same "Unable to find an element with the text: Free shipping…"
});
What’s happening
  1. "Unable to find" is getBy's zero-match error. It prints the whole DOM after the message, so you can see that "Monitor" really isn't there, or that it's split across elements (<b>Moni</b>tor), which is what the hint about "broken up by multiple elements" refers to.
  2. "Found multiple elements" is the 2+ match error. getBy refuses to guess which one you meant. Use getAllBy and pick one, or make the query more specific ({ name: "Mouse" }), or scope it with within(list).
  3. findBy reports the same "Unable to find" message, but only after its timeout. In the test it rejected after 1005 ms. A test full of findBys for things that never appear is slow, so don't use findBy "just in case": use it only for content that arrives asynchronously.
AdvancedMatching text, scoping and hidden elements
  • Exact by default. getByText("Your cart") matches the full, trimmed, whitespace-collapsed text of an element. It wouldn't match <h2>Your cart (2)</h2>. Use a regex (/your cart/i), { exact: false } (substring, case-insensitive), or a function (content, element) => … for text split across elements.
  • within(element) scopes queries to part of the page: within(screen.getByRole("navigation")).getByRole("link", { name: "Home" }) avoids matching the "Home" link in the footer.
  • Hidden elements. ByRole ignores elements hidden from the accessibility tree (display: none, aria-hidden, a closed <details>). Pass { hidden: true } to include them. ByText does find hidden elements, so pair it with toBeVisible() when visibility matters.
  • Speed. ByRole computes accessible names, which is slower than other queries on very large DOMs (big tables). If one test is slow, a more specific query or within usually fixes it.
  • Discovering roles. screen.logTestingPlaygroundURL() and logRoles(container) print the roles and names Testing Library sees, which helps when you don't know what role an element has.

#User interactions with user-event

My notes used fireEvent.click(button). That works for a simple click, but fireEvent dispatches exactly one DOM event. A real user's click is pointerdown, mousedown, focus, pointerup, mouseup, click; typing one character is keydown, keypress, beforeinput, input, keyup. user-event simulates the whole interaction the way a browser would, including the rules browsers enforce: disabled elements don't get clicks, maxLength limits typing, pointer-events: none blocks the pointer.

The mental model: fireEvent is poking the component with a stick; user-event is sitting a person at the keyboard. Use user-event by default and fireEvent only for events user-event doesn't model (a scroll, a custom event, transitionend).

In version 14 the API is: create a session with userEvent.setup() at the start of the test, then await every interaction, because they're async (they yield between events, like a browser does).

SearchForm.jsx
import { useState } from "react";

export function SearchForm({ onSearch }) {
  const [query, setQuery] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    onSearch(query.trim());
  }

  return (
    <form role="search" onSubmit={handleSubmit}>
      <label htmlFor="q">Search</label>
      <input id="q" value={query} onChange={(e) => setQuery(e.target.value)} />
      <button type="submit" disabled={query.trim() === ""}>
        Go
      </button>
    </form>
  );
}
SearchForm.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchForm } from "./SearchForm";

test("searches for what the user typed", async () => {
  const user = userEvent.setup();
  const onSearch = vi.fn();
  render(<SearchForm onSearch={onSearch} />);

  const input = screen.getByRole("textbox", { name: "Search" });
  const go = screen.getByRole("button", { name: "Go" });
  expect(go).toBeDisabled();

  await user.type(input, "  react  ");
  expect(input).toHaveValue("  react  ");
  expect(go).toBeEnabled();

  await user.click(go);
  expect(onSearch).toHaveBeenCalledWith("react");
});

test("Enter submits too", async () => {
  const user = userEvent.setup();
  const onSearch = vi.fn();
  render(<SearchForm onSearch={onSearch} />);

  await user.type(screen.getByLabelText("Search"), "hooks{Enter}");
  expect(onSearch).toHaveBeenCalledWith("hooks");
});

test("clear and retype", async () => {
  const user = userEvent.setup();
  render(<SearchForm onSearch={() => {}} />);
  const input = screen.getByLabelText("Search");
  await user.type(input, "abc");
  await user.clear(input);
  await user.type(input, "xyz");
  expect(input).toHaveValue("xyz");
});
What’s happening
  1. userEvent.setup() creates a session that tracks pointer and keyboard state across calls (which keys are held, where the pointer is). Creating it before render is the documented pattern.
  2. user.type(input, " react ") first clicks the input (so it gets focus), then types nine characters one at a time. Each keystroke fires a real input event, React calls onChange, setQuery runs and the controlled input re-renders. query goes " " → " " → " r" … → " react ".
  3. After typing, query.trim() is no longer empty, so the button becomes enabled. Asserting toBeDisabled() first and toBeEnabled() after proves the component reacts to input, not just that it renders.
  4. user.click(go) clicks the submit button. The browser behaviour of "clicking a submit button submits its form" is simulated, so onSubmit runs, preventDefault stops jsdom from trying to navigate, and onSearch gets the trimmed "react".
  5. "hooks{Enter}" types the word and then presses Enter. Braces name special keys. Enter in a text field submits the form (implicit submission), exactly like a browser. A fireEvent.keyDown(input, { key: "Enter" }) would not submit, because it's only the keydown event, not the browser's default action.
  6. user.clear selects the content and deletes it, firing the same events a user's select-all-and-delete would. Then the input holds only "xyz".

Here's the difference measured. The same input, logging every event React sees, once with fireEvent.change and once with user.type:

EventCompare.test.jsx
function LoggedInput({ log }) {
  return (
    <input
      aria-label="Name"
      maxLength={3}
      onFocus={() => log("focus")}
      onKeyDown={(e) => log(`keydown ${e.key}`)}
      onChange={(e) => log(`change "${e.target.value}"`)}
      onKeyUp={(e) => log(`keyup ${e.key}`)}
    />
  );
}

test("fireEvent.change: one synthetic event", () => {
  const events = [];
  render(<LoggedInput log={(e) => events.push(e)} />);
  fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Rohit" } });
  console.log("fireEvent:", events);
  console.log("value:", screen.getByLabelText("Name").value);
});

test("user.type: what a real user produces", async () => {
  const user = userEvent.setup();
  const events = [];
  render(<LoggedInput log={(e) => events.push(e)} />);
  await user.type(screen.getByLabelText("Name"), "Rohit");
  console.log("user.type:", events);
  console.log("value:", screen.getByLabelText("Name").value);
});
Output
fireEvent: [ 'change "Rohit"' ]
value: Rohit

user.type: [
  'focus',        'keydown R',
  'change "R"',   'keyup R',
  'keydown o',    'change "Ro"',
  'keyup o',      'keydown h',
  'change "Roh"', 'keyup h',
  'keydown i',    'keyup i',
  'keydown t',    'keyup t'
]
value: Roh
What’s happening
  1. fireEvent.change sets value to "Rohit" directly and dispatches one event. React's onChange fires once. No focus, no key events, and the maxLength={3} is ignored: the input ends up holding five characters, something no user could produce.
  2. user.type focuses the input first (focus), then for each character fires keydown, the input change, and keyup. React's onChange fires once per character, with the value growing "R" → "Ro" → "Roh".
  3. At i and t there's keydown and keyup but no change: the browser rule "don't insert past maxLength" is simulated, so the value stays "Roh".
  4. That's why user-event finds bugs fireEvent hides: a handler on onKeyDown that fireEvent.change never triggers, validation that only runs on blur, or a field that can't actually accept the value your test forced into it.
AdvancedMore of the user-event API, and the pointer-events check
  • Keyboard syntax: {Enter}, {Tab}, {Backspace}, {ArrowDown}; hold a key with {Shift>} … {/Shift}; user.keyboard("{Control>}a{/Control}") presses Ctrl+A on whatever has focus.
  • Focus: user.tab() moves focus like the Tab key, so you can check tab order and focus traps in dialogs (expect(closeButton).toHaveFocus()).
  • Forms: user.selectOptions(select, "ca"), user.click(checkbox), user.upload(fileInput, new File(["x"], "a.png", { type: "image/png" })), user.paste("text").
  • Pointer: user.hover, user.unhover, user.dblClick, user.tripleClick.
  • pointer-events: none. user-event checks computed styles before clicking. An element under an overlay, or styled pointer-events: none, can't be clicked. The real error from clicking <button style={{ pointerEvents: "none" }}>: "Unable to perform pointer interaction as the element has pointer-events: none". fireEvent.click would have clicked it anyway.
  • delay. Between keystrokes user-event waits setTimeout(0) by default (delay: 0). With fake timers, pass userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }) so those waits can finish. See the fake-timers part of Testing asynchronous behaviour.

#Testing asynchronous behaviour

Most interesting UI is asynchronous: a click starts a request, a timer shows a toast, a debounced search fires later. The test has to wait for the UI to settle before asserting, without sleeping for a fixed time. Testing Library's async utilities all work the same way: retry a check until it passes or a timeout runs out (1000 ms by default, checking every 50 ms and whenever the DOM changes).

  • findBy… = "retry this query until it finds the element". It's waitFor + getBy in one call.
  • waitFor(callback) = "retry this callback until it stops throwing". Use it for assertions that aren't about finding an element: a mock was called, a value changed.
  • waitForElementToBeRemoved(callback) = "wait until this element is gone", for spinners and loading text.

The mental model: you're not telling the test how long to wait, you're telling it what to wait for. That's what makes these tests both fast and stable.

This is the "button click fetches some data and sets state" component from my notes, with an error path added:

FetchButton.jsx
import { useState } from "react";

export default function FetchButton() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  async function handleClick() {
    setLoading(true);
    setError(null);
    try {
      const response = await fetch("/api/data");
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      setData(await response.json());
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <button onClick={handleClick}>Fetch Data</button>
      {loading && <p>Loading…</p>}
      {data && <p>{data}</p>}
      {error && <p role="alert">Failed: {error}</p>}
    </div>
  );
}

And the tests. My notes' versions used global.fetch = jest.fn(...) and fireEvent.click; these are the Vitest equivalents with user-event, plus a loading and an error case:

FetchButton.test.jsx
import { render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import FetchButton from "./FetchButton";

beforeEach(() => {
  vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json("Fetched Data"));
});
afterEach(() => {
  vi.restoreAllMocks();
});

test("findByText waits for the data", async () => {
  const user = userEvent.setup();
  render(<FetchButton />);

  await user.click(screen.getByRole("button", { name: "Fetch Data" }));

  expect(await screen.findByText("Fetched Data")).toBeInTheDocument();
  expect(fetch).toHaveBeenCalledWith("/api/data");
});

test("waitFor retries an assertion", async () => {
  const user = userEvent.setup();
  render(<FetchButton />);

  await user.click(screen.getByRole("button", { name: "Fetch Data" }));

  await waitFor(() => expect(screen.getByText("Fetched Data")).toBeInTheDocument());
  await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
});

test("the loading message appears, then goes away", async () => {
  let respond;
  fetch.mockReturnValueOnce(new Promise((resolve) => (respond = resolve)));
  const user = userEvent.setup();
  render(<FetchButton />);

  await user.click(screen.getByRole("button", { name: "Fetch Data" }));
  expect(screen.getByText("Loading…")).toBeInTheDocument(); // request still pending

  respond(Response.json("Fetched Data"));
  await waitForElementToBeRemoved(() => screen.queryByText("Loading…"));
  expect(screen.getByText("Fetched Data")).toBeInTheDocument();
});

test("shows an error when the server fails", async () => {
  fetch.mockResolvedValueOnce(new Response("boom", { status: 500 }));
  const user = userEvent.setup();
  render(<FetchButton />);

  await user.click(screen.getByRole("button", { name: "Fetch Data" }));

  expect(await screen.findByRole("alert")).toHaveTextContent("Failed: HTTP 500");
});
What’s happening
  1. vi.spyOn(globalThis, "fetch") replaces fetch with a mock for each test, and vi.restoreAllMocks() puts the real one back afterwards. Response.json("Fetched Data") builds a real Response with ok: true, status: 200 and a JSON body, so the component's response.ok check and response.json() behave exactly as in a browser.
  2. In the first test, the click calls handleClick, which sets loading, then awaits the mocked fetch. findByText("Fetched Data") keeps retrying while the promise chain resolves, setData runs and React re-renders. Once the text appears, it resolves. The test never says how long to wait.
  3. The waitFor version does the same with an explicit callback: it re-runs expect(screen.getByText(...)) until it stops throwing. That's my notes' waitFor example. It works, but findByText says the same thing more briefly, so prefer findBy for elements and keep waitFor for non-DOM checks like toHaveBeenCalledTimes(1).
  4. The loading test needs control over when the response arrives. mockReturnValueOnce(new Promise(...)) hands back a promise the test resolves itself. While it's pending, "Loading…" is on screen (checked synchronously). Then respond(...) lets it finish, and waitForElementToBeRemoved waits for the loading text to go.
  5. In the error test, new Response("boom", { status: 500 }) has ok: false, so the component throws HTTP 500, the catch stores the message, and the role="alert" paragraph appears. Testing the failure path is as important as the happy path.

Using getByText where findByText is needed is the classic async mistake. This is my notes' "getByText (synchronous)" case, run for real:

GetByTooEarly.test.jsx
test("getByText right after the click", () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json("Fetched Data"));
  render(<FetchButton />);
  fireEvent.click(screen.getByText("Fetch Data"));
  expect(screen.getByText("Fetched Data")).toBeInTheDocument();
});
Output
stderr | src/ch20/GetByTooEarly.fail.jsx > getByText right after the click
An update to FetchButton inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
  /* fire events that update state */
});
/* assert on the output */

This ensures that you're testing the behavior the user would see in the browser. Learn more at https://react.dev/link/wrap-tests-with-act

 FAIL  src/ch20/GetByTooEarly.fail.jsx > getByText right after the click
TestingLibraryElementError: Unable to find an element with the text: Fetched Data. This could be because the text is broken up by multiple elements. …

<body>
  <div>
    <div>
      <button>
        Fetch Data
      </button>
      <p>
        Loading…
      </p>
    </div>
  </div>
</body>
What’s happening
  1. fireEvent.click runs handleClick synchronously up to its first await. setLoading(true) has happened, and RTL's act wrapper flushed it, so the DOM shows "Loading…" (visible in the dump).
  2. The mocked fetch promise is already resolved, but its .then continuation is a microtask, and microtasks can't run while the test function is still executing synchronously. So getByText("Fetched Data") runs while the component is still loading, and throws.
  3. The test fails and ends. Only then do the microtasks run: setData and setLoading(false) happen outside any act scope, which is what the "not wrapped in act(...)" warning is about (once per state update, so twice here).
  4. The fix isn't to wrap things in act by hand. It's to wait for the outcome: expect(await screen.findByText("Fetched Data")).toBeInTheDocument(). findBy runs inside RTL's async act handling, so the updates are flushed and the warning goes away.
  5. Whenever you see the act warning, read it as "something changed after your test stopped looking". Usually a findBy or await is missing, or a test ends while a request is still in flight.
AdvancedFake timers with Vitest: why tests hang, and the fixes

With Jest, findBy and waitFor notice Jest's fake timers and advance them for you. They don't recognise Vitest's. Testing Library's check is literally typeof jest !== "undefined" (in @testing-library/dom/dist/helpers.js). So with vi.useFakeTimers(), findByRole("status") on the Cart above waits on fake time that nobody advances. In the sandbox it ran until the 3000 ms test timeout: "Error: Test timed out in 3000ms."

user-event is worse: it hangs even when your own timers aren't involved. After each interaction, RTL's asyncWrapper waits for a setTimeout(0) to "drain the microtask queue", and under Vitest's fake timers that timeout never fires.

Three fixes, all verified:

FakeTimers.test.jsx
afterEach(() => vi.useRealTimers());

test("fix 1: advance the clock yourself, inside act", async () => {
  vi.useFakeTimers();
  render(<Cart items={[]} />);
  await act(() => vi.advanceTimersByTime(100));
  expect(screen.getByRole("status")).toHaveTextContent("10% off today");
});

test("fix 2: let fake time follow real time", async () => {
  vi.useFakeTimers({ shouldAdvanceTime: true });
  render(<Cart items={[]} />);
  expect(await screen.findByRole("status")).toHaveTextContent("10% off today");
});

test("debounce: one search after the user stops typing", async () => {
  vi.useFakeTimers();
  // Testing Library only recognises Jest's fake timers; this shim lets it advance Vitest's.
  vi.stubGlobal("jest", { advanceTimersByTime: (ms) => vi.advanceTimersByTime(ms) });
  const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
  const onSearch = vi.fn();
  render(<DebouncedSearch onSearch={onSearch} />); // calls onSearch 300 ms after the last keystroke

  await user.type(screen.getByLabelText("Search"), "react");
  expect(onSearch).not.toHaveBeenCalled(); // still inside the 300 ms window

  await act(() => vi.advanceTimersByTime(299));
  expect(onSearch).not.toHaveBeenCalled();

  await act(() => vi.advanceTimersByTime(1));
  expect(onSearch).toHaveBeenCalledTimes(1);
  expect(onSearch).toHaveBeenCalledWith("react");
  vi.unstubAllGlobals();
});
What’s happening
  1. Fix 1 skips the waiting utilities entirely. vi.advanceTimersByTime(100) fires the promo timer, and wrapping it in act makes React flush the resulting setPromo render before the next line. Then a plain getByRole succeeds. This is the most precise option: you control exactly how much time passes.
  2. Fix 2, shouldAdvanceTime: true, makes the fake clock tick along with real time, so findBy's polling sees the timer fire after about 100 real milliseconds. Easy, but you lose the "time stands still" control.
  3. Fix 3 is for user-event with fake timers. advanceTimers tells user-event how to move fake time for its own delays between keystrokes, and the jest shim makes RTL's jest !== undefined check pass. RTL also checks that setTimeout has a clock property, which Vitest's fake setTimeout does. Without the shim, user.type hung and the test hit the 5000 ms timeout.
  4. The debounce test then proves the timing exactly: five keystrokes, no search; 299 ms later, still none; 1 ms more and exactly one search for "react". Each keystroke's effect cleanup cleared the previous timer, which is what debouncing means.
  5. Rule of thumb: with Vitest, prefer real timers plus findBy for ordinary async UI, and reach for fake timers only for long or exact delays (debounce, polling, toasts that disappear after 5 s).
AdvancedSuspense and use() need an awaited act

Components that suspend (use(promise), React.lazy, Suspense-enabled data libraries) don't work with a plain render(...) in React 19. A component reading use(promise) stayed on its fallback forever in the sandbox, even after the promise resolved, and React logged two warnings:

Output
A suspended resource finished loading inside a test, but the event was not wrapped in act(...).
…
A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React
components that depend on asynchronous data, you must await the result:

await act(() => ...)

The fix is to render inside an awaited async act, and resolve data inside one too:

React
let container;
await act(async () => ({ container } = render(<Suspense fallback={<p>Loading…</p>}><Album id={1} /></Suspense>)));
await act(async () => resolveAlbum({ title: "Album 1" })); // now the album renders
What’s happening
  1. RTL's render uses a synchronous act. When a component suspends inside it, React 19 needs the act scope to stay open until the suspended data arrives; a sync act can't, so React warns and the retry never gets scheduled in the test.
  2. await act(async () => render(...)) keeps the scope async. React renders the fallback, notes the pending promise, and when the promise resolves inside a later awaited act, it retries and commits the real content.
  3. This is used in the Suspense and transition questions in Review Questions, where it's what made the predicted output observable.

#Testing custom hooks

A hook can only run inside a component, so you can't call useCounter() from a test directly ("Invalid hook call"). renderHook solves it: it renders a tiny invisible test component that calls your hook and stores the latest return value in result.current. You interact with the hook through what it returns, wrap anything that updates state in act, and assert on result.current.

The mental model: renderHook is a component with no UI, and result.current is a window onto its latest render.

My notes' version used renderHook from @testing-library/react-hooks together with waitForNextUpdate. Correcting my notes: that package was for React 16 and 17. It doesn't support React 18's createRoot and is deprecated. renderHook moved into @testing-library/react itself (version 13.1, 2022), and waitForNextUpdate didn't come with it: you use waitFor with an assertion instead.

useCounter.js
import { useCallback, useState } from "react";

export function useCounter(initial = 0, step = 1) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount((c) => c + step), [step]);
  const reset = useCallback(() => setCount(initial), [initial]);
  return { count, increment, reset };
}
useCounter.test.jsx
import { act, renderHook } from "@testing-library/react";
import { useCounter } from "./useCounter";

test("starts at the initial value", () => {
  const { result } = renderHook(() => useCounter(5));
  expect(result.current.count).toBe(5);
});

test("increment and reset", () => {
  const { result } = renderHook(() => useCounter(0, 2));

  act(() => result.current.increment());
  act(() => result.current.increment());
  expect(result.current.count).toBe(4);

  act(() => result.current.reset());
  expect(result.current.count).toBe(0);
});

test("new props reach the hook through rerender", () => {
  const { result, rerender } = renderHook(({ step }) => useCounter(0, step), {
    initialProps: { step: 1 },
  });
  act(() => result.current.increment()); // 0 → 1
  rerender({ step: 10 });
  act(() => result.current.increment()); // 1 → 11
  expect(result.current.count).toBe(11);
});

test("result.current is a live view, not a snapshot", () => {
  const { result } = renderHook(() => useCounter());
  const { count, increment } = result.current; // destructured once
  act(() => increment());
  console.log("destructured:", count, "| live:", result.current.count);
  // destructured: 0 | live: 1
});
What’s happening
  1. renderHook(() => useCounter(5)) renders a test component whose body is that callback. After the first render, result.current is { count: 5, increment, reset }.
  2. result.current.increment() calls setCount, a state update, so it goes inside act. act flushes the update and the re-render before returning, and result.current now points at the new return value. Two increments with step 2 take count from 0 → 2 → 4; reset brings it back to 0.
  3. Hooks get their arguments from the component, so to change them you pass initialProps and call rerender with new props. After rerender({ step: 10 }), useCallback sees the new step, creates a new increment, and the next call adds 10: 1 → 11.
  4. The last test shows a classic trap. Destructuring count copies the value from the first render, so it stays 0 forever. result.current is re-read on every access and shows 1. Always read through result.current after an act.
  5. Skipping act is the other trap. Calling result.current.increment() bare logged "An update to TestComponent inside a test was not wrapped in act(...)" and the assertion failed with "expected +0 to be 1": the update hadn't been flushed when expect ran. ("TestComponent" is the name of renderHook's invisible component.)

Now the useFetchData hook from my notes, with its AbortController cleanup (shown in full in useFetch: a data-fetching hook). First, my notes' test moved to Vitest as literally as possible:

useFetchData.notes.test.jsx
import { renderHook, waitFor } from "@testing-library/react";
import { useFetchData } from "./useFetchData";

// My notes' test, moved to Vitest: jest.fn → vi.fn, waitForNextUpdate → waitFor.
globalThis.fetch = vi.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ data: "Test Data" }),
  })
);

test("fetches data successfully", async () => {
  const { result } = renderHook(() => useFetchData("https://api.example.com"));
  await waitFor(() => expect(result.current.loading).toBe(false));
  expect(result.current.data).toEqual({ data: "Test Data" });
  expect(result.current.error).toBeNull();
});
Output
 FAIL  src/ch20/useFetchData.notes.fail.jsx > fetches data successfully
AssertionError: expected null to deeply equal { data: 'Test Data' }

- Expected:
{
  "data": "Test Data",
}

+ Received:
null
What’s happening
  1. The hook renders with loading: true and starts fetch(url, { signal }), which returns the mock object { json }.
  2. The hook then checks if (!response.ok). The mock has no ok property, so !undefined is true and the hook throws new Error("Error: undefined") (it uses response.statusText, also missing).
  3. The catch block stores that error and sets loading to false. waitFor sees loading === false and stops waiting, but data is still null, hence "expected null to deeply equal { data: 'Test Data' }".
  4. So the bug is in the test, not the hook. The original waitForNextUpdate version would have failed the same way. The fix is a realistic mock: Response.json({ data: "Test Data" }) has ok: true.
  5. My notes' error test, Promise.reject("API is down") with expect(result.current.error).toEqual("API is down"), does pass, because the hook stores whatever was thrown. But real fetch rejects with a TypeError, never a string, so the corrected version below rejects with one.

The corrected tests, covering success, both kinds of failure, and the cleanup my notes called "IMPORTANT":

useFetchData.test.jsx
import { renderHook, waitFor } from "@testing-library/react";
import { useFetchData } from "./useFetchData";

let fetchMock;
beforeEach(() => {
  fetchMock = vi.spyOn(globalThis, "fetch");
});
afterEach(() => {
  vi.restoreAllMocks();
});

test("starts loading, then returns the data", async () => {
  fetchMock.mockResolvedValue(Response.json({ data: "Test Data" }));

  const { result } = renderHook(() => useFetchData("https://api.example.com"));
  expect(result.current).toEqual({ data: null, loading: true, error: null });

  await waitFor(() => expect(result.current.loading).toBe(false));
  expect(result.current.data).toEqual({ data: "Test Data" });
  expect(result.current.error).toBeNull();
});

test("an HTTP error becomes an Error", async () => {
  fetchMock.mockResolvedValue(new Response(null, { status: 503, statusText: "Service Unavailable" }));

  const { result } = renderHook(() => useFetchData("https://api.example.com"));

  await waitFor(() => expect(result.current.error).not.toBeNull());
  expect(result.current.error.message).toBe("Error: Service Unavailable");
  expect(result.current.data).toBeNull();
});

test("a network failure is reported as is", async () => {
  fetchMock.mockRejectedValue(new TypeError("fetch failed"));

  const { result } = renderHook(() => useFetchData("https://api.example.com"));

  await waitFor(() => expect(result.current.error).toBeInstanceOf(TypeError));
  expect(result.current.loading).toBe(false);
});

test("unmounting aborts the request", async () => {
  fetchMock.mockReturnValue(new Promise(() => {})); // never settles
  const { unmount } = renderHook(() => useFetchData("https://api.example.com"));

  const { signal } = fetchMock.mock.calls[0][1];
  expect(signal.aborted).toBe(false);
  unmount();
  expect(signal.aborted).toBe(true);
});
What’s happening
  1. The first test checks the initial return value synchronously (loading: true, nothing else) before waiting. A hook's starting state is part of its contract: components render a spinner from it.
  2. new Response(null, { status: 503, statusText: "Service Unavailable" }) has ok: false, so the hook throws Error: Service Unavailable and stores it. The test waits for error to be set, then checks its message.
  3. mockRejectedValue(new TypeError("fetch failed")) simulates what real fetch does when the network is down (in Node the message is "fetch failed"; browsers say "Failed to fetch"). The hook's err.name !== "AbortError" check lets it through, so it's stored.
  4. The abort test grabs the signal the hook passed as fetch's second argument (mock.calls[0][1]). A never-settling promise keeps the request "in flight"; unmount() runs the effect cleanup, controller.abort() fires, and signal.aborted flips to true. That's the memory-leak protection from my notes, now proven by a test.
AdvancedChanging the URL, and Strict Mode's double fetch
useFetchData.test.jsx
test("changing the url aborts the old request and fetches the new one", async () => {
  fetchMock.mockImplementation((url) =>
    url.endsWith("/1") ? new Promise(() => {}) : Promise.resolve(Response.json({ id: 2 }))
  );
  const { result, rerender } = renderHook(({ url }) => useFetchData(url), {
    initialProps: { url: "/users/1" },
  });
  const firstSignal = fetchMock.mock.calls[0][1].signal;

  rerender({ url: "/users/2" });

  expect(firstSignal.aborted).toBe(true);
  await waitFor(() => expect(result.current.data).toEqual({ id: 2 }));
  expect(fetchMock).toHaveBeenCalledTimes(2);
});

test("in Strict Mode the first request is aborted straight away", async () => {
  fetchMock.mockResolvedValue(Response.json({ ok: true }));
  const { result } = renderHook(() => useFetchData("/x"), { reactStrictMode: true });
  await waitFor(() => expect(result.current.loading).toBe(false));
  console.log("calls:", fetchMock.mock.calls.length,
    "aborted:", fetchMock.mock.calls.map((c) => c[1].signal.aborted));
  // calls: 2 aborted: [ true, false ]
});
What’s happening
  1. mockImplementation answers per URL: /users/1 never settles, /users/2 resolves at once. This is the race-condition setup from Race conditions and AbortController.
  2. rerender({ url: "/users/2" }) changes the effect's dependency, so React runs the old effect's cleanup (aborting request 1) before starting request 2. The first signal is aborted, and only { id: 2 } ever reaches state.
  3. reactStrictMode: true (an option of render and renderHook in RTL 16, also settable globally with configure({ reactStrictMode: true })) renders the hook inside <StrictMode>. In development React mounts, unmounts and remounts effects once, so fetch is called twice and the first call is aborted immediately: [ true, false ].
  4. That's the same double request you see in the browser's Network tab in development, and the test shows the hook already handles it correctly. Running a few hook tests in Strict Mode is a cheap way to catch effects that don't clean up.

When to use renderHook: for reusable hooks (shared across components or published in a library), where the hook's API is the contract. If a hook exists only to serve one component, test it through that component instead; you'll test what users see and won't have to rewrite tests when you inline or split the hook.

#Mocking network requests

A component that fetches data needs a fake server in tests: real APIs are slow, flaky and change their data. The question is where to cut. There are three places, from closest to the component to furthest:

Cut atHowYour code that still runsGood for
Your API modulevi.mock("./api")Only the componentQuick unit tests; the data layer is tested elsewhere
The fetch globalvi.spyOn(globalThis, "fetch")Component and your fetch wrapperSmall tests; you must build Response objects yourself
The networkMSWEverything: fetch or axios, TanStack Query, retries, headers, JSON parsingIntegration tests, shared mocks for dev and Storybook

MSW (Mock Service Worker) intercepts requests at the network level. In the browser it uses a Service Worker; in Node (Vitest, Jest) it patches the HTTP modules and fetch. Your code makes a normal request, MSW matches it against request handlers and answers with a mocked response. The mental model: MSW is a fake backend living in the same process, with routes written like a tiny Express app. Your component doesn't know it's being tested.

My notes' MSW example ("Mocking API calls… We did it in SMT") used the version 1 API: rest.get(url, (req, res, ctx) => res(ctx.json(...))). Correcting my notes: MSW 2 (2023) replaced rest with http and the res(ctx…) composition with returning a standard Response, usually via HttpResponse.json(). MSW 3 (current: 3.0.2) keeps that handler API, adds subpath imports like msw/http, and renames the listen option for unmatched requests to onUnhandledFrame (it was onUnhandledRequest). MSW wasn't in the shared sandbox, so I installed 3.0.2 into my own sandbox folder and ran everything below against it.

MSW 1 (my notes)MSW 2 and 3
import { rest } from "msw"import { http, HttpResponse } from "msw" (or "msw/http" in v3)
rest.get(url, (req, res, ctx) => …)http.get(url, ({ request, params, cookies }) => …)
res(ctx.json({ name: "John Doe" }))HttpResponse.json({ name: "John Doe" })
res(ctx.status(500), ctx.json({ error }))HttpResponse.json({ error }, { status: 500 })
res.networkError("…")HttpResponse.error()
ctx.delay(200)await delay(200)
server.listen({ onUnhandledRequest: "error" })v3: server.listen({ onUnhandledFrame: "error" })

My notes' test, rewritten for MSW 3, with the fetchData it was testing:

api.js
export async function fetchData(url) {
  const response = await fetch(url);
  const body = await response.json();
  if (!response.ok) throw new Error(body.error ?? `HTTP ${response.status}`);
  return body;
}
handlers.js
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("https://api.example.com/users/:id", ({ params }) => {
    return HttpResponse.json({ id: Number(params.id), name: "John Doe" });
  }),
];
msw.test.jsx
import { http, HttpResponse, delay } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
import { fetchData } from "./api";
import { UserProfile } from "./UserProfile";
import { handlers } from "./handlers";

const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("fetchData", () => {
  it("returns the user", async () => {
    const data = await fetchData("https://api.example.com/users/1");
    expect(data).toEqual({ id: 1, name: "John Doe" });
  });

  it("throws the server's error message", async () => {
    server.use(
      http.get("https://api.example.com/users/:id", () =>
        HttpResponse.json({ error: "Server error" }, { status: 500 })
      )
    );
    await expect(fetchData("https://api.example.com/users/1")).rejects.toThrow("Server error");
  });
});
What’s happening
  1. handlers.js describes the fake backend: a GET to /users/:id answers with JSON. :id is a path parameter, read from params (always a string, hence Number(params.id)). Keeping handlers in their own file lets tests, Storybook and the dev server share them.
  2. setupServer(...handlers) creates the Node interceptor. server.listen() in beforeAll switches interception on for the file. onUnhandledFrame: "error" makes any request without a handler fail loudly instead of quietly going to the real internet.
  3. The first test calls the real fetchData, which calls the real fetch. MSW intercepts it, the handler returns { id: 1, name: "John Doe" }, and response.json() parses it. Nothing in api.js was mocked, so its parsing and error handling are really tested.
  4. The error test uses server.use(...) to prepend a handler for this test only. It wins over the default one and returns a 500 with { error: "Server error" }. fetchData sees ok: false, throws new Error(body.error), and .rejects.toThrow("Server error") passes. Note the await on expect(...).rejects: without it the test would finish before the assertion ran.
  5. afterEach(() => server.resetHandlers()) removes the per-test override, so the next test sees the defaults again. afterAll(() => server.close()) restores the real fetch. Those three hooks are the standard MSW lifecycle, the same in my notes.

The real payoff is component tests. Same server, now behind a component that fetches in an effect:

UserProfile.jsx
import { useEffect, useState } from "react";
import { fetchData } from "./api";

export function UserProfile({ id }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetchData(`https://api.example.com/users/${id}`)
      .then((u) => !ignore && setUser(u))
      .catch((e) => !ignore && setError(e.message));
    return () => {
      ignore = true;
    };
  }, [id]);

  if (error) return <p role="alert">{error}</p>;
  if (!user) return <p>Loading…</p>;
  return <h1>{user.name}</h1>;
}
msw.test.jsx
describe("UserProfile", () => {
  it("shows the name once it arrives", async () => {
    render(<UserProfile id={7} />);
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    expect(await screen.findByRole("heading", { name: "John Doe" })).toBeInTheDocument();
  });

  it("shows a network error", async () => {
    server.use(http.get("https://api.example.com/users/:id", () => HttpResponse.error()));
    render(<UserProfile id={7} />);
    expect(await screen.findByRole("alert")).toHaveTextContent("fetch failed"); // Node's wording; browsers say "Failed to fetch"
  });

  it("can slow a response down", async () => {
    server.use(
      http.get("https://api.example.com/users/:id", async () => {
        await delay(200);
        return HttpResponse.json({ id: 7, name: "Slow Sam" });
      })
    );
    render(<UserProfile id={7} />);
    expect(await screen.findByText("Slow Sam")).toBeInTheDocument();
  });

  it("checks what the app sent", async () => {
    const seen = [];
    server.use(
      http.get("https://api.example.com/users/:id", ({ request, params }) => {
        seen.push({ url: request.url, id: params.id });
        return HttpResponse.json({ id: 3, name: "Asha" });
      })
    );
    render(<UserProfile id={3} />);
    await screen.findByText("Asha");
    expect(seen).toEqual([{ url: "https://api.example.com/users/3", id: "3" }]);
  });
});
What’s happening
  1. The first test is a full round trip: effect runs, fetchData calls fetch, MSW answers, setUser re-renders. "Loading…" is checked synchronously first, then findByRole waits for the heading. No mock knows anything about UserProfile.
  2. HttpResponse.error() simulates a network failure, so fetch rejects with a TypeError. In Node its message is "fetch failed"; my first version of this test expected the browser's "Failed to fetch" and failed with Expected element to have text content: Failed to fetch / Received: fetch failed. Assert on your own UI text when you can, not on platform error messages.
  3. await delay(200) inside a handler slows the response, which is how you test loading states, timeouts and race conditions without fake timers. findByText waited the 200 ms (the test took 208 ms).
  4. The last test records what the handler received. request is a standard Request and params.id is "3", a string. This is how you check the app sent the right URL, query string, headers or body. Prefer it to expect(fetch).toHaveBeenCalledWith(...), which ties the test to fetch specifically.

Without a handler, onUnhandledFrame: "error" turns a forgotten mock into a clear failure. The real output for a fetch("https://api.example.com/orders") nobody handles:

Output
stderr | src/ch20/msw-unhandled.fail.jsx > a request nobody handles
[MSW] Error: intercepted a request without a matching request handler:

  • GET https://api.example.com/orders

If you still wish to intercept this unhandled request, please create a request handler for it.
Read more: https://mswjs.io/docs/http/intercepting-requests

 FAIL  src/ch20/msw-unhandled.fail.jsx > a request nobody handles
TypeError: fetch failed
Caused by: InternalError: [MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledFrame" option.
AdvancedThe other two cuts: vi.mock and vi.spyOn

Mocking your own API module is the quickest option, and fine when the component is what you're testing:

viMock.test.jsx
import { render, screen } from "@testing-library/react";
import { fetchData } from "./api";
import { UserProfile } from "./UserProfile";

vi.mock("./api", () => ({
  fetchData: vi.fn(),
}));

test("UserProfile with the api module mocked", async () => {
  fetchData.mockResolvedValue({ id: 5, name: "Mocked Mia" });

  render(<UserProfile id={5} />);

  expect(await screen.findByRole("heading", { name: "Mocked Mia" })).toBeInTheDocument();
  expect(fetchData).toHaveBeenCalledWith("https://api.example.com/users/5");
});

test("and the error path", async () => {
  fetchData.mockRejectedValue(new Error("Server error"));
  render(<UserProfile id={5} />);
  expect(await screen.findByRole("alert")).toHaveTextContent("Server error");
});
What’s happening
  1. vi.mock("./api", factory) is hoisted above the imports by Vitest, so when UserProfile.jsx imports ./api, it gets the factory's object instead of the real module. The test's own import { fetchData } gets the same mock.
  2. Each test programs the mock: mockResolvedValue for success, mockRejectedValue for failure. The component's effect calls it and renders the result.
  3. What's lost: api.js never runs, so a bug in its parsing or ok check would go unnoticed, and the mock returns whatever shape you typed, even one the real API never sends. That's why MSW is the better default for anything beyond a unit test.
  4. The fetch spy (used in Testing asynchronous behaviour) sits in between: your wrapper runs, but you hand-build Response objects. One trap: mockResolvedValue(Response.json(x)) returns the same Response every time, and a body can be read only once. The second call failed with "TypeError: Body is unusable: Body has already been read". Use mockImplementation(() => Promise.resolve(Response.json(x))) when fetch is called more than once.

#Testing components that use context, routers and stores

Added

Real components rarely stand alone. They read a theme from context, call useNavigate, select from a Redux store, or use TanStack Query. Render one of them bare and it crashes, because the provider it expects isn't there (or, with context, it silently gets the default value). The fix is to render the component inside the same providers the app uses, with test-friendly settings.

The mental model: each test builds a small, fresh copy of the app's shell around the component: a new store, a new query cache, a memory router at the right URL. Fresh matters: a store or query cache shared between tests leaks state from one test into the next.

Context is the simplest case. RTL's wrapper option renders your providers around the component:

ThemeContext.jsx
import { createContext, useContext } from "react";

export const ThemeContext = createContext("light");

export function ThemedButton({ children }) {
  const theme = useContext(ThemeContext);
  return <button className={`btn-${theme}`}>{children}</button>;
}
providers.test.jsx
test("context: pass a value with the wrapper option", () => {
  render(<ThemedButton>Save</ThemedButton>, {
    wrapper: ({ children }) => <ThemeContext value="dark">{children}</ThemeContext>,
  });
  expect(screen.getByRole("button")).toHaveClass("btn-dark");
});

test("context: without a provider you get the default", () => {
  render(<ThemedButton>Save</ThemedButton>);
  expect(screen.getByRole("button")).toHaveClass("btn-light");
});
What’s happening
  1. wrapper is a component that receives the rendered UI as children. RTL renders <Wrapper><ThemedButton>…</ThemedButton></Wrapper>, and also uses the wrapper for rerender, so providers stay in place when props change.
  2. <ThemeContext value="dark"> is the React 19 way to provide a context: the context object itself is the provider. In React 18 and earlier you'd write <ThemeContext.Provider value="dark">, which still works in 19.
  3. Without a wrapper, useContext finds no provider and returns the default passed to createContext("light"). The test passes, but notice that a forgotten provider doesn't crash: it silently renders the default. That's a reason to have the default be something obviously wrong, or to throw from a custom hook, as in Context best practices and re-renders.

For a whole app shell, write one renderWithProviders helper and use it everywhere (this is the pattern from the Redux Toolkit docs, extended with a router and TanStack Query):

cartSlice.js
import { configureStore, createSlice } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    added(state, action) {
      state.items.push(action.payload);
    },
  },
});

export const { added } = cartSlice.actions;

export function makeStore(preloadedState) {
  return configureStore({ reducer: { cart: cartSlice.reducer }, preloadedState });
}
test-utils.jsx
import { render } from "@testing-library/react";
import { Provider } from "react-redux";
import { MemoryRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { makeStore } from "./cartSlice";

// One place that knows which providers the app needs. Every test gets fresh instances.
export function renderWithProviders(
  ui,
  { preloadedState, store = makeStore(preloadedState), route = "/", ...options } = {}
) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  function Wrapper({ children }) {
    return (
      <Provider store={store}>
        <QueryClientProvider client={queryClient}>
          <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
        </QueryClientProvider>
      </Provider>
    );
  }

  return { store, queryClient, ...render(ui, { wrapper: Wrapper, ...options }) };
}
What’s happening
  1. makeStore(preloadedState) is a factory, not a module-level store. Each call builds a new store, so each test starts clean; preloadedState lets a test start from "two items already in the cart" without clicking through the UI to get there.
  2. A new QueryClient per test means a new, empty cache. retry: false matters: TanStack Query retries failed queries three times with backoff (1 s, 2 s, 4 s) by default, so an error-state test would time out long before the error showed.
  3. MemoryRouter keeps history in memory instead of the address bar, and initialEntries={[route]} starts it at the URL the test needs, so useParams and useNavigate work.
  4. The helper returns store and queryClient along with everything render returns, so a test can dispatch to the store or inspect its state when that's the clearest way to check something.

A page using all three: a route param, a query, a store and navigation.

ProductPage.jsx
import { useQuery } from "@tanstack/react-query";
import { Link, Route, Routes, useNavigate, useParams } from "react-router";
import { AddToCart, CartBadge } from "./CartBadge"; // dispatch(added(product)) / useSelector(items.length)

function Product() {
  const { id } = useParams();
  const navigate = useNavigate();
  const { data, isPending, isError } = useQuery({
    queryKey: ["product", id],
    queryFn: async () => {
      const res = await fetch(`/api/products/${id}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
  });

  if (isPending) return <p>Loading…</p>;
  if (isError) return <p role="alert">Could not load product</p>;
  return (
    <article>
      <h1>{data.name}</h1>
      <AddToCart product={data} />
      <button onClick={() => navigate("/cart")}>Go to cart</button>
    </article>
  );
}

export function Shop() {
  return (
    <>
      <header>
        <Link to="/">Home</Link> <CartBadge />
      </header>
      <Routes>
        <Route path="/products/:id" element={<Product />} />
        <Route path="/cart" element={<h1>Your cart</h1>} />
      </Routes>
    </>
  );
}
providers.test.jsx
test("the whole page: route, query, store and navigation", async () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ id: 42, name: "Keyboard" }));
  const user = userEvent.setup();
  const { store } = renderWithProviders(<Shop />, { route: "/products/42" });

  expect(screen.getByText("Loading…")).toBeInTheDocument();
  expect(await screen.findByRole("heading", { name: "Keyboard" })).toBeInTheDocument();
  expect(fetch).toHaveBeenCalledWith("/api/products/42");

  await user.click(screen.getByRole("button", { name: "Add Keyboard" }));
  expect(screen.getByLabelText("Items in cart")).toHaveTextContent("1");
  expect(store.getState().cart.items).toEqual([{ id: 42, name: "Keyboard" }]);

  await user.click(screen.getByRole("button", { name: "Go to cart" }));
  expect(screen.getByRole("heading", { name: "Your cart" })).toBeInTheDocument();
});

test("a failed query shows the error at once (retry: false)", async () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 404 }));
  renderWithProviders(<Shop />, { route: "/products/1" });
  expect(await screen.findByRole("alert")).toHaveTextContent("Could not load product");
});
What’s happening
  1. route: "/products/42" starts the memory router there. <Routes> matches /products/:id, useParams() gives { id: "42" }, and the query key becomes ["product", "42"].
  2. On the first render the query has no data, so isPending is true and "Loading…" shows. The query function calls the mocked fetch, and findByRole("heading", { name: "Keyboard" }) waits for the data render. The fetch assertion proves the URL was built from the route param.
  3. Clicking "Add Keyboard" dispatches added(product) to the real store. CartBadge, subscribed with useSelector, re-renders to "1". Checking store.getState() as well is optional; the badge already proves it from the user's side.
  4. "Go to cart" calls navigate("/cart"). The memory router updates its location, <Routes> renders the cart route, and the new heading appears. Navigation is tested without a browser and without mocking useNavigate.
  5. In the second test the fetch returns a 404, the query function throws, and with retry: false the query errors immediately, so the alert appears within findByRole's 1000 ms. With the default retry: 3, the same test failed with "Unable to find role="alert"": after a second, fetch had been called only once, and the first retry was still waiting for its 1 s delay.
AdvancedRoute modules with loaders: createRoutesStub

Components that read useLoaderData() or useActionData() need a data router, which MemoryRouter isn't. React Router 7 and later ship createRoutesStub for exactly this:

providers.test.jsx
import { createRoutesStub, useLoaderData } from "react-router";

test("createRoutesStub: give a route component its loader data", async () => {
  function Invoice() {
    const invoice = useLoaderData();
    return <h1>Invoice {invoice.number}</h1>;
  }
  const Stub = createRoutesStub([
    {
      path: "/invoices/:id",
      Component: Invoice,
      HydrateFallback: () => <p>Loading…</p>,
      loader: ({ params }) => ({ number: `INV-${params.id}` }),
    },
  ]);
  render(<Stub initialEntries={["/invoices/7"]} />);
  expect(await screen.findByRole("heading", { name: "Invoice INV-7" })).toBeInTheDocument();
});
What’s happening
  1. createRoutesStub takes route objects (path, Component, loader, action, children) and returns a component that runs them in an in-memory data router.
  2. initialEntries={["/invoices/7"]} navigates to that URL. The router matches the route, runs its loader with params.id === "7", and renders Invoice once the data is ready.
  3. The loader here is a stand-in for the real one. You can import the real loader instead and mock the network under it with MSW, which tests the route the way it really runs.
  4. HydrateFallback is what renders while the first loader runs. Without it, the router logged "No HydrateFallback element provided to render during initial hydration" during the test.

#End-to-end tests with Cypress and Playwright

End-to-end (E2E) tests run the real app in a real browser: real routing, real CSS and layout, real browser APIs, and often a real backend. They catch what jsdom can't: a button hidden behind a sticky header, a broken production build, a CORS error, a redirect loop after login. The cost is speed and stability, seconds per test instead of milliseconds, and more moving parts. So you write a few, for the flows that make money or lose trust: sign-up, login, checkout, the main search.

The mental model: an E2E test is a scripted QA tester. It opens the site, clicks and types like a person, and checks what appears on screen. Both tools wait automatically. Cypress retries each command and assertion until it passes or times out (4 s by default), and Playwright's locators and expect do the same, so you rarely write explicit waits.

The specs below are written for Cypress 16 and Playwright 1.64. Neither could run in this sandbox (no browsers and no app server), so they weren't executed. They use only long-standing APIs of both tools.

My notes had a full user-flow example: register, log in, add an item to the cart. Here it is with the best practices my notes listed (fixtures, mocking API calls) applied, and a few fixes:

cypress/e2e/checkout.cy.js
describe("E-commerce user flow", () => {
  beforeEach(() => {
    // Mock the product list with a fixture so the test doesn't depend on catalogue data.
    cy.intercept("GET", "/api/products", { fixture: "products.json" }).as("getProducts");
  });

  it("registers, logs in and adds an item to the cart", () => {
    const email = `user-${Date.now()}@example.com`; // unique, so reruns don't collide

    // Step 1: register
    cy.visit("/register");
    cy.get("[data-cy=username]").type("newUser");
    cy.get("[data-cy=email]").type(email);
    cy.get("[data-cy=password]").type("password123");
    cy.get("[data-cy=submit]").click();
    cy.location("pathname").should("eq", "/login"); // registration really succeeded

    // Step 2: log in
    cy.get("[data-cy=email]").type(email);
    cy.get("[data-cy=password]").type("password123");
    cy.get("[data-cy=submit]").click();
    cy.contains("h1", "Welcome, newUser").should("be.visible");

    // Step 3: add an item to the cart
    cy.visit("/products");
    cy.wait("@getProducts");
    cy.contains("[data-cy=product-item]", "Mechanical keyboard").click();
    cy.get("[data-cy=add-to-cart]").click();

    cy.get("[data-cy=cart-count]").should("have.text", "1");
    cy.visit("/cart");
    cy.get("[data-cy=cart-item]").should("have.length", 1);
  });
});
cypress/fixtures/products.json
[
  { "id": 1, "name": "Mechanical keyboard", "price": 89 },
  { "id": 2, "name": "Wireless mouse", "price": 35 }
]
What’s happening
  1. cy.intercept("GET", "/api/products", { fixture: "products.json" }) is Cypress's way of mocking API calls: any matching request from the app gets the fixture file as its response. .as("getProducts") names it so cy.wait("@getProducts") can wait for the request to actually happen. Fixtures live in cypress/fixtures/ and keep test data out of the spec.
  2. Cypress commands don't run immediately. They're queued, then run in order, each retrying until it succeeds. cy.get("[data-cy=username]") keeps looking until the field exists, so slow renders don't need sleeps.
  3. Fix from my notes: after registering, the notes did cy.visit("/login") straight away, so a broken registration would only show up later as a confusing login failure. cy.location("pathname").should("eq", "/login") asserts the redirect happened. Assert after each step so a failure points at the step that broke.
  4. Fix from my notes: selectors like .product-item and button.add-to-cart break when someone renames a CSS class. data-cy attributes exist only for tests, so they survive restyling. cy.contains(selector, text) picks the product by what the user reads, not by position (.first() breaks if the order changes).
  5. The cart assertions use should, which retries: should("have.text", "1") waits for the badge to update after the add-to-cart request, up to the default 4 s timeout.
  6. Fix from my notes: registering a brand-new user through the UI in every test is slow and repeats the same steps. Test the registration form once, here; every other test should log in through the API with cy.session (below).

Mocking an error and reusing a login, two things my notes' "best practices" pointed at:

cypress/e2e/cart-errors.cy.js
Cypress.Commands.add("login", (email, password) => {
  cy.session([email, password], () => {
    cy.request("POST", "/api/login", { email, password }); // the response sets the session cookie
  });
});

describe("cart errors", () => {
  beforeEach(() => {
    cy.login("rohit@example.com", "test-password");
    cy.intercept("GET", "/api/products", { fixture: "products.json" });
  });

  it("shows the server's message when an item is out of stock", () => {
    cy.intercept("POST", "/api/cart", {
      statusCode: 409,
      body: { error: "Out of stock" },
    }).as("addToCart");

    cy.visit("/products");
    cy.contains("[data-cy=product-item]", "Wireless mouse").click();
    cy.get("[data-cy=add-to-cart]").click();

    cy.wait("@addToCart").its("request.body").should("deep.equal", { productId: 2, quantity: 1 });
    cy.get("[role=alert]").should("contain.text", "Out of stock");
    cy.get("[data-cy=cart-count]").should("have.text", "0");
  });
});
What’s happening
  1. cy.session(id, setup) runs setup once, saves the cookies and storage it produced, and restores them for later tests with the same id. Logging in through cy.request skips the login form entirely, which turns seconds of typing into one HTTP call.
  2. cy.intercept with a statusCode and body forces the error path. Getting a real backend into an "out of stock" state on demand is hard; mocking that one response is easy, and the rest of the app still runs for real.
  3. cy.wait("@addToCart").its("request.body") checks what the app sent: the right product id and quantity. Asserting on the request catches bugs where the UI looks fine but the payload is wrong.
  4. The last two assertions check the user-visible result: an alert with the server's message, and a cart count that didn't change. The credentials here are test values for a test account, never real ones.

The same kind of test in Playwright. Its test runner is separate from Vitest, runs real Chromium, Firefox and WebKit, and its locators read much like Testing Library's queries:

playwright.config.js
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  use: { baseURL: "http://localhost:5173", trace: "on-first-retry" },
  webServer: { command: "npm run dev", url: "http://localhost:5173", reuseExistingServer: true },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
});
e2e/cart.spec.js
import { test, expect } from "@playwright/test";

test("adds an item to the cart", async ({ page }) => {
  await page.route("**/api/products", (route) =>
    route.fulfill({ json: [{ id: 1, name: "Mechanical keyboard", price: 89 }] })
  );

  await page.goto("/products");
  await page.getByRole("link", { name: "Mechanical keyboard" }).click();
  await page.getByRole("button", { name: "Add to cart" }).click();

  await expect(page.getByTestId("cart-count")).toHaveText("1");
  await page.getByRole("link", { name: "Cart" }).click();
  await expect(page.getByRole("listitem")).toHaveCount(1);
});
What’s happening
  1. webServer starts the dev server before the tests (or reuses one already running) and waits for the URL to respond, so npx playwright test is a single command. projects runs every test in Chromium and WebKit.
  2. page.route(pattern, handler) is Playwright's request mocking, the equivalent of cy.intercept. route.fulfill({ json }) answers with a JSON body and the right content type.
  3. Unlike Cypress, Playwright code is ordinary async/await: each action is awaited, and locators like getByRole("button", { name: "Add to cart" }) wait for the element to be visible, enabled and stable before clicking.
  4. await expect(locator).toHaveText("1") is a web-first assertion: it retries until the text matches or the timeout (5 s by default) runs out. A plain expect(await locator.textContent()).toBe("1") would check once and be flaky.
  5. trace: "on-first-retry" records a trace (DOM snapshots, network, console) when a test fails and is retried. npx playwright show-trace replays it step by step, which is the fastest way to debug a CI-only failure.
Cypress 16Playwright 1.64
Runs inThe browser, alongside your appNode, driving browsers over a protocol
BrowsersChrome-family, Firefox, WebKit (experimental)Chromium, Firefox, WebKit
Code styleChained, queued commands (cy.get().click())async/await
Network mockingcy.interceptpage.route
Multiple tabs, originsLimited (cy.origin for other origins)Native (pages, contexts)
Parallel runsPaid Cloud, or split specs yourselfBuilt in (workers, sharding)
DebuggingInteractive runner with time travelUI mode, trace viewer
Component testingYesExperimental

Both are good. Cypress's interactive runner is pleasant for building tests; Playwright is faster in CI, handles multiple tabs and real Safari engines, and has become the more common pick for new projects.

AdvancedVisual testing with Storybook

My notes: "Let's say you have a button. You create a storybook for it… for visual testing and looking at it." That's two jobs. Looking is Storybook itself: each story renders a component in one state (primary, disabled, loading), so you can see every state side by side without clicking through the app (more in Storybook). Visual testing compares screenshots of those stories against approved baselines, so a CSS change that breaks the button's padding fails a check instead of reaching users.

Button.stories.jsx
import { expect, fn, userEvent, within } from "storybook/test";
import { Button } from "./Button";

export default {
  title: "Design system/Button",
  component: Button,
  args: { label: "Save", onClick: fn() },
};

export const Primary = {};
export const Danger = { args: { variant: "danger", label: "Delete" } };
export const Disabled = { args: { disabled: true } };

export const ClickCallsHandler = {
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole("button", { name: "Save" }));
    await expect(args.onClick).toHaveBeenCalledTimes(1);
  },
};
What’s happening
  1. This is Component Story Format: the default export describes the component, and each named export is a story with its own args (props). Storybook wasn't installed in the sandbox, so this file wasn't run; it's written for Storybook 9 and later, where the test helpers are imported from storybook/test (Storybook 8 used @storybook/test).
  2. play runs after the story renders and drives it with the same Testing Library and user-event API as the unit tests. In recent versions, the Vitest addon runs every story, and every play function, as a test in a real browser.
  3. Visual regression is then a screenshot per story compared to a baseline, using a service such as Chromatic, or Playwright's await expect(page).toHaveScreenshot() pointed at each story's URL. A changed pixel shows up as a diff for someone to approve or reject.
  4. The stories double as documentation for the component library, which is how my notes used Storybook at SMT: one place to see, test and review every state of a shared component.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.