React testing cheatsheet.

Setup

npm i -D vitest @vitest/coverage-v8 jsdom \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event

vitest.config.ts:

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./test/setup.ts"],
  },
});

test/setup.ts:

import "@testing-library/jest-dom/vitest";

Basic test

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("renders greeting", () => {
  render(<Hello name="World" />);
  expect(screen.getByText("Hello World")).toBeInTheDocument();
});

test("increments on click", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  
  await user.click(screen.getByRole("button", { name: /add/i }));
  
  expect(screen.getByRole("status")).toHaveTextContent("1");
});

Queries

// getByX — sync, throws if not found
screen.getByRole("button", { name: "Save" })
screen.getByLabelText("Email")
screen.getByPlaceholderText("Search…")
screen.getByText(/welcome/i)
screen.getByDisplayValue("text")
screen.getByAltText("logo")
screen.getByTitle("close")
screen.getByTestId("user-card")

// queryByX — sync, returns null
screen.queryByText("nope")

// findByX — async, awaits up to 1s
await screen.findByText("loaded")

// getAllByX — multiple
screen.getAllByRole("listitem")

Priority: role > label > placeholder > text > displayValue > altText > title > testId.

Matchers (jest-dom)

expect(el).toBeInTheDocument();
expect(el).toBeVisible();
expect(el).toBeDisabled();
expect(el).toBeChecked();
expect(el).toHaveTextContent("hi");
expect(el).toHaveValue("v");
expect(el).toHaveClass("active");
expect(el).toHaveAttribute("href", "/x");
expect(el).toHaveFocus();
expect(input).toBeRequired();

userEvent (preferred over fireEvent)

const user = userEvent.setup();

await user.click(button);
await user.dblClick(button);
await user.hover(el);
await user.unhover(el);

await user.type(input, "hello");
await user.clear(input);
await user.keyboard("{Enter}");
await user.tab();
await user.upload(input, file);
await user.selectOptions(select, "option-value");

userEvent triggers full event sequences (focus, keydown, input, etc).

Async

// Find: re-tries until found
const el = await screen.findByText("loaded");

// waitFor: arbitrary predicate
await waitFor(() => {
  expect(spy).toHaveBeenCalled();
});

// waitForElementToBeRemoved
await waitForElementToBeRemoved(() => screen.queryByText("loading"));

Mocking modules

vi.mock("./api", () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "A" }),
}));

import { fetchUser } from "./api";

test("loads user", async () => {
  render(<UserCard id={1} />);
  expect(await screen.findByText("A")).toBeInTheDocument();
});

MSW (mock service worker)

npm i -D msw
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("/api/users/:id", () => HttpResponse.json({ id: 1, name: "A" })),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Intercept network at fetch level; tests run real components against fake API.

Custom render

// test/utils.tsx
function renderWithProviders(ui: React.ReactElement) {
  return render(
    <QueryClientProvider client={new QueryClient()}>
      <Router>{ui}</Router>
    </QueryClientProvider>
  );
}

export * from "@testing-library/react";
export { renderWithProviders as render };
import { render, screen } from "./utils";

Snapshot

expect(container).toMatchSnapshot();
expect(container).toMatchInlineSnapshot();

Use sparingly; brittle for UI.

Async hooks

import { renderHook } from "@testing-library/react";

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

act warnings

If you see not wrapped in act(...), you have an async state update outside await. Wrap with await or waitFor.

Common mistakes

  • Querying by class/id — brittle. Use roles/labels.
  • screen.getByText("Save") for a button — better getByRole("button", { name: "Save" }).
  • fireEvent.click — partial events. Use userEvent.
  • Not awaiting findBy — race conditions.
  • Testing implementation details — refactor breaks tests.

Read this next

If you want my RTL + MSW test harness, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .