Skip to content
Chapter 86Lesson 1

Picking Vitest and wiring the runner

Choose Vitest as your test runner and write the one config that serves both Node logic and browser components.

Up to now you have verified your code by running it: you save a file, the dev server reloads, you click through the app, and it works. That loop doesn’t scale. A web app grows past the point where you can hold all of it in your head, and the question stops being “does this work” and becomes “did changing this break something three modules away that I forgot existed?” A test suite answers that on every commit, without re-clicking the whole app.

This chapter settles three decisions: which runner to use, what shape the suite takes, and what a single test looks like. This lesson covers the runner. By the end you’ll have Vitest installed, a vitest.config.ts you understand, and a sense of when to run vitest while you code versus vitest run in CI.

The runner choice comes down to one problem: the codebase has two kinds of code to test. Most of it is pure logic in /lib, like validators, mappers, and the Temporal codecs, all running in plain Node and never touching a browser. Components are the other kind, and rendering them needs a DOM that Node doesn’t have. The runner has to serve both from one configuration, without the Babel and ts-jest plumbing this used to cost. You already build in Vite, ES modules, and TypeScript every day, and Vitest is that same toolchain pointed at your tests.

Two test runners fit a Next.js project: Jest, the longtime JavaScript default, and Vitest, the Vite -native runner with a Jest-compatible API. Three things decide it.

First, and most important: your project is ESM and TypeScript top to bottom. Jest was built in the CommonJS era, so running ESM and .ts/.tsx through it means bolting on a transform pipeline: babel-jest or ts-jest, a preset, and a config that drifts out of sync with your real build. Vitest adds no second toolchain; it reads the Vite and TypeScript pipeline you already have and runs .ts and .tsx directly.

Second, the switch costs almost nothing to learn. Vitest’s API is deliberately Jest-compatible: describe, it, expect, and beforeEach are identical, and the one rename a Jest reader notices is vi.fn() for Jest’s jest.fn(). Your knowledge transfers in, and inheriting a Jest codebase later is a mechanical migration, not a rewrite.

Third, feedback is fast. Vitest’s watch mode is HMR for tests: save a file, and Vitest re-runs only that file and the tests that depend on it, the same way the dev server hot-reloads a component. It is fast enough to leave running while you edit, so a failure shows up the second you introduce it.

jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { useESM: true }],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
export default config;

A second toolchain on top of the one you have. On an ESM and TypeScript project, Jest needs a ts-jest preset, an explicit transform, and a hand-maintained alias map that duplicates tsconfig.json — all config you own, and all of it drifts.

A version note: this course uses the Vitest 4 line, with 4.1 the current stable release. A tutorial that tells you to install version 3 or keep a separate workspace file predates it; disregard those.

Setup is short, since the toolchain Vitest plugs into is already on disk: two packages, three scripts, and a config file.

  1. Install the runner and the coverage provider as dev dependencies .

    Terminal window
    pnpm add -D vitest @vitest/coverage-v8

    @vitest/coverage-v8 is the coverage provider. Install it now so the dependency is in place; you’ll configure coverage in Coverage as a diagnostic, two lessons from here.

  2. Add the test scripts to package.json.

    package.json
    {
    "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
    }
    }

    test runs the watch loop, the one you run constantly. test:run does a single pass and exits, which is what continuous integration calls. test:coverage is that single pass with a coverage report added.

  3. Create the config file. That’s the next section, where the real decisions live.

A few packages you might expect are deliberately absent. @vitest/ui adds an opt-in browser dashboard. jsdom and @testing-library/react are what component tests need; they arrive with the React Testing Library chapter, so installing them now would leave dependencies nothing uses.

The runner’s behavior lives in one file, vitest.config.ts. The finished version is short, but we build it in three stages so each option’s reasoning is visible.

The smallest config that runs a test is a defineConfig with a test block holding three options.

vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

Each line is a decision.

environment: 'node' runs tests in a plain Node process, with no browser. It’s the default because most of what you test, the /lib logic, never touches a DOM. The few tests that do get their own environment in a moment, rather than paying for a fake browser on every test.

include and exclude are the collection rules. include tells Vitest which files are tests: anything under src ending in .test or .spec, in a .ts or .tsx file. exclude keeps it out of node_modules, build output (dist), and the Next.js cache (.next).

globals: false is the one that deserves a pause. With globals: true, the Jest-style default, describe, it, and expect are ambient globals that exist with no import line. That’s convenient, but wrong for a codebase you intend to keep alive for years: a refactoring tool can’t follow ambient names, “find all references” comes up empty, and a file’s imports no longer tell you what it depends on. With globals: false, every test file imports { describe, it, expect } from 'vitest' explicitly, like everything else it uses. One import line per file buys code that stays grep-able and keeps a real symbol for refactor tools to track.

Your app code imports with the @/ path alias : import { db } from '@/lib/db' instead of a brittle ../../../lib/db. Your tests should import the same way.

The catch: the alias is declared in tsconfig.json, and Vitest resolves modules through Vite, which doesn’t read tsconfig paths on its own, so @/lib/db in a test fails to resolve. The fix is one plugin. vite-tsconfig-paths reads the paths from your tsconfig.json and teaches Vite’s resolver about them, so test resolution and app resolution match.

vitest.config.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: false,
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

Some things have to happen before a file’s tests run, such as loading test environment variables and pinning the timezone. Those live in a setup file that setupFiles points Vitest at, run once at the top of every test file.

vitest.config.ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: false,
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

We’ll write vitest.setup.ts itself in a later section; for now the config just knows where to find it.

That’s the whole base config. Here it is once more as a single walkthrough.

import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: false,
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

Vitest’s config is a Vite config. defineConfig gives it types, and the vite-tsconfig-paths plugin makes @/ resolve in tests exactly as it does in the app.

import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: false,
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

Tests run in plain Node by default. Most of what you test is pure logic that never needs a browser; the few tests that do get their own environment in the next section.

import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: false,
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

No ambient globals. Every test imports describe, it, and expect from 'vitest', so the codebase stays grep-able and refactor tools keep a symbol to follow.

import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: false,
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

One file runs before each test file: env loading and timezone pinning. We build it in the setup-file section below.

import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: false,
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
});

What counts as a test, and where never to look. Colocated *.test.ts files under src, skipping build output and the Next.js cache.

1 / 1

Your codebase has two kinds of test, soon three, and they don’t all want the same environment. The /lib logic runs in plain Node. Integration tests that hit a real database also run in Node, but live apart because they cross module boundaries and carry a heavier lifecycle. Component tests, later, need a fake browser. One flat include glob can’t express that, but test projects can.

A project is a named slice of your config with its own environment and include glob, declared in a projects array inside the test block of the same root config. The shared options you already set, globals: false, the vite-tsconfig-paths plugin, and setupFiles, stay at the root and apply to every project; each project overrides only its environment and the files it claims.

This codebase defines three.

  • vitest.config.ts the one root config
  • vitest.setup.ts shared setup
  • Directorysrc/
    • Directorylib/
      • money.ts
      • money.test.ts unit project (node)
      • invoice-mapper.ts
      • invoice-mapper.test.ts unit project (node)
    • Directorycomponents/
      • invoice-badge.test.tsx component project (jsdom), later
  • Directorytests/
    • Directoryintegration/
      • create-invoice.test.ts integration project (node, real DB)
// inside test: { ... } of vitest.config.ts
projects: [
{
test: {
name: 'unit',
environment: 'node',
include: ['src/**/*.test.ts'],
},
},
{
test: {
name: 'integration',
environment: 'node',
include: ['tests/integration/**/*.test.ts'],
},
},
// {
// test: {
// name: 'component',
// environment: 'jsdom',
// include: ['src/**/*.test.tsx'],
// },
// },
],

unit: pure-logic tests in plain Node. Its glob claims every colocated *.test.ts under src, the /lib surface that forms the wide base of the suite.

// inside test: { ... } of vitest.config.ts
projects: [
{
test: {
name: 'unit',
environment: 'node',
include: ['src/**/*.test.ts'],
},
},
{
test: {
name: 'integration',
environment: 'node',
include: ['tests/integration/**/*.test.ts'],
},
},
// {
// test: {
// name: 'component',
// environment: 'jsdom',
// include: ['src/**/*.test.tsx'],
// },
// },
],

integration: also Node, but these tests run against a real test database and cross module boundaries, so they live under tests/integration/. The database lifecycle that makes this work belongs to a later chapter; for now this reserves the slot.

// inside test: { ... } of vitest.config.ts
projects: [
{
test: {
name: 'unit',
environment: 'node',
include: ['src/**/*.test.ts'],
},
},
{
test: {
name: 'integration',
environment: 'node',
include: ['tests/integration/**/*.test.ts'],
},
},
// {
// test: {
// name: 'component',
// environment: 'jsdom',
// include: ['src/**/*.test.tsx'],
// },
// },
],

component: the only one that needs a fake browser (jsdom). It’s commented out because the dependencies land in the React Testing Library chapter. Defining the slot now makes its arrival one uncomment rather than a config rethink.

1 / 1

With projects present, each project’s own include claims its files, replacing the single root-level include. The globs are narrower on purpose: each takes only the files it owns. The unit glob is *.test.ts, not *.{test,spec}.{ts,tsx}, because this codebase uses .test.ts as its one naming convention, and .tsx tests belong to the component project that ships a DOM to render them. Run vitest --project unit to test a single slice while iterating, or bare vitest to run every project in CI.

The runner’s execution model isn’t obvious, and it shapes how you write every test. Two rules.

First, each test file runs in its own worker, a separate worker_threads thread by default. (If a library misbehaves in a thread, you can switch a project to forks, which uses child processes instead. You’ll rarely need it.)

Second, files run in parallel across workers, but the tests inside a single file run in order, one after another. Parallelism happens at the file boundary, not the test boundary.

files: parallel
Worker 1
money.test.ts
1 it( … )
2 it( … )
3 it( … )
tests in a file: sequential
Worker 2
invoice-mapper.test.ts
1 it( … )
2 it( … )
3 it( … )
tests in a file: sequential
Worker 3
create-invoice.test.ts
1 it( … )
2 it( … )
3 it( … )
tests in a file: sequential
Three files, three workers running at once; inside each file the tests run top to bottom.

Two consequences follow.

Because each file is its own worker, file-level isolation is free. If file A mocks a module or changes an environment variable, none of it leaks into file B: a different thread means a different module registry. You never have to clean up after one file for the sake of another.

Because the tests inside a file share one worker and run in sequence, isolation inside a file is your job. If one it block leaves behind a mutated array or a stale counter and the next reads it, the tests are coupled by run order, and a test that only passes because of the one before it is a test you can’t trust. This is why the course teaches “no shared mutable state, no run-order dependency” from the very first test; in the test-database chapter you’ll see why it’s the leading cause of flaky tests.

Two test files, A and B, start running. Order these events the way Vitest's default model produces them. Drag the items into the correct order, then press Check.

File A and file B each start in their own worker, at the same time
Inside file A, its first it block runs
Inside file A, its second it block runs only after the first finishes
File A’s results and file B’s results are collected once every file’s tests have run

Back to vitest.setup.ts, the file you wired into setupFiles. It runs once before every test file, and the rule is sharp: setup is for what every test needs, nothing feature-specific. That leaves two whole-suite concerns, environment and time.

vitest.setup.ts
import { config } from 'dotenv';
config({ path: '.env.test' });
process.env.TZ = 'UTC';

The first line points dotenv at .env.test, so tests get test configuration, a test database URL and fake API keys, and never see production secrets. A test that can reach a production credential can mutate production data.

The second line pins the runtime timezone to UTC, the same decision you made for production in Storage, domain, edge. If a test machine ran in America/New_York while production ran in UTC, a date-formatting test could pass on your laptop and fail in CI for no reason but the clock. Pinning TZ to UTC makes a passing test mean the same thing everywhere.

That is the entire baseline. You’ll add one line later: when component tests arrive, a global afterEach(cleanup) gets registered here to tear down rendered DOM between tests, but registering it before jsdom exists would only error.

Keep fixtures out too. Per-feature test data lives next to the code that uses it and gets imported where needed, not built once in the setup file for every test to grab.

Running tests: vitest while you code, vitest run in CI

Section titled “Running tests: vitest while you code, vitest run in CI”

Two commands do two jobs, and confusing them can hang a CI pipeline.

vitest with no arguments runs in watch mode. It does an initial pass, then stays alive, re-running dependent tests every time you save. This is your local loop: start it in a terminal pane and leave it. While it runs, press p to filter by filename, t to filter by test name, and q to quit. For a heavy debugging session, vitest --ui opens a browser dashboard via the separately installed @vitest/ui package.

vitest run does a single pass and then exits. This is what CI runs: it executes the suite once, reports, and returns an exit code the job reads as pass or fail.

CI later adds a reporter flag, vitest run --reporter=junit, so GitHub Actions can render the results, but that belongs to the CI chapter. For now: vitest is for you, vitest run is for the machine.

Each claim is about how you run Vitest and how the runner is wired. Mark each statement True or False.

vitest with no arguments runs the suite once and then exits.

Bare vitest is watch mode — it stays alive re-running dependent tests on save. vitest run is the one that does a single pass and exits.

A CI test job should call vitest run, not vitest.

vitest run exits with a pass/fail code; bare vitest watches forever and hangs the CI job until it times out.

With globals: false, every test file imports describe, it, and expect from 'vitest'.

That’s the point of globals: false — no ambient names, so the codebase stays grep-able and refactor tools keep a symbol to follow.

Each test file runs in its own worker, so the tests inside one file are also isolated from each other automatically.

File-level isolation is free, but tests inside one file share a worker and run in sequence — isolating them from each other is the author’s job.

The names you import from 'vitest' are few and match Jest’s. Here is a complete test with every name imported explicitly, the way globals: false requires.

src/lib/money.test.ts
import { describe, it, expect } from 'vitest';
import { addCents } from '@/lib/money';
describe('addCents', () => {
it('sums two cent amounts', () => {
const total = addCents(199, 801);
expect(total).toBe(1000);
});
});

Three imports, describe, it, and expect, and the @/ alias resolves to your source. From here the chapter turns from wiring to judgment: what shape the suite should take, and what makes a single test worth keeping.