React course menu
Testing React Components with Vitest and Testing Library
Test React components the way users use them: setting up Vitest, jsdom and React Testing Library in a Vite project, rendering components, finding elements by role and label, simulating clicks and typing with user-event, mocking functions and fetch, async tests, and reading failures.
On this page
Automated tests check that your components keep working as you change code. The standard tools for a Vite React project are:
- Vitest: the test runner (finds test files, runs them, reports results);
- React Testing Library: renders components and finds elements the way a user would, by their visible text, labels and roles;
- user-event: simulates real user actions such as clicking and typing;
- jsdom: a simulated browser DOM so tests run in Node without opening a browser.
Setup#
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-domTell Vitest to use jsdom, enable its global test/expect functions, and load a setup file:
// vite.config.js
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js',
},
})// src/setupTests.js
import "@testing-library/jest-dom/vitest";The setup file adds DOM matchers such as toBeInTheDocument() and toBeDisabled(). globals: true also lets Testing Library clean up (unmount) rendered components after each test automatically; without it, components from earlier tests stay in the document and later tests find the wrong elements.
Add a script to package.json:
"scripts": {
"test": "vitest"
}npm test runs in watch mode, re-running tests when files change; npx vitest run runs them once (as a CI server would).
Your first test#
The component:
// src/Counter.jsx
import { useState } from "react";
export default function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)} disabled={count === 0}>
Reset
</button>
</div>
);
}The tests, in a file ending in .test.jsx:
// src/Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter.jsx";
test("starts at the initial value", () => {
render(<Counter initial={5} />);
expect(screen.getByText("Count: 5")).toBeInTheDocument();
});
test("increments and resets", async () => {
const user = userEvent.setup();
render(<Counter />);
const reset = screen.getByRole("button", { name: "Reset" });
expect(reset).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
expect(reset).toBeEnabled();
await user.click(reset);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});Every test follows the same shape: render the component, act like a user, assert what the user would see.
Finding elements#
Prefer queries that match how users (and assistive technology) find things, in this order:
getByRole("button", { name: "Save" }): elements by their accessible role and name. The most robust choice, and it nudges you towards accessible markup.getByLabelText("Email"): form fields by their label.getByText("Count: 5"): visible text.getByTestId("..."): adata-testidattribute, only when nothing else works.
Query variants:
getAllBy..., queryAllBy... and findAllBy... return arrays.
Forms and mock functions#
vi.fn() creates a mock function that records how it was called, perfect for checking callback props:
// src/SignupForm.jsx
import { useState } from "react";
export default function SignupForm({ onSubmit }) {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
function handleSubmit(e) {
e.preventDefault();
if (!email.includes("@")) {
setError("Enter a valid email");
return;
}
setError("");
onSubmit(email);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Sign up</button>
{error && <p role="alert">{error}</p>}
</form>
);
}// src/SignupForm.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import SignupForm from "./SignupForm.jsx";
test("shows an error for an invalid email", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<SignupForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "not-an-email");
await user.click(screen.getByRole("button", { name: "Sign up" }));
expect(screen.getByRole("alert")).toHaveTextContent("Enter a valid email");
expect(handleSubmit).not.toHaveBeenCalled();
});
test("submits a valid email", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<SignupForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "asha@example.com");
await user.click(screen.getByRole("button", { name: "Sign up" }));
expect(handleSubmit).toHaveBeenCalledWith("asha@example.com");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});user.type types character by character, firing the same events a real keyboard would.
Async code and mocking fetch#
Tests should not call real servers. Replace fetch with a mock that returns the response you want, and use findBy... to wait for the result:
// src/UserList.jsx
import { useEffect, useState } from "react";
export default function UserList() {
const [users, setUsers] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetch("/api/users")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setUsers)
.catch((err) => setError(err.message));
}, []);
if (error) return <p role="alert">Could not load users: {error}</p>;
if (!users) return <p>Loading…</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}// src/UserList.test.jsx
import { render, screen } from "@testing-library/react";
import UserList from "./UserList.jsx";
afterEach(() => {
vi.unstubAllGlobals();
});
test("shows users after loading", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: "Asha" }, { id: 2, name: "Ravi" }],
}));
render(<UserList />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
const items = await screen.findAllByRole("listitem");
expect(items.map((li) => li.textContent)).toEqual(["Asha", "Ravi"]);
});
test("shows an error when the request fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 }));
render(<UserList />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load users: HTTP 500");
});vi.stubGlobal swaps the global fetch for the test, and vi.unstubAllGlobals() restores it afterwards. For larger apps, Mock Service Worker (MSW) intercepts requests at the network level, so components use the real fetch.
Running the tests#
npx vitest run --reporter=verbose ✓ src/testing/UserList.test.jsx > shows users after loading
✓ src/testing/UserList.test.jsx > shows an error when the request fails
✓ src/testing/Counter.test.jsx > starts at the initial value
✓ src/testing/Counter.test.jsx > increments and resets
✓ src/testing/SignupForm.test.jsx > shows an error for an invalid email
✓ src/testing/SignupForm.test.jsx > submits a valid email
Test Files 3 passed (3)
Tests 6 passed (6)(The files were in a src/testing/ folder in the project these were run in; durations are omitted.)
Reading a failure#
When an assertion fails, Testing Library prints the current DOM, which usually shows the problem straight away. Changing the counter test to expect "Count: 3" after two clicks gives:
FAIL src/testing/Counter.test.jsx > increments and resets
TestingLibraryElementError: Unable to find an element with the text: Count: 3. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Ignored nodes: comments, script, style
<body>
<div>
<div>
<p>
Count:
2
</p>
<button>
Increment
</button>
<button>
Reset
</button>
</div>
</div>
</body>The printed DOM shows the count is 2: the test, not the component, was wrong.
What to test#
- Behaviour users can see: text, enabled/disabled buttons, error messages, what happens after clicks and typing.
- Callback props are called with the right values.
- Loading, success and error states.
Avoid testing implementation details (state variable names, which hooks are used, CSS classes), so you can refactor without rewriting tests. For full journeys across pages in a real browser, add a few end-to-end tests with a tool such as Playwright.
Common mistakes#
Key takeaway#
Next: React Frameworks and Next.js.
Finished this lesson?