Testing with Vitest

React · Lesson 25 of 29 7 min read Updated

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:

Terminal
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
  • vitest runs the tests and provides test, expect and mocks.
  • jsdom simulates a browser page inside Node.js, so components can render without a real browser.
  • @testing-library/react renders components and finds elements in them; @testing-library/dom is its required companion.
  • @testing-library/user-event simulates real user actions such as typing and clicking.
  • @testing-library/jest-dom adds readable checks such as toBeInTheDocument() and toBeDisabled().

Add a test section to vite.config.js and a setup file that runs before each test file:

JavaScript
// 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"],
  },
});
JavaScript
// 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 test

Finally 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.

JavaScript
// 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:

Output
 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:

React JSXTry it yourself
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);
Output
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.

React JSX
// 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:

  1. getByRole("button", { name: "Save" }): buttons, links, headings, checkboxes and so on.
  2. getByLabelText("Email"): form fields by their label.
  3. getByText("Cups sold: 0"): visible text.
  4. getByTestId("chart"): a last resort using a data-testid attribute.

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.

React JSX
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:

Output
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 await with userEvent and findBy. Both are asynchronous; without await the check runs too early.
  • No cleanup between tests. Without cleanup(), components from earlier tests stay on the page and queries find duplicates.
  • Using getBy to check absence. It throws; use queryBy... with not.toBeInTheDocument().
  • Calling real APIs in tests. Pass mock functions or mock fetch so 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 and cleanup.
  • Test behaviour the user sees: query by role, label and text, and simulate actions with userEvent.
  • getBy throws, queryBy returns null, findBy waits 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
React JSX
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?

2. Which query should you prefer to find a "Save" button?

3. How do you check that a loading message has disappeared?

4. What is vi.fn() used for?

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