Topic 17.2
Setting Up Vitest and Testing Library
In one line
Vitest runs your test files in Node with jsdom pretending to be a browser, React Testing Library renders components into that fake page, and a setup file adds jest-dom matchers and cleans up after each test.
Think of it like this
A flight simulator. Pilots practise in a room that looks and responds like a real cockpit, but the plane never leaves the ground, so they can crash safely a hundred times a day. jsdom is the simulator for your components: a pretend browser page inside Node.js, where tests can click and type thousands of times a minute without opening a real browser.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Vitest
- A fast test runner built on Vite. It finds
*.test.jsxfiles, runs the tests and prints a report. - Jest
- An older, very popular test runner with almost the same API as Vitest.
- jsdom
- A pretend browser page written in JavaScript, so DOM code can run inside Node.js. No layout or drawing.
- Test environment
- What the test runner sets up before tests run, such as
nodeorjsdom. - React Testing Library (RTL)
- A small library that renders React components for tests and finds elements the way users do.
- Matcher
- A check method on
expect, liketoBeortoBeInTheDocument. - jest-dom
- A package that adds DOM matchers such as
toBeInTheDocument()andtoHaveTextContent(). - Setup file
- A file the runner loads before every test file, for shared setup like matchers and cleanup.
- Watch mode
- The runner stays open and reruns tests whenever you save a file.
- CI (continuous integration)
- A server that installs, builds and tests your code on every push or pull request.
Step by step
01Install the packages as dev dependencies
In a Vite React project (Topic 0.4), install the runner, the environment and the Testing Library packages. -D saves them as dev dependencies: tools needed while developing, never shipped to users.
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom02Tell Vitest to use jsdom and a setup file
Vitest reads vitest.config.js (or a test section inside vite.config.js). globals: true makes describe, test, expect and afterEach available without imports, which also switches on RTL's automatic cleanup.
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom", // a pretend browser page
globals: true, // describe/test/expect without imports
setupFiles: ["./src/setupTests.js"],
},
});03Write the setup file
One import registers the jest-dom matchers with Vitest's expect. The explicit afterEach(cleanup) is harmless with globals on, and required with them off, so many teams keep it.
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup(); // unmount whatever the test rendered
});04Add the scripts and run
npm test starts watch mode. In CI use npm run test:run (or npx vitest run), which runs once and fails the build if any test fails.
{
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest",
"test:run": "vitest run"
}
}05How a test run flows
For each test file Vitest creates the jsdom environment, runs the setup file, then the tests one by one, with afterEach hooks between them. Each test file runs in isolation by default, so one file's leftovers can't leak into another.
06TypeScript projects: add the types
With globals: true in a TypeScript project, add "types": ["vitest/globals", "@testing-library/jest-dom"] to the compilerOptions of the tsconfig that covers your tests, so the editor knows test, expect and toBeInTheDocument. Without globals, import them from vitest in each file instead.
Try it yourself
- 1
Fix the wrong expectation
In the mini runner, change
"enabled"to"on". Predict the summary line before you run. - 2
Add a matcher
Add
toEqual: (x) => JSON.stringify(actual) === JSON.stringify(x) || fail("to equal " + JSON.stringify(x))to the miniexpect, then write a testexpect([1, 2]).toEqual([1, 2]). Why wouldtoBefail for the same arrays? - 3
Turn cleanup back on
In the second example, add
cleanup();as the first line of thetesthelper. Predict both test lines and the heading count, then run.
Code & diagrams
A runner collects tests first, then runs them one by one, cleaning up in between, and prints a summary. The third test is wrong on purpose so you can see a failure message. Vitest does the same with far better messages, timing, watch mode and parallel files.
Preview
Console
Nothing logged yet.Expected console output
✓ Toggle > starts off
✓ Toggle > turns on when clicked
× Toggle > says 'enabled' when on (a wrong expectation)
→ expected "Dark mode: on" to contain "enabled"
Tests 1 failed | 2 passed (3)render() appends a new container to document.body every time. Without cleanup the second test finds Asha's heading and Ravi's, so getByRole throws. cleanup() unmounts every tree render() created and removes the containers.
Preview
Console
Nothing logged yet.Expected console output
✓ greets Asha
✗ greets Ravi: Found multiple elements with the role "heading"
headings on the page: 2
after cleanup(): 0// src/Toggle.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Toggle from "./Toggle";
describe("Toggle", () => {
test("starts off", () => {
render(<Toggle />);
expect(screen.getByRole("button", { name: /dark mode/i })).toHaveTextContent("off");
});
test("turns on when clicked", async () => {
const user = userEvent.setup();
render(<Toggle />);
const button = screen.getByRole("button", { name: /dark mode/i });
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
expect(button).toHaveTextContent("on");
});
});$ npm run test:run
✓ src/Toggle.test.jsx (2 tests)
✓ src/Cart.test.jsx (3 tests)
Test Files 2 passed (2)
Tests 5 passed (5)
# A failing assertion prints the test name, the matcher message and the code frame:
FAIL src/Toggle.test.jsx > Toggle > turns on when clicked
Error: expect(element).toHaveTextContent()
Expected element to have text content:
enabled
Received:
Dark mode: onBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the cleanup (globals off, no afterEach)
Set globals: false in vitest.config.js and remove afterEach(cleanup) from the setup file. Two tests in one file both call screen.getByRole("heading").
Break #2
Run tests in the node environment
Remove environment: "jsdom" from the config (the default environment is node) and run a test that calls render(<Toggle />).
Break #3
Use jest-dom matchers without the setup import
Delete import "@testing-library/jest-dom/vitest"; from the setup file and run a test using toBeInTheDocument().
Myth vs fact
Myth
jsdom is a real browser, so passing tests prove the layout works.
Fact
jsdom implements the DOM but no layout or painting: every size is 0 and media queries don't match. Test behaviour in jsdom; test layout and visuals in a real browser (Topic 17.7).
Myth
You need Jest to test React.
Fact
Any runner works. Vitest is the natural choice in Vite projects (same config, fast, ESM-native); Jest is still common in older projects. Testing Library is the same in both.
Myth
toBeInTheDocument() is part of Vitest.
Fact
It comes from jest-dom and only exists after you import @testing-library/jest-dom/vitest in a setup file.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Vitest runs test files in parallel worker processes or threads and isolates each file by default. Module-level state (a store created at import time, a cache) is fresh per file but shared between tests in the same file: reset it in
beforeEachor create it per test (Topic 17.6 does this for stores). - ▸
RTL wraps
render,fireEventand user-event in React'sact()and setsIS_REACT_ACT_ENVIRONMENTfor you, so React flushes effects and updates before you assert. In React 19react-dom/test-utilsonly keeps a deprecatedact; importactfromreact(RTL re-exports it). - ▸
Per-file environments are possible:
// @vitest-environment nodeat the top of a pure-logic test file skips jsdom startup. Vitest also has a Browser Mode that runs component tests in a real browser through Playwright: real layout and events, at the cost of speed (Topic 17.7). - ▸
In a Next.js app, async Server Components aren't fully supported by unit-testing tools yet, and the Next.js docs recommend E2E tests for them; and unit-test client components and plain functions with Vitest as usual (see the Next.js testing guide in
node_modules/next/dist/docs/).
Remember this
- 1
Vitest is a test runner built on Vite, the build tool you used in Topic 0.4. It finds files named
*.test.jsxor*.spec.jsx(.js,.tsand.tsxtoo), runs everytest(...)inside them and prints a report. It understands JSX, TypeScript and your Vite config out of the box, and its API (describe,test/it,expect,vi.fn()) is nearly identical to Jest, the older runner that Create React App used. Everything in this phase works in Jest with small renames (vi.fn→jest.fn). - 2
Tests run in Node.js, which has no
documentand no DOM. The test environment fixes that: withenvironment: "jsdom", Vitest loads jsdom, a JavaScript implementation of the DOM and HTML standards, before each test file. Nowdocument.body, events, forms and focus all exist. jsdom has no layout and no paint: sizes and positions are all0, CSS media queries don't apply, and nothing is drawn. That is fine for behaviour tests; for layout and visuals use a real browser (Topic 17.7).happy-domis a faster, less complete alternative environment. - 3
React Testing Library (
@testing-library/react, RTL) gives yourender()to mount a component into the jsdom page,screento find things on it, and re-exports everything from@testing-library/dom. It is deliberately small: it has no way to read state or props, which nudges you towards testing behaviour.@testing-library/user-eventsimulates real user interaction (Topic 17.4). RTL 16 lists@testing-library/domas a peer dependency (a package you must install yourself next to it), so install both. - 4
jest-dom (
@testing-library/jest-dom) adds readable DOM matchers toexpect. A matcher is a check method liketoBe. jest-dom addstoBeInTheDocument(),toHaveTextContent(),toBeVisible(),toBeDisabled(),toHaveValue(),toHaveAttribute(),toBeChecked(),toHaveFocus()andtoHaveAccessibleName(), with failure messages that show the element. It works with Vitest by importing@testing-library/jest-dom/vitestonce, in a setup file. - 5
A setup file runs before every test file. Two jobs belong there: register the jest-dom matchers, and clean up after each test. RTL's
render()adds a container todocument.bodyand leaves it there. RTL removes them automatically only when a globalafterEachexists, which happens withglobals: truein Vitest. Without globals you callafterEach(cleanup)yourself. Forget cleanup and the second test sees the first test's component too: queries find two matches and throw (the second live example shows it). - 6
Two scripts finish the setup:
"test": "vitest"starts watch mode in a terminal (it reruns the affected tests every time you save), andvitest runruns everything once and exits with a non-zero code on failure, which is what CI (continuous integration, the server that tests every pull request) needs.vitest run --coverageadds a coverage report once you install a coverage provider.
Explain it without notes
What is jsdom and what can't it do?
What goes in a Vitest setup file for React Testing Library and why?
What is the difference between vitest and vitest run?
Why does RTL 16 make you install @testing-library/dom yourself?
Practice
Write a vitest.config.js for a project that wants jsdom, no globals, and a setup file at test/setup.js. Then write that setup file.
Write a real test file (Vitest syntax, jest-dom) for a Badge component that renders <span>New</span> when isNew is true and nothing otherwise.
Trade-offs
- ↔
jsdom is fast and needs no browser, but misses layout, real focus quirks and CSS. Vitest Browser Mode or Playwright component tests are more realistic and slower. Most teams use jsdom for the bulk and a real browser for a few checks.
- ↔
globals: truesaves imports and turns on RTL's auto-cleanup, but hides wheretestandexpectcome from and needs extra TypeScript types. Explicit imports are clearer; then rememberafterEach(cleanup).
Done when you can
I can install Vitest, jsdom and the Testing Library packages.
I can write a vitest.config with the jsdom environment and a setup file.
I can explain why the setup file imports jest-dom and calls cleanup.
I know the difference between watch mode and
vitest run.I know what jsdom can't test.