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 — bettergetByRole("button", { name: "Save" }).fireEvent.click— partial events. UseuserEvent.- 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 .