Testing React components used to be a contentious topic — too much ceremony, too many debates about shallow vs deep rendering, too many brittle snapshots. In 2026 the community has largely converged: React Testing Library with Vitest, mock the network with MSW, and test what users actually do. The philosophy is simple and the tooling is fast.
What changed in 2026
- Vitest replaced Jest as the default for most new React projects — it shares Jest's API, runs in the same process as Vite, and is 3–5× faster on cold starts.
- React 19 async transitions mean more async state updates in components; Testing Library's
waitFor and findBy* queries handle this correctly.
- Server Components need different testing. RSC (React Server Components) are best tested as integration or E2E; RTL covers client components.
- MSW v2 dropped the
rest API in favour of http handlers and is now used in both browser and Node test environments seamlessly.
Setup: Vitest + React Testing Library
npm install -D vitest @vitest/ui jsdom \
@testing-library/react @testing-library/user-event \
@testing-library/jest-dom msw
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/test/setup.ts",
},
});
src/test/setup.ts:
import "@testing-library/jest-dom";
Your first component test
// Button.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";
test("calls onClick when clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Save</Button>);
await user.click(screen.getByRole("button", { name: /save/i }));
expect(handleClick).toHaveBeenCalledOnce();
});
Key points: query by role (getByRole), simulate events with userEvent (not fireEvent), use vi.fn() for mocks.
Querying priorities
| Priority |
Query |
When to use |
| 1st |
getByRole |
Buttons, inputs, headings — accessible |
| 2nd |
getByLabelText |
Form fields |
| 3rd |
getByPlaceholderText |
Inputs without label |
| 4th |
getByText |
Non-interactive text |
| Last |
getByTestId |
Only when nothing else works |
Prefer queries that would work for a screen-reader user. If you need getByTestId everywhere, accessibility is likely broken.
Mocking network calls with MSW
// src/test/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/user/:id", ({ params }) => {
return HttpResponse.json({ id: params.id, name: "Alice" });
}),
];
// src/test/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// setup.ts — add:
import { server } from "./server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Now in your test you can override a handler for a specific scenario:
import { server } from "../test/server";
import { http, HttpResponse } from "msw";
test("shows error on 500", async () => {
server.use(
http.get("/api/user/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserProfile id="1" />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
How to pick what to test
| Test target |
Worth testing? |
Why |
| User interactions (click, type, submit) |
Yes |
Core behaviour |
| Loading and error states |
Yes |
Often missed edge cases |
| Conditional rendering |
Yes |
Business logic |
| CSS class names |
No |
Implementation detail |
| Internal state values |
No |
Use observable output |
| Snapshot of entire tree |
No |
Too brittle |
Common mistakes
Using fireEvent instead of userEvent. fireEvent dispatches a single DOM event. userEvent simulates the full browser interaction (focus, keydown, keyup, click) and catches bugs that fireEvent misses.
Querying by test ID first. It hides accessibility problems. Use it as a last resort.
Not await-ing async queries. screen.getByText throws immediately if the element is not there. Use screen.findByText (returns a promise) for elements that appear after async work.
Mocking the entire module. vi.mock("../../api") mocks too much. Use MSW to mock the network; only mock at module boundaries you own.
What to skip
- Enzyme — it is not maintained for React 18+ and encourages implementation-detail testing.
- Manual
act() wrapping — modern RTL handles it internally; if you need it explicitly, something is off.
- 100 % coverage as a goal — coverage measures lines executed, not correctness. A well-tested critical path beats 100 % coverage of trivial getters.
FAQ
Should I use Playwright instead of Testing Library?
They are complementary. Playwright is for end-to-end browser flows; Testing Library is for component unit and integration tests. Use both.
How do I test custom hooks?
Use renderHook from @testing-library/react. It wraps the hook in a minimal component and gives you result.current to inspect.
Do I need to test React Server Components?
Not with RTL. RSC render on the server; test them with integration tests (Playwright or Next.js createRequest/createResponse utilities) or treat them as thin data-passing shells that delegate logic to tested utilities.
What is the difference between getBy, queryBy, and findBy?
getBy throws if not found (synchronous). queryBy returns null if not found (synchronous). findBy is async and waits for the element to appear — use it after user interactions that trigger state updates.
Where to go next