Vitest 4 in practice: config, environments, coverage, output
- Read and write a Vitest 4 config —
include,environment(and the per-file@vitest-environmentcomment),globals,setupFilesvsglobalSetup— knowing each default - Explain what the default
forkspool gives you overthreads(process-level state such asprocess.env.TZ), and what turningisolateoff lets leak between files - Turn on coverage with
coverage.includeset, and say what a coverage percentage does and doesn't prove - Judge when a snapshot test helps and when it rubber-stamps, and review a
-uupdate like code - Get passing tests' console output back when a reporter hides it (the agent reporter,
silent: "passed-only"), and predict what breaks when a suite moves to Vitest 5
Read
The config file, line by line
FlagVitest runs with no config at all. You add vitest.config.ts when a default doesn't fit — and
you should know the defaults, because they're what you get for every line you leave out. This is
Vitest 4.
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Which files are tests. Default: ["**/*.{test,spec}.?(c|m)[jt]s?(x)"]
include: ["src/**/*.test.ts"],
// Where tests run. Default: "node" — no DOM. "jsdom" or "happy-dom" for UI tests.
environment: "node",
// describe/it/expect as globals? Default: false — import them from "vitest".
globals: false,
// Files run before EACH test file, in the same worker as its tests.
setupFiles: ["./test/setup.ts"],
},
});
The one for this app is a good example of "only what's needed":
export default defineConfig({
resolve: { alias: { "@": root } }, // the same @/ alias as tsconfig
test: {
environment: "node", // lib/* logic is pure; no DOM needed
include: ["lib/**/*.test.ts", "learning/**/*.test.ts"],
},
});
include keeps Vitest from picking up test files it shouldn't — here, the challenge graders under
content/, which have their own config and their own run.
Two commands cover daily use:
vitest— in an interactive terminal, starts watch mode: it re-runs the tests affected by each file you save.vitest run— runs once and exits with a status code. That's what CI andpackage.jsonscripts use. Outside a terminal (CI, or a non-interactive shell) plainvitestruns once too.
Run part of the suite by passing a filter: vitest run cart runs files whose path contains cart;
-t "starts empty" runs tests whose name matches.
Setup files, globals, and where your tests actually run
FlagsetupFiles vs globalSetup
test: {
setupFiles: ["./test/setup.ts"], // before EACH test file, inside its worker
globalSetup: ["./test/global-setup.ts"], // ONCE, in the main process, before any worker starts
}
setupFilesrun in the same process as the tests they precede, so anything they do to the test's world sticks: register matchers, install MSW'sserver.listen(), add anafterEachcleanup. Vitest re-runs them for every test file.globalSetupruns once, before the workers exist, and can return a teardown. Use it for things that are expensive and shared — starting a test database or a server. It can't touch a test's globals, because it doesn't run where the tests run.
globals: true
With the default false, every test file imports describe, it, expect from "vitest". Turn
it on and they're global, Jest-style — and add "vitest/globals" to compilerOptions.types in
tsconfig.json so TypeScript knows. One practical consequence, from Vitest's docs: "some libraries,
e.g., @testing-library/react, rely on globals being present to perform auto cleanup" — unmounting
what each test rendered. With globals: false, call RTL's cleanup() in an afterEach in a setup
file.
Pools: forks vs threads
Vitest runs test files in parallel workers. In Vitest 4 the default pool is "forks" — child
processes. The alternative, "threads", uses worker threads in one process: often faster, but
the docs say "you are unable to use process related APIs such as process.chdir()".
The same limit bites with time zones. A setup file that sets process.env.TZ = "Asia/Tokyo" works
under forks and is silently ignored under threads — checked under Vitest 4.1: the forks run
reports Asia/Tokyo, the threads run keeps the machine's zone. A suite that tests time-zone
behaviour that way needs forks (and should assert the zone actually changed). The
UTC vs local time unit covers time zones in tests in full.
isolate
By default (isolate: true) each test file gets a fresh copy of every module, so module-level state
from one file can't leak into another. Turning it off is faster but brings that leak back — within
a file, modules are always shared, which is why the testability unit moves state out of modules.
Where did my output go? Reporters, CI and agents
FlagThe same suite prints different things depending on where it runs, and Vitest decides some of it for you from the environment.
What changes in CI
Vitest reads the environment (through the std-env package) and, when it detects CI:
- doesn't start watch mode — the default is
!process.env.CI && process.stdin.isTTY; - rejects
.only—allowOnlydefaults to!process.env.CI, so a forgottenit.onlyfails the run instead of silently skipping the rest of the file; - adds the GitHub Actions reporter when
GITHUB_ACTIONSis"true"and you haven't configuredreporters, which turns failures into annotations on the PR.
What changes under an AI coding agent
Since Vitest 4.1, when Vitest detects it's being run by a coding agent (Claude Code, for instance)
and no reporters option is set, it swaps the default reporter for the agent (minimal)
reporter (4.0 has no such switch). Its docs: it
"outputs a minimal report containing only failed tests and their error messages", and console
logs from passing tests are suppressed.
Checked under Vitest 4.1.12: a test that runs console.log("HELLO") and passes prints nothing in an
agent session; the same run with --reporter=default, or with the agent's environment variables
unset, prints HELLO. Logs from failing tests still appear. So a debugging console.log that
"never runs" may be running fine — the reporter is just dropping it. Ask for it back:
vitest run --reporter=verbose # or --reporter=default
or pin reporters: ["default"] in the config so humans and agents see the same thing. The reverse
switch exists too: silent: "passed-only" hides passing tests' logs on purpose, for any reporter.
The other reporters
default (summary, plus the tree for failures or a single file), verbose (every test as it
finishes), tree, dot, and machine-readable ones like json and junit. Vitest 4 removed the
basic reporter (use ["default", { summary: false }]).
When you upgrade to Vitest 5
Vitest 5 shipped in September 2026. Behaviour this track describes that changes (the first two checked by running the same file on 4.1.12 and 5.0.5):
- an un-awaited
.resolves/.rejectsfails the test, even when the expectation is right (Vitest 4 auto-awaits it with a warning); clearMocksdefaults totrue, so call history no longer carries over between tests — a test that counted a module-level mock's calls across tests goes red;- it needs Node 22.12+ and Vite 6.4+, and
viteis no longer bundled: it becomes a required peer dependency (npm and pnpm install peers for you; Yarn users add it themselves); - the
jsonandjunitreporters write to files by default instead of stdout.
Read the migration guide before bumping; the full list is longer.
Quiz
In a project of mostly logic tests, one new component test fails with ReferenceError: document is not defined. What's the most targeted fix?
Where should MSW's server.listen() go so every test file's fetch calls are intercepted?
With globals: false, React Testing Library renders pile up within a test file — the second test finds two copies of a button. Why?
A setup file sets process.env.TZ = "Asia/Tokyo". After someone switches pool to "threads" for speed, date tests start seeing the machine's own time zone. Why?
Coverage says 92%, but you know two whole modules have no tests at all. What's going on?
FlagA markup refactor fails 40 snapshot tests. The author runs vitest -u, and reviewers approve the PR without opening the .snap diffs. What has gone wrong?
An AI coding agent runs vitest run to debug. A console.log inside a passing test never shows up. What's the likeliest reason?
A file creates const send = vi.fn() at the top; its second test calls send() once and expects toHaveBeenCalledTimes(2), counting the first test's call. Green on Vitest 4, red after upgrading to Vitest 5. Why?
Fill in the blank
Give one file a simulated DOM, leaving the rest of the suite in Node.
Flag// @vitest- happy-dom import { render, screen } from "@testing-library/react";
Fill in the config keys.
Flagexport default defineConfig({ test: { : ["./test/setup.ts"], // before each test file, in its worker : true, // describe/it/expect without importing them }, });
Make untested files show up in the coverage report at 0% instead of being left out.
Flagcoverage: { : ["src/**/*.ts"], },
Two CLI flags: see every test (and its logs) as it finishes, and accept intentional snapshot changes.
Flagvitest run --reporter= vitest run
Predict the output
// test/setup.ts
process.env.TZ = "Asia/Tokyo";
// vitest.config.ts
test: { setupFiles: ["./test/setup.ts"], pool: "threads" }
// tz.test.ts — the machine's own zone is America/New_York
it("zone", () => {
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
});// src/price.ts — tested by src/price.test.ts // src/untested.ts — no test imports it // vitest.config.ts — coverage on, no coverage.include // $ vitest run --coverage
// registry.ts
export const seen: string[] = [];
// a.test.ts
it("a adds", () => { seen.push("a"); });
// b.test.ts — runs after a.test.ts, in the same worker
it("b expects empty", () => { expect(seen).toEqual([]); });
// vitest.config.ts
test: { isolate: false, maxWorkers: 1 }