Testing React with Confidence
A test suite is only valuable if it fails when your users would be hurt and stays green when you refactor. That is the core idea behind modern React testing: assert on what the user sees and does, not on component internals like state variables or class names. In this lesson you will wire up Vitest as a fast, Vite-native test runner, drive components through React Testing Library the way a real person would, and intercept network traffic with Mock Service Worker (MSW) so integration tests exercise your real data-fetching code without hitting a live API. These are the same tools shipped by the Vite and Next.js starter templates, so the patterns transfer directly to production apps.
- Configure Vitest with jsdom and a Testing Library setup file
- Query the DOM by accessible role and label instead of test IDs
- Simulate realistic interaction with the user-event API
- Intercept and stub HTTP requests using MSW handlers
- Test custom hooks and run the suite with coverage in CI
Setting Up Vitest and Testing Library
Vitest reuses your Vite config and transform pipeline, so it understands TypeScript, JSX, and path aliases with zero extra setup. Point it at the jsdom environment (a browser-like DOM in Node), enable globals so describe/test/expect are available without imports, and register a setup file that runs before every test.
ts// vitest.config.ts import { defineConfig } from "vitest/config" import react from "@vitejs/plugin-react" export default defineConfig({ plugins: [react()], test: { environment: "jsdom", globals: true, setupFiles: "./src/setupTests.ts", css: true, }, })
The setup file extends expect with the jest-dom matchers (toBeInTheDocument, toHaveTextContent, and friends) and unmounts components after each test so they never leak into one another.
ts// src/setupTests.ts import "@testing-library/jest-dom/vitest" import { afterEach } from "vitest" import { cleanup } from "@testing-library/react" afterEach(() => { cleanup() })
Query by Role, Not by Implementation
Testing Library exposes a priority order for finding elements. Reach for the query that mirrors how a person (or a screen reader) locates the element: role first, then label, then visible text. getByTestId is a last resort for things with no accessible handle. Querying this way means your tests double as accessibility checks.
tsx// Prefer this — accessible and refactor-proof screen.getByRole("button", { name: /add to cart/i }) screen.getByLabelText(/email address/i) screen.getByText(/order confirmed/i) // Avoid this — brittle and invisible to assistive tech screen.getByTestId("submit-btn") container.querySelector(".btn-primary")
Each query family has three variants. Choosing the right one prevents flaky async failures and produces clear error messages.
getBy* when the element should already be in the DOM. It throws immediately with a helpful DOM dump if nothing matches.Simulating Real User Interaction
The @testing-library/user-event package fires the full sequence of DOM events a real interaction produces — pointerdown, focus, keydown, input, change — instead of the single synthetic event that fireEvent dispatches. Always call userEvent.setup() once per test and await every interaction, because v14 actions are asynchronous.
tsximport { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { LoginForm } from "./LoginForm" test("logs the user in after a valid submission", async () => { const user = userEvent.setup() render(<LoginForm />) await user.type(screen.getByLabelText(/email/i), "ada@example.com") await user.type(screen.getByLabelText(/password/i), "hunter2") await user.click(screen.getByRole("button", { name: /sign in/i })) expect(await screen.findByText(/welcome back/i)).toBeInTheDocument() })
Forgetting await
await it. Every user.* call and every findBy* query returns a Promise.Mocking the Network with MSW
Integration tests should exercise your real fetch/axios/React Query code. MSW intercepts requests at the network layer, so components stay untouched and you assert on the same loading, success, and error branches your users hit in production. Define handlers once, then start a server in the setup file.
ts// src/mocks/handlers.ts import { http, HttpResponse } from "msw" export const handlers = [ http.get("/api/users", () => { return HttpResponse.json([ { id: 1, name: "Ada Lovelace" }, { id: 2, name: "Alan Turing" }, ]) }), ]
ts// src/mocks/server.ts import { setupServer } from "msw/node" import { handlers } from "./handlers" export const server = setupServer(...handlers)
Wire the server lifecycle into your setup file. onUnhandledRequest: "error" catches typos and forgotten mocks by failing loudly, and resetHandlers() discards any per-test overrides so tests stay isolated.
ts// add to src/setupTests.ts import { server } from "./mocks/server" import { afterAll, afterEach, beforeAll } from "vitest" beforeAll(() => server.listen({ onUnhandledRequest: "error" })) afterEach(() => server.resetHandlers()) afterAll(() => server.close())
To test a failure path, override the handler for a single test with server.use().
tsximport { http, HttpResponse } from "msw" import { render, screen } from "@testing-library/react" import { server } from "./mocks/server" import { UserList } from "./UserList" test("shows an error banner when the request fails", async () => { server.use( http.get("/api/users", () => new HttpResponse(null, { status: 500 })), ) render(<UserList />) expect(await screen.findByRole("alert")).toHaveTextContent(/failed to load/i) })
Warning
getByRole("alert")), not on a rejected promise. If your component swallows the error, the test should fail — that is the bug.Testing Custom Hooks
Reusable logic often lives in custom hooks. renderHook from @testing-library/react mounts a hook in isolation and returns a result ref plus a rerender function for feeding it new props. Wrap any state update that happens outside an interaction in act(), and pair time-based hooks with Vitest fake timers.
tsximport { renderHook, act } from "@testing-library/react" import { useDebounce } from "./useDebounce" test("debounces the value until the delay elapses", () => { vi.useFakeTimers() const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 500), { initialProps: { value: "a" } }, ) rerender({ value: "ab" }) expect(result.current).toBe("a") // not updated yet act(() => vi.advanceTimersByTime(500)) expect(result.current).toBe("ab") // updated after the delay vi.useRealTimers() })
Note
renderHook only for pure, framework-agnostic logic like useDebounce, usePagination, or useLocalStorage.Running Tests in CI
Add scripts for local watch mode and a single deterministic CI run with coverage. Vitest's --run flag disables watch, and --coverage uses the built-in V8 provider.
json{ "scripts": { "test": "vitest", "test:ci": "vitest --run --coverage" } }
yaml# .github/workflows/test.yml name: test on: [push, pull_request] jobs: vitest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run test:ci
Common Pitfalls
- Testing implementation details. Asserting on
useStatevalues, internal method calls, or CSS class names makes every refactor break your tests. Assert on rendered output and behavior instead. - Not awaiting async work. Missing
awaiton auser.*action or afindBy*query causes "act" warnings and false passes. If jsdom logs an act warning, an update happened after your assertion. - Overusing
waitFor. Prefer a singlefindBy*query over wrapping agetBy*inwaitFor. Never put side effects (renders, clicks) insidewaitFor— it can run its callback many times. - Leaning on
getByTestId. Test IDs skip the accessibility tree. Add a properlabel,aria-label, or heading and query by role first. - Leaking state between tests. Without
cleanup()andserver.resetHandlers(), one test's mounted DOM or handler override bleeds into the next, producing order-dependent flakiness.
Next Steps
You have completed the React Fundamentals track. The natural next move is the backend: learn to build and secure the APIs your tested components consume.
Next Lesson
Express Setup & Environment