REACT (https://www.kirupa.com/react/index.htm) BOOK (https://www.amazon.com/exec/obidos/ASIN/0134546318/kirupacom) Testing React Apps by kirupa (https://www.kirupa.com/me/index.htm) | filed under Learning React (https://www.kirupa.com/react/index.htm) 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. What We Are Using Testing in React involves a few separate pieces, and it helps to know what each one does before we install them: - Vitest is the test runner. It finds your test files, runs them, and reports what passed and what failed. It is built to work with Vite, so it reuses the same configuration and handles JSX without extra setup. If you have used Jest before, the functions are nearly identical. - jsdom pretends to be a browser. Your tests run in Node, which has no document or window. jsdom provides a fake version of them so that React has something to render into. - React Testing Library gives you render to put a component on that fake page, and screen to look for things in it. - user-event simulates what a person does, like clicking and typing, in a realistic way. - jest-dom adds readable checks like toBeInTheDocument() and toBeDisabled(). Despite the name, it works great with Vitest. 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. Setting Up We'll start with a regular Vite React project. If you don't have one, the steps are in setting up your React environment (https://www.kirupa.com/react/setting_up_react_environment.htm). 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 [

,

] 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. Your First Test Let's test a simple component. Here is a counter: import { useState } from "react"; function Counter({ initialCount = 0 }) { const [count, setCount] = useState(initialCount); return (

Count: {count}

); } 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(); expect(screen.getByText("Count: 0")).toBeInTheDocument(); }); test("starts at the initialCount it is given", () => { render(); expect(screen.getByText("Count: 10")).toBeInTheDocument(); }); }); There are four things going on in this file: - describe groups related tests under a name. It is optional, but it keeps the output tidy. - test (you can also write it) defines a single test. The first argument is a plain English description, and the second is the function that does the work. - render draws the component onto the fake page. - expect(...) followed by a check, like toBeInTheDocument(), is the assertion. If it isn't true, the test fails. The test is shaped like a story: set things up, do something, check the result. You'll see that pattern again and again. Simulating Clicks and Typing 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(); 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: 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.

Count: 4

... 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. Testing Forms 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 (https://www.kirupa.com/react/forms_in_react.htm). 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 (
setText(e.target.value)} /> {items.length === 0 ? (

Nothing to do. Suspicious.

) : (
    {items.map((item) => (
  • {item.text}
  • ))}
)}
); } 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(); 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(); 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(); 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(); 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(); 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: - getByLabelText("New task") finds the input through its