Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~25 min· topic 2 of 7

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.jsx files, 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 node or jsdom.
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, like toBe or toBeInTheDocument.
jest-dom
A package that adds DOM matchers such as toBeInTheDocument() and toHaveTextContent().
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.

terminalwhole filebash
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom

02Tell 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.

vitest.config.jswhole filejavascript
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.

src/setupTests.jswhole filejavascript
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.

package.jsonwhole filejson
{
  "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.

How a test run flowsdiagram
Rendering diagram…

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

    Fix the wrong expectation

    In the mini runner, change "enabled" to "on". Predict the summary line before you run.

  2. 2

    Add a matcher

    Add toEqual: (x) => JSON.stringify(actual) === JSON.stringify(x) || fail("to equal " + JSON.stringify(x)) to the mini expect, then write a test expect([1, 2]).toEqual([1, 2]). Why would toBe fail for the same arrays?

  3. 3

    Turn cleanup back on

    In the second example, add cleanup(); as the first line of the test helper. Predict both test lines and the heading count, then run.

Code & diagrams

What a test runner does, in 25 lines New tab

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

Press Run to see the page.

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)
Why the setup file cleans up after each test New tab

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

Press Run to see the page.

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
A real test file with jest-dom matchersjsx
// 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");
  });
});
Running it (output abbreviated and illustrative)bash
$ 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: on

Break 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").

terminal
$ Console
── what you'll see ──
✗ greets Ravi: Found multiple elements with the role "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 />).

terminal
$ npm run test:run
── what you'll see ──
ReferenceError: document is not defined

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().

terminal
$ npm run test:run
── what you'll see ──
Error: Invalid Chai property: 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 beforeEach or create it per test (Topic 17.6 does this for stores).

  • ▸

    RTL wraps render, fireEvent and user-event in React's act() and sets IS_REACT_ACT_ENVIRONMENT for you, so React flushes effects and updates before you assert. In React 19 react-dom/test-utils only keeps a deprecated act; import act from react (RTL re-exports it).

  • ▸

    Per-file environments are possible: // @vitest-environment node at 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. 1

    Vitest is a test runner built on Vite, the build tool you used in Topic 0.4. It finds files named *.test.jsx or *.spec.jsx (.js, .ts and .tsx too), runs every test(...) 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. 2

    Tests run in Node.js, which has no document and no DOM. The test environment fixes that: with environment: "jsdom", Vitest loads jsdom, a JavaScript implementation of the DOM and HTML standards, before each test file. Now document.body, events, forms and focus all exist. jsdom has no layout and no paint: sizes and positions are all 0, 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-dom is a faster, less complete alternative environment.

  3. 3

    React Testing Library (@testing-library/react, RTL) gives you render() to mount a component into the jsdom page, screen to 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-event simulates real user interaction (Topic 17.4). RTL 16 lists @testing-library/dom as a peer dependency (a package you must install yourself next to it), so install both.

  4. 4

    jest-dom (@testing-library/jest-dom) adds readable DOM matchers to expect. A matcher is a check method like toBe. jest-dom adds toBeInTheDocument(), toHaveTextContent(), toBeVisible(), toBeDisabled(), toHaveValue(), toHaveAttribute(), toBeChecked(), toHaveFocus() and toHaveAccessibleName(), with failure messages that show the element. It works with Vitest by importing @testing-library/jest-dom/vitest once, in a setup file.

  5. 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 to document.body and leaves it there. RTL removes them automatically only when a global afterEach exists, which happens with globals: true in Vitest. Without globals you call afterEach(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. 6

    Two scripts finish the setup: "test": "vitest" starts watch mode in a terminal (it reruns the affected tests every time you save), and vitest run runs 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 --coverage adds a coverage report once you install a coverage provider.

Explain it without notes

01

What is jsdom and what can't it do?

02

What goes in a Vitest setup file for React Testing Library and why?

03

What is the difference between vitest and vitest run?

04

Why does RTL 16 make you install @testing-library/dom yourself?

Practice

01

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.

02

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: true saves imports and turns on RTL's auto-cleanup, but hides where test and expect come from and needs extra TypeScript types. Explicit imports are clearer; then remember afterEach(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.