Module 8: Tooling and Testing
Testing Components with Vitest and React Testing Library
Setting up Vitest, writing tests that behave like a user, handling async UI, mocking network calls, and testing hooks and reducers.
Tests for UI code have a bad reputation, mostly because people write them badly. They test implementation details, break on every refactor, and everyone learns to ignore them. Done well, a handful of tests lets you change code confidently and catches the stupid regression before your users do.
The guiding idea of React Testing Library (RTL): test your components the way a user uses them. Find things by what's visible on screen, click and type like a person, and assert on what shows up. Don't check state variables or internal function calls.
The tools#
- Vitest is the test runner. It uses your existing Vite config, so there's nothing extra to set up for JSX or imports, and its API mirrors Jest's.
- jsdom is a simulated browser DOM that runs in Node, so tests don't need a real browser.
- React Testing Library renders components into jsdom and gives you queries to find elements.
- user-event simulates real interactions: typing, clicking, tabbing.
- jest-dom adds readable assertions like
toBeInTheDocument().
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
How one test actually runs:
Vitest (in Node, reuses your Vite config for JSX)
│
▼
jsdom provides window and document
│ render(<Counter />) React Testing Library mounts into document.body
▼
screen.getByRole("button", ...) query the way a user finds things
│ await user.click(button) user-event fires the real sequence of DOM events
▼
expect(...).toBeInTheDocument() jest-dom assertion, fails the test if false
Configuration#
// vite.config.js
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true, // describe, it, expect without importing
setupFiles: "./src/test/setup.js",
},
});
// src/test/setup.js
import "@testing-library/jest-dom/vitest";
// package.json scripts
"test": "vitest",
"test:run": "vitest run"
npm test starts watch mode and re-runs affected tests when you save. With globals: true, RTL also cleans up the rendered DOM after each test automatically.
Your first test#
// src/components/Counter.jsx
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
// src/components/Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter.jsx";
describe("Counter", () => {
it("starts at zero", () => {
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
it("increments when the button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
});
});
Notice we never touched count or setCount. If you rewrite the component with useReducer, the tests still pass, which is exactly what you want.
Finding elements: the query priority#
RTL queries come in families: getBy... (throws if not found), queryBy... (returns null, use it to assert something is absent), and findBy... (async, waits for it to appear). Which attribute you query by matters.
Prefer, in this order:
getByRole: "button", "textbox", "heading", "link" with aname. It mirrors how assistive tech sees the page, so it also nudges you to write accessible markup.getByLabelText: for form fields with a label.getByPlaceholderText,getByText: okay when the above don't fit.getByTestId: the last resort, for things with no accessible handle.
screen.getByRole("heading", { name: /welcome/i });
screen.getByLabelText("Email");
screen.queryByText("Error") // null if absent
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
If getByRole can't find your button, it's often telling you something is wrong with your markup, like a clickable div.
Testing a form#
// LoginForm.jsx
export default function LoginForm({ onSubmit }) {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
function handleSubmit(e) {
e.preventDefault();
if (!email.includes("@")) return setError("Enter a valid email.");
setError("");
onSubmit(email);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
{error && <p role="alert">{error}</p>}
<button>Log in</button>
</form>
);
}
// LoginForm.test.jsx
it("shows an error for an invalid email and does not submit", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "not-an-email");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(screen.getByRole("alert")).toHaveTextContent("Enter a valid email.");
expect(handleSubmit).not.toHaveBeenCalled();
});
it("submits a valid email", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "asha@example.com");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(handleSubmit).toHaveBeenCalledWith("asha@example.com");
});
vi.fn() creates a mock function that records how it was called. It's right for checking that a callback prop fires, since that's part of the component's public behavior.
Async UI#
When something appears after a promise resolves, use findBy (it polls for a short time) or waitFor.
it("loads and shows users", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: "Asha" }],
})
);
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(await screen.findByText("Asha")).toBeInTheDocument();
});
it("shows an error when the request fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 }));
render(<UserList />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
Mocking fetch is fine for small cases. For anything bigger, the library MSW (Mock Service Worker) intercepts requests at the network level so your component code runs unchanged, and it's what many teams standardize on.
Remember to restore globals between tests with vi.unstubAllGlobals() in an afterEach, otherwise a mock from one test leaks into the next.
Components that need providers#
Components that use router, query client or context need those wrappers during tests. Make a helper:
// src/test/renderWithProviders.jsx
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
export function renderWithProviders(ui, { route = "/" } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, // fail fast in tests
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
</QueryClientProvider>
);
}
Create a new QueryClient per test, otherwise cached data bleeds between tests. And turn off retries, or an error test will wait through three retries before failing.
Testing hooks and reducers#
A reducer is a pure function. Test it with no React involved.
import { cartReducer } from "./cartReducer.js";
it("increments quantity when adding an existing item", () => {
const state = { items: [{ id: 1, qty: 1 }] };
const next = cartReducer(state, { type: "add", item: { id: 1 } });
expect(next.items[0].qty).toBe(2);
});
For custom hooks, RTL has renderHook:
import { act, renderHook } from "@testing-library/react";
it("toggles", () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => result.current[1]()); // updates must be wrapped in act
expect(result.current[0]).toBe(true);
});
act makes sure React finishes applying updates before your assertion. RTL's render and user-event already wrap things in act for you. You only need it directly when calling hook functions yourself.
What to test, what to skip#
Worth testing:
- the important user flows (log in, add to cart, submit a form)
- conditional UI (error states, empty states, permissions)
- pure logic: reducers, validators, formatters
- bugs you've fixed, so they stay fixed
Not worth testing:
- that
useStateworks (React already tests React) - exact markup structure and class names
- every tiny presentational component
- implementation details like "state is set to true"
A small set of tests on key behavior beats 100% coverage of trivia.
Where people get burned#
- Querying by class name or test id when a role would do.
- Forgetting
awaitonuser.click, so the assertion runs before the update. - Using
getByfor something that appears later (usefindBy). - Asserting something is absent with
getBy(it throws). UsequeryBy. - Not resetting mocks between tests, so tests pass or fail depending on order.
- Testing implementation, then breaking tests with every refactor.
- Sharing one
QueryClientacross tests.
Practice#
- Set up Vitest in a Vite project and write the two
Countertests. - Write tests for a login or signup form covering invalid input, valid input and the callback.
- Test a component that fetches data with a mocked
fetch, covering loading, success and error. - Write unit tests for the cart reducer from module 4, with no React involved.
- Break the component's markup on purpose (make the button a
div) and see which test notices.