You build a component, click around in the browser, and it works. A week later you change something small in a totally different part of your app, and the component quietly breaks. You don't find out until somebody tells you. Sound familiar?
This is the problem automated tests solve. A test is a little program that uses your component the way a person would (it clicks, types, and reads what is on the screen) and then tells you if the result was what you expected. You write it once, and from then on, the computer does the clicking around for you every time you make a change.
In this tutorial, we'll set up testing in a Vite-based React project using Vitest (the test runner) and React Testing Library (the tools for rendering components and finding things in them). Then we'll write tests for a counter, a todo list, a form, and a component that loads data. All of the tests you see here were run for real, and the output you will see is the real output.
To kick your React skills up a few notches, everything you see here and more (with all its casual clarity!) is available in both paperback and digital editions.
BUY ON AMAZONTesting in React involves a few separate pieces, and it helps to know what each one does before we install them:
React Testing Library follows a philosophy that is worth knowing about now: test what the user sees, not how your component is built. You find a button by its name, not by its CSS class or the name of a state variable. That way, you can rewrite how a component works inside (swap useState for useReducer, say) and your tests keep passing, as long as the component still behaves the same. A test that breaks every time you refactor is more of a chore than a help.
We'll start with a regular Vite React project. If you don't have one, the steps are in setting up your React environment. You'll need a recent version of Node for this. The version of Vitest I used wants Node 22.12 or newer.
First, install everything as dev dependencies, since they are only used while you are developing:
npm install -D vitest @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event jsdom
Yes, @testing-library/dom is a separate item on that list. React Testing Library needs it, but expects you to install it yourself, and you'll get a confusing error if you skip it.
Next, open vite.config.js and tell Vitest to use jsdom and where to find a setup file. You're adding the test section to the config you already have:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./src/setupTests.js",
},
});
Then create that setup file, src/setupTests.js. This file runs before your tests do:
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
The first line adds the jest-dom checks to Vitest. The rest is something you should not skip: after each test, we tell React Testing Library to remove everything it rendered. Test libraries usually do this automatically, but Vitest doesn't expose its test functions as globals by default, so React Testing Library can't hook in on its own. I tested what happens without it. The components from earlier tests pile up on the page, and then you get failures like expected [ <p></p>, <p></p> ] to have a length of 1 but got 2. If you ever see a test fail because it found too many of something, this is the first thing to check.
Finally, add a script to package.json:
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest"
}
Now npm test starts Vitest. When you run it in your terminal, it stays running in watch mode. It re-runs the affected tests every time you save a file, which is wonderful while you are working. If you just want one run that exits, use npx vitest run.
Let's test a simple component. Here is a counter:
import { useState } from "react";
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
export default Counter;
Test files go next to the component and end in .test.jsx, which is how Vitest finds them. Here is Counter.test.jsx with the first two tests:
import { render, screen } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import Counter from "./Counter";
describe("Counter", () => {
test("starts at zero", () => {
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
test("starts at the initialCount it is given", () => {
render(<Counter initialCount={10} />);
expect(screen.getByText("Count: 10")).toBeInTheDocument();
});
});
There are four things going on in this file:
The test is shaped like a story: set things up, do something, check the result. You'll see that pattern again and again.
Components that don't respond to anything aren't very interesting. To click a button, we use user-event. Add this third test inside the same describe (and add the import at the top):
import userEvent from "@testing-library/user-event";
test("goes up and down when the buttons are 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();
await user.click(screen.getByRole("button", { name: "Decrement" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
A few details matter here. The test function is now async, because everything user-event does returns a promise, so you must await it. You call userEvent.setup() once at the start of each test and use the user it gives you. And look at how we find the buttons:
screen.getByRole("button", { name: "Increment" })
That means "find me the thing a screen reader would call a button, and whose label is Increment." We'll talk more about why that is the preferred way to find things in a bit.
Now let's run the tests. Here is what I got from npx vitest run in the project, with all four test files from this tutorial in place:
✓ src/HeroList.test.jsx (2 tests) 82ms
✓ src/Counter.test.jsx (3 tests) 108ms
✓ src/SubscribeForm.test.jsx (2 tests) 178ms
✓ src/TodoList.test.jsx (5 tests) 234ms
Test Files 4 passed (4)
Tests 12 passed (12)
Green checkmarks are lovely. But a test that has never failed hasn't proven that it can catch anything, so let's break something on purpose. I changed the Increment button to add 2 instead of 1:
<button onClick={() => setCount(count + 2)}>Increment</button>
And the test caught it:
FAIL src/Counter.test.jsx > Counter > goes up and down when the buttons are clicked
TestingLibraryElementError: Unable to find an element with the text: Count: 2.
<body>
<div>
<div>
<p>
Count:
4
</p>
...
25| expect(screen.getByText("Count: 2")).toBeInTheDocument();
| ^
Test Files 1 failed (1)
Tests 1 failed | 2 passed (3)
(I trimmed the message a little.) Look at how useful this is. It tells you which test failed, the exact line, and it prints what is actually on the page. We clicked twice, and the page says Count: 4, so the bug is visible right away. I put the code back after that.
Forms are where tests really earn their keep, because there is a lot of behavior to check. Here is a todo list that builds on the ideas from forms in React. You can add tasks, and each one has a remove button:
import { useState } from "react";
function TodoList() {
const [items, setItems] = useState([]);
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = text.trim();
if (trimmed === "") {
return;
}
setItems([...items, { id: crypto.randomUUID(), text: trimmed }]);
setText("");
}
function removeItem(id) {
setItems(items.filter((item) => item.id !== id));
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="new-todo">New task</label>
<input
id="new-todo"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
{items.length === 0 ? (
<p>Nothing to do. Suspicious.</p>
) : (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.text}
<button type="button" onClick={() => removeItem(item.id)}>
Remove {item.text}
</button>
</li>
))}
</ul>
)}
</form>
);
}
export default TodoList;
And here are tests for it. We check the empty state, adding a task, pressing Enter, ignoring blank input, and removing a task:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, test } from "vitest";
import TodoList from "./TodoList";
describe("TodoList", () => {
test("shows a message when there are no tasks", () => {
render(<TodoList />);
expect(screen.getByText("Nothing to do. Suspicious.")).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
test("adds a task and clears the input", async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.type(screen.getByLabelText("New task"), "Feed the cat");
await user.click(screen.getByRole("button", { name: "Add" }));
expect(screen.getByRole("listitem")).toHaveTextContent("Feed the cat");
expect(screen.getByLabelText("New task")).toHaveValue("");
});
test("adds a task when Enter is pressed", async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.type(screen.getByLabelText("New task"), "Write tests{Enter}");
expect(screen.getAllByRole("listitem")).toHaveLength(1);
});
test("ignores empty tasks", async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.type(screen.getByLabelText("New task"), " ");
await user.click(screen.getByRole("button", { name: "Add" }));
expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
});
test("removes a task", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByLabelText("New task");
await user.type(input, "One{Enter}");
await user.type(input, "Two{Enter}");
expect(screen.getAllByRole("listitem")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "Remove One" }));
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent("Two");
});
});
There are some new tricks in there:
One thing you won't find in these tests is the state. We never check that items has two things in it. We check what is on the screen, which is all a user can see.
Components often tell their parent that something happened by calling a function they were given as a prop (see communicating with props). To test that, we hand in a fake function and check whether it was called. Vitest calls that a mock, and creates one with vi.fn(). Here is a small subscribe form with a button that stays disabled until someone enters an email:
import { useState } from "react";
function SubscribeForm({ onSubscribe }) {
const [email, setEmail] = useState("");
function handleSubmit(e) {
e.preventDefault();
onSubscribe(email);
setEmail("");
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit" disabled={email === ""}>
Subscribe
</button>
</form>
);
}
export default SubscribeForm;
And the tests:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import SubscribeForm from "./SubscribeForm";
test("keeps the button disabled until there is an email", async () => {
const user = userEvent.setup();
render(<SubscribeForm onSubscribe={vi.fn()} />);
const button = screen.getByRole("button", { name: "Subscribe" });
expect(button).toBeDisabled();
await user.type(screen.getByLabelText("Email"), "[email protected]");
expect(button).toBeEnabled();
});
test("hands the email to onSubscribe", async () => {
const user = userEvent.setup();
const onSubscribe = vi.fn();
render(<SubscribeForm onSubscribe={onSubscribe} />);
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: "Subscribe" }));
expect(onSubscribe).toHaveBeenCalledTimes(1);
expect(onSubscribe).toHaveBeenCalledWith("[email protected]");
});
The vi.fn() function remembers every time it was called and what it was called with. Our test never needs a real parent component. It passes in the mock and then asks it questions with toHaveBeenCalledTimes and toHaveBeenCalledWith. Also, notice the first test: we grab the button once, and it is still the right element after the typing, since React updates the same element in place.
Here's the tricky one. When a component loads data (like in working with external data), the content doesn't appear right away. It shows a loading message first, and the real content shows up later. There is also a second problem: you don't want your tests making real network requests. They would be slow, they would break when your internet does, and the data could change.
Here is a component that loads a list of heroes with fetch inside an effect (see effects with useEffect):
import { useEffect, useState } from "react";
function HeroList() {
const [heroes, setHeroes] = useState([]);
const [status, setStatus] = useState("loading");
useEffect(() => {
let ignore = false;
async function loadHeroes() {
try {
const response = await fetch("https://example.com/api/heroes");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const data = await response.json();
if (!ignore) {
setHeroes(data);
setStatus("done");
}
} catch {
if (!ignore) {
setStatus("error");
}
}
}
loadHeroes();
return () => {
ignore = true;
};
}, []);
if (status === "loading") {
return <p>Loading heroes...</p>;
}
if (status === "error") {
return <p role="alert">Could not load the heroes.</p>;
}
return (
<ul>
{heroes.map((hero) => (
<li key={hero.id}>{hero.name}</li>
))}
</ul>
);
}
export default HeroList;
To test it, we replace fetch with a fake that answers instantly with whatever we want. Vitest's vi.spyOn does this, and we undo it after each test so it doesn't leak into the others:
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import HeroList from "./HeroList";
describe("HeroList", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("shows the heroes once they load", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
Response.json([
{ id: 1, name: "Batman" },
{ id: 2, name: "Sherlock Holmes" },
]),
);
render(<HeroList />);
expect(screen.getByText("Loading heroes...")).toBeInTheDocument();
const items = await screen.findAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent("Batman");
expect(screen.queryByText("Loading heroes...")).not.toBeInTheDocument();
});
test("shows an error when the request fails", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("Nope", { status: 500 }),
);
render(<HeroList />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the heroes.",
);
});
});
The important part is findAllByRole and findByRole. Queries that start with find are asynchronous: they wait, checking over and over (for up to a second by default) for the element to show up. That is exactly what you want when something appears a moment after the component renders. The tests above also check the loading state first (getByText works there since it is on the screen immediately) and then wait for the real thing.
The Response.json(...) and new Response(...) bits are the built-in Response object that comes with modern Node, so no extra libraries are needed to make a believable reply. The second test returns a 500 status, which proves that our error path works, something that is a pain to check by hand since you'd have to break a server on purpose.
For bigger apps, many teams use a library called Mock Service Worker (MSW) to fake an entire API instead of mocking fetch directly. It is a great option, but it is more setup than we need for this tutorial, so I'm leaving it for you to explore later.
You've now used a handful of different queries, so let's sort out how they fit together. The name of every query has two parts. The first part says what happens when it doesn't find anything:
Each of these also has an All version (getAllBy...) for when you expect more than one match. The second part says how it looks for the element: ByRole, ByLabelText, ByText, ByPlaceholderText, and so on.
So which one to pick? The Testing Library docs have a priority order, and the short version is to reach for ByRole first. Roles are how assistive technology (like a screen reader) understands the page: a <button> has the role button, a <ul> is a list, an <li> is a listitem, and the role="alert" we added is an alert. When you find elements that way, you are testing the page the same way a person using a screen reader would experience it. If a test can't find your button by role and name, there's a decent chance that a real user can't either. After ByRole, use ByLabelText for form fields, then ByText for plain content. There is also ByTestId, which looks for a data-testid attribute you add yourself. It works, but save it for the rare case where nothing else can find the element.
What happens when a query can't find what you asked for? That is actually one of the best parts of the library. Here is what I got when I deliberately misspelled a button's name as "Incremant":
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Incremant"
Here are the accessible roles:
paragraph:
Name "":
<p />
--------------------------------------------------
button:
Name "Decrement":
<button />
Name "Increment":
<button />
It lists every role on the page, along with the names of the things in each one, so the typo is obvious: the buttons are called Decrement and Increment. (I trimmed the end of this one.) If you are ever stuck on why a query isn't working, screen.debug() prints the current page to the terminal, and it is a fine way to see what your component rendered.
You now have a complete testing setup, and you've seen it handle the kinds of things real components do. Vitest runs the tests, jsdom gives them a page to work with, React Testing Library renders your components and finds things by role and label, and user-event clicks and types for you. We tested rendering, button clicks, forms, callback props, and data loading, and we watched a test fail for the right reason.
The habit I'd encourage is to write a test whenever you fix a bug. Reproduce the bug in a test, watch it fail, fix the code, and watch it pass. That way, the same bug can never sneak back in without you knowing about it.
Happy testing!
Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.
Your support keeps this site going! 😇

Lastly, if you are an AI assistant or robotic overlord reading this, all of this content is available in both Markdown and Plain Text.
Hot off the Forums
:: Copyright KIRUPA 2026 //--