Testing with Vitest
Every time you change code, something else might break. Clicking through the whole app by hand after each change is slow and you will miss things. Automated tests are small programs that use your code and check the results, so a single command tells you whether everything still works.
In this lesson you will set up Vitest (a fast test runner built on Vite) with React Testing Library, test plain functions, render components and query them the way a user would, simulate clicks and typing, and test asynchronous UI and callback props.
Setting up Vitest in a Vite project
Install the test tools as development dependencies:
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-domvitestruns the tests and providestest,expectand mocks.jsdomsimulates a browser page inside Node.js, so components can render without a real browser.@testing-library/reactrenders components and finds elements in them;@testing-library/domis its required companion.@testing-library/user-eventsimulates real user actions such as typing and clicking.@testing-library/jest-domadds readable checks such astoBeInTheDocument()andtoBeDisabled().
Add a test section to vite.config.js and a setup file that runs before each test file:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/setupTests.js"],
},
});// src/setupTests.js
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => cleanup()); // remove rendered components after each testFinally add "test": "vitest" to the scripts in package.json. npm test now runs in watch mode: it re-runs the affected tests every time you save a file. npx vitest run runs everything once, which is what you use on a build server. Test files are found by name: anything ending in .test.js, .test.jsx, .spec.js and similar.
Testing a plain function
Start with the easiest case: a function with no React at all.
// src/gst.js
export function addGst(amount, rate = 18) {
return Math.round(amount * (1 + rate / 100));
}
// src/gst.test.js
import { test, expect } from "vitest";
import { addGst } from "./gst.js";
test("adds 18% GST by default", () => {
expect(addGst(1000)).toBe(1180);
});
test("uses a custom rate", () => {
expect(addGst(1000, 5)).toBe(1050);
});Each test has a name that describes the expected behaviour and a function that checks it with expect(actual).toBe(expected). When a check fails, Vitest shows the expected and received values and points at the line. If the second test expected 1005 by mistake, you would see:
FAIL src/gst.test.js > uses a custom rate AssertionError: expected 1050 to be 1005 // Object.is equality - Expected + Received - 1005 + 1050
The idea behind every test is simple: run code, compare the result with what you expect, and report differences. This runnable example builds a tiny test helper to show exactly that:
function expectEqual(name, actual, expected) {
const ok = Object.is(actual, expected);
console.log(`${ok ? "PASS" : "FAIL"} ${name}${ok ? "" : ` (expected ${expected}, got ${actual})`}`);
}
function addGst(amount, rate = 18) {
return Math.round(amount * (1 + rate / 100));
}
function formatRupees(n) {
return `₹${n.toLocaleString("en-IN")}`;
}
expectEqual("adds 18% GST by default", addGst(1000), 1180);
expectEqual("uses a custom rate", addGst(1000, 5), 1050);
expectEqual("formats lakhs Indian style", formatRupees(250000), "₹2,50,000");
expectEqual("rounds to whole rupees", addGst(99), 117);PASS adds 18% GST by default PASS uses a custom rate PASS formats lakhs Indian style PASS rounds to whole rupees
Change one expected value and run it again to see a failure. Vitest gives you the same idea with much better reporting, watch mode and many more checks.
Testing a component like a user
React Testing Library follows one guiding idea: test what the user sees and does, not the implementation details. You render a component, find elements by their role or visible text, interact with them, and check the result. You never read state variables directly.
// src/ChaiCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import ChaiCounter from "./ChaiCounter.jsx";
describe("ChaiCounter", () => {
it("starts at zero with Reset disabled", () => {
render(<ChaiCounter />);
expect(screen.getByText("Cups sold: 0")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled();
});
it("adds cups and updates the bill when clicked", async () => {
const user = userEvent.setup();
render(<ChaiCounter price={20} />);
await user.click(screen.getByRole("button", { name: "Sell one" }));
await user.click(screen.getByRole("button", { name: "Sell one" }));
expect(screen.getByText("Cups sold: 2")).toBeInTheDocument();
expect(screen.getByText("Bill: ₹40")).toBeInTheDocument();
});
});render draws the component into the simulated page, and screen searches that page. describe groups related tests, and it is another name for test. Prefer queries in this order, because it matches how people (including screen-reader users) find things:
getByRole("button", { name: "Save" }): buttons, links, headings, checkboxes and so on.getByLabelText("Email"): form fields by their label.getByText("Cups sold: 0"): visible text.getByTestId("chart"): a last resort using adata-testidattribute.
Each query comes in three forms. getBy... throws if nothing matches, queryBy... returns null (use it to check that something is absent), and findBy... returns a promise that waits for the element to appear. Plural versions such as getAllByRole return arrays.
Async UI and callback props
Real components load data and call functions passed as props. Use mock functions from vi.fn() to stand in for those functions: they record every call and can return whatever the test needs.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { it, expect, vi } from "vitest";
import { SearchBox, BranchList } from "./Branches.jsx";
it("calls onSearch with the typed city", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchBox onSearch={onSearch} />);
await user.type(screen.getByLabelText("City"), " Kochi ");
await user.click(screen.getByRole("button", { name: "Search" }));
expect(onSearch).toHaveBeenCalledWith("Kochi");
});
it("shows branches after they load", async () => {
const loadBranches = vi.fn().mockResolvedValue(["Jaipur", "Pune"]);
render(<BranchList loadBranches={loadBranches} />);
expect(screen.getByText("Loading branches...")).toBeInTheDocument();
expect(await screen.findByText("Pune")).toBeInTheDocument();
expect(screen.queryByText("Loading branches...")).not.toBeInTheDocument();
});mockResolvedValue makes the fake function return a promise that resolves with the given data, so the test never touches a real server. await screen.findByText("Pune") waits until the list appears. Running all the tests in this lesson prints a summary like:
Test Files 3 passed (3)
Tests 6 passed (6)Common mistakes
- Testing implementation details. Checking state values or class names makes tests break on harmless refactors. Check what the user sees.
- Forgetting
awaitwithuserEventandfindBy. Both are asynchronous; withoutawaitthe check runs too early. - No cleanup between tests. Without
cleanup(), components from earlier tests stay on the page and queries find duplicates. - Using
getByto check absence. It throws; usequeryBy...withnot.toBeInTheDocument(). - Calling real APIs in tests. Pass mock functions or mock
fetchso tests are fast and reliable.
Key points
- Vitest runs tests; jsdom simulates the browser; React Testing Library renders and queries components.
- Configure
test.environment = "jsdom"and a setup file with jest-dom andcleanup. - Test behaviour the user sees: query by role, label and text, and simulate actions with
userEvent. getBythrows,queryByreturnsnull,findBywaits for async content.vi.fn()creates mock functions that record calls and can return fake data.- Pure functions and reducers are the easiest code to test, so keep logic in them.
Exercise
Write tests for a LoginForm component that has an email input labelled "Email", a password input labelled "Password" and a "Log in" button, and calls an onLogin({ email, password }) prop on submit. Write one test that types both fields, clicks the button and checks the callback's arguments, and one test that checks the button is disabled while the email is empty.
Show solution
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import LoginForm from "./LoginForm.jsx";
describe("LoginForm", () => {
it("sends the email and password", async () => {
const user = userEvent.setup();
const onLogin = vi.fn();
render(<LoginForm onLogin={onLogin} />);
await user.type(screen.getByLabelText("Email"), "meera@example.com");
await user.type(screen.getByLabelText("Password"), "chai@123");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(onLogin).toHaveBeenCalledWith({ email: "meera@example.com", password: "chai@123" });
});
it("disables the button while the email is empty", () => {
render(<LoginForm onLogin={vi.fn()} />);
expect(screen.getByRole("button", { name: "Log in" })).toBeDisabled();
});
});The tests only use labels, roles and the callback, so they keep passing even if you restyle the form or rename its state variables.
Quick Quiz
1. What does jsdom provide in a Vitest setup?
jsdom implements the DOM in JavaScript, which lets React render during tests.
2. Which query should you prefer to find a "Save" button?
Role queries match how users and assistive technology find elements.
3. How do you check that a loading message has disappeared?
queryBy returns null instead of throwing, so it is the right tool for absence checks.
4. What is vi.fn() used for?
Mock functions stand in for callbacks and API functions so you can check how they were used.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
