Picking Vitest and wiring the runner
Your first lesson on automated testing, choosing Vitest as the runner and writing the one config that serves both Node logic and browser components.
Up to now you have verified every line of code in this course by running it yourself: you saved a file, the dev server reloaded, you clicked through the app, and it worked. That loop doesn’t scale. A SaaS codebase 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 question for you, without re-clicking the whole app on every commit.
This chapter settles the three decisions every later testing lesson leans on: which runner to use, what shape the suite takes, and what a single test looks like. This first lesson covers the runner. By the end you’ll have Vitest installed, a vitest.config.ts you can read line by line and defend, and a clear sense of when to use vitest while you code versus vitest run in CI.
A concrete problem drives the runner choice, so it’s worth naming up front. The codebase already has two kinds of code that need testing. Most of it is pure logic in /lib: validators, mappers, the error-code mapper, the Temporal codecs. That code runs in plain Node and never touches a browser. Components are the other kind: when you start testing them later, they need a DOM to render into, and there is no DOM in Node. A 2026 test runner has to serve both kinds from one configuration, without the hundred lines of Babel and ts-jest plumbing that 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.
Why Vitest, not Jest
Section titled “Why Vitest, not Jest”There are two runners a Next.js team would genuinely consider in 2026: Jest, the historical default that dominated JavaScript testing for most of a decade, and Vitest, the Vite -native runner with a Jest-compatible API. The decision comes down to three things.
The first is the one that matters most. Your project is ESM and TypeScript, top to bottom. Jest was built in the CommonJS era, so running ESM and .ts/.tsx files through it means bolting on a transform pipeline: babel-jest or ts-jest, a preset, and a config that you maintain and that drifts out of sync with your real build. Vitest doesn’t transform your code through a second toolchain. It reads the Vite and TypeScript pipeline your project already has and runs .ts and .tsx directly. For an ESM-first stack this is the difference between a config you understand and a config you copy from Stack Overflow and hope works.
The second is that the switch costs you almost nothing to learn. Vitest’s API is deliberately Jest-compatible: describe, it, expect, and beforeEach are identical, character for character. The one rename a Jest reader notices is vi.fn() where Jest writes jest.fn(). That compatibility cuts both ways. Knowledge you already have transfers in, and if you ever inherit a Jest codebase, the migration is mechanical rather than a rewrite.
The third is speed of feedback, which will change how you work. Vitest’s watch mode is HMR for tests: when you save a file, Vitest re-runs only that file and the tests that depend on it, not the whole suite. You know this feeling already from the dev server, where you change a component and see it update with no full reload. Watch mode is fast enough to leave running in a terminal while you edit, so a test failure shows up the same second you introduce it.
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 already 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 of it is config you own and that drifts.
import { defineConfig } from 'vitest/config';import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ plugins: [tsconfigPaths()], test: { environment: 'node', globals: false, },});Reuses the pipeline already on disk. There’s no transform to configure. Vitest runs .ts and .tsx through the project’s Vite and TypeScript setup, and the @/ alias comes straight from tsconfig.json through one plugin.
One thing to pin down, because the version matters here: this course is on the Vitest 4 line, and 4.1 is the current stable release as of 2026. If you find an older tutorial that tells you to install version 3 or to keep a separate workspace file, it predates this and you can disregard it. The differences that matter are called out as we go. This is the last time the chapter mentions Jest; from here on the testing chapters assume Vitest.
Installing Vitest
Section titled “Installing Vitest”Installation is short, because the toolchain it plugs into is already on disk. You need two packages, three scripts, and a config file.
-
Install the runner and the coverage provider as dev dependencies .
Terminal window pnpm add -D vitest @vitest/coverage-v8@vitest/coverage-v8is the coverage provider. You’re installing it now so the dependency is present, but you won’t configure coverage until two lessons from now, in Coverage as a diagnostic. There is no coverage setup in this lesson. -
Add the test scripts to
package.json.package.json {"scripts": {"test": "vitest","test:run": "vitest run","test:coverage": "vitest run --coverage"}}testruns the watch loop, the command you’ll type many times a day.test:rundoes a single pass and exits, which is what continuous integration calls.test:coverageis that single pass with a coverage report added. -
Create the config file. That’s the next section, and it’s where the real decisions live.
A few packages you might expect are deliberately not here. @vitest/ui gives you a browser dashboard for the suite; it’s opt-in, and you add it only if you want it. jsdom and @testing-library/react are what component tests need, and they arrive later, in the React Testing Library chapter, when you actually write a component test. The reason to name them now is so you don’t install them prematurely and then wonder why nothing uses them.
The config, one piece at a time
Section titled “The config, one piece at a time”Everything that makes this runner yours lives in one file: vitest.config.ts. The finished version is short, but showing it all at once would hide the reasoning, and the reasoning is the whole point of a setup lesson. So we build it up in three stages, and you’ll see exactly what each stage adds.
Stage one: the minimum
Section titled “Stage one: the minimum”The smallest config that runs a test is a defineConfig with a test block holding three options.
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'node', globals: false, include: ['src/**/*.{test,spec}.{ts,tsx}'], exclude: ['node_modules', 'dist', '.next'], },});Read each line as a decision, because each one is.
environment: 'node' runs tests in a plain Node process, with no browser. This is the default because most of what you test, the /lib logic, never touches a DOM. The tests that do need a DOM are a minority, so we’ll give them 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 from wandering into node_modules, build output (dist), or the Next.js cache (.next).
globals: false is the one that deserves a pause, because it’s a deliberate judgment call. With globals: true, the Jest-style default, describe, it, and expect are ambient globals : they simply exist, with no import line. That’s convenient, but it’s wrong for a codebase you intend to keep alive for years. When those names are ambient, a refactoring tool can’t follow them, “find all references” comes up empty, and a reader can’t tell from a file’s imports what it depends on. With globals: false, every test file imports { describe, it, expect } from 'vitest' explicitly, the way it imports everything else. The codebase stays grep-able, refactor tools keep a real symbol to track, and nothing is special-cased. You pay one import line per file and get a codebase that behaves like the rest of your code.
Stage two: path aliases
Section titled “Stage two: path aliases”Your app code imports with the @/ path alias : import { db } from '@/lib/db' instead of a brittle ../../../lib/db. Your tests should import the exact same way. A test for lib/db.ts that has to reach for ../db while the rest of the codebase writes @/lib/db is friction you don’t want.
The catch is that the alias is declared in tsconfig.json, and Vitest resolves modules through Vite, which doesn’t read tsconfig paths on its own. So out of the box, @/lib/db in a test file 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 exactly.
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'], },});Stage three: the shared setup file
Section titled “Stage three: the shared setup file”Some things have to happen before a file’s tests can run, such as loading test environment variables and pinning the timezone. Those live in a setup file, and you point Vitest at it with setupFiles. It runs once at the top of every test file, before that file’s tests.
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, since it has its own decisions worth their own space. 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, so you can hold the finished shape in your head and revisit every option’s reason at once.
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.
One config, many environments
Section titled “One config, many environments”Here is the decision that made Vitest the pick over anything else, and the concept this lesson is really about. Your codebase doesn’t have one kind of test. It has two, soon three, and they don’t all want the same environment. The /lib logic wants plain Node. Integration tests that hit a real database want plain Node too, but they live apart because they cross module boundaries and have a heavier lifecycle. Component tests, later, want 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 its own include glob. You declare them in a projects array inside the test block of the same root config, and Vitest treats each as a self-contained world. The shared options you already set, namely globals: false, the vite-tsconfig-paths plugin, and setupFiles, stay at the root and apply to every project. What each project overrides is the part that differs between them: the 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
unitproject (node) - invoice-mapper.ts
- invoice-mapper.test.ts
unitproject (node)
Directorycomponents/
- invoice-badge.test.tsx
componentproject (jsdom), later
- invoice-badge.test.tsx
Directorytests/
Directoryintegration/
- create-invoice.test.ts
integrationproject (node, real DB)
- create-invoice.test.ts
// inside test: { ... } of vitest.config.tsprojects: [ { 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.tsprojects: [ { 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 the test-database chapter; for now this just reserves the slot.
// inside test: { ... } of vitest.config.tsprojects: [ { 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. The slot is defined now so its arrival is one uncomment rather than a config rethink.
Once projects is present, each project’s own include is what claims files; the single root-level include from the base config is now expressed per project instead. Notice that the project globs are narrower than that root pattern, and deliberately so, because each project claims only the files it actually owns. The unit glob is *.test.ts, not *.{test,spec}.{ts,tsx}. This codebase uses .test.ts as its one naming convention for a test, with no .spec second spelling, to keep grep clean. The .tsx test files belong to the component project, which is the project that ships a DOM to render them in. So the .spec and .tsx from the base pattern don’t vanish by accident; they’re partitioned to where they make sense. With the projects in place, vitest --project unit runs a single slice, while bare vitest runs every project. That’s how you run just the fast unit tests while iterating, then let the full set run in CI.
The point to take away is that the suite is not one flat pile of tests. It’s a set of projects, each owning a glob and each running in the environment its tests actually need. That partition is what lets one config serve Node logic and browser components without compromise, and it’s exactly why a Vite-native runner won the decision.
How the runner executes your tests
Section titled “How the runner executes your tests”One part of the runner’s behavior isn’t obvious, and it quietly shapes how you write every test, so it gets a diagram. It comes down to two rules.
First, each test file runs in its own worker, a separate worker_threads thread by default. (If some library misbehaves inside a thread, you can switch a project to forks, which uses child processes instead. You’ll rarely need it, so just know the word exists.)
Second, files run in parallel across workers, but the tests inside a single file run one after another, in order. Parallelism happens at the file boundary, not the test boundary.
Those two rules have consequences, and the consequences are what actually matters.
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 and a separate world. You don’t have to clean up after a file for the sake of other files, because the boundary does it for you.
Because the tests inside a file share one worker and run in sequence, isolation inside a file is your job, not the runner’s. If one it block leaves behind a mutated array or a stale counter and the next it reads it, your tests are now coupled by run order, and a test that only passes because of the test 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. The runner hands you isolation between files for free; isolation within a file you build with discipline. We’ll cover why this is the leading cause of flaky tests in the test-database chapter. For now, just know where the boundary sits.
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.
it block runs it block runs only after the first finishes The setup file every test shares
Section titled “The setup file every test shares”Now back to vitest.setup.ts, the file you wired into setupFiles. It runs once before each test file, and the rule for what belongs in it is sharp: setup is for things that every single test needs, nothing feature-specific. The two whole-suite concerns are environment and time.
import { config } from 'dotenv';
config({ path: '.env.test' });
process.env.TZ = 'UTC';The first line loads dotenv pointed at .env.test. Tests get test configuration, such as a test database URL and fake API keys, and never see production secrets. A test that can reach a production credential is a test that can, on a bad day, mutate production data.
The second line pins the runtime timezone to UTC. This is the same decision you made for production back in Storage, domain, edge, now applied to the test runtime. If a test machine sat 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 on the wall. Pinning TZ to UTC makes the test runtime match production, so a passing test means the same thing everywhere.
That’s the entire baseline. There’s one line you’ll add later, not now: when component tests arrive, a global afterEach(cleanup) gets registered here to tear down rendered DOM between tests. That belongs to the React Testing Library chapter, and adding it before jsdom exists would only error.
Keep fixtures out of here entirely, too. The tempting move is to build a reusable test invoice in the setup file so every test can grab it. Don’t: per-feature test data lives next to the code that uses it and gets imported where needed. The chapter on the /lib unit surface covers how to build that data cleanly. The only rule for this file is that such things don’t belong in it.
Running tests: vitest while you code, vitest run in CI
Section titled “Running tests: vitest while you code, vitest run in CI”There are two commands and two jobs, plus one mistake that can take a CI pipeline down. This distinction is worth committing to memory.
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. A few interactive keys are worth knowing while it’s running. Press p to filter by filename pattern, t to filter by test name, and q to quit. For a heavy debugging session, vitest --ui opens a browser dashboard (it needs the @vitest/ui package you’d add separately).
vitest run does a single pass and then exits. This is what continuous integration runs: it executes the suite once, reports, and returns an exit code the CI job can read as pass or fail.
CI eventually adds a reporter flag, vitest run --reporter=junit, so GitHub Actions can render the results, but wiring that up belongs to the CI chapter. For now the rule is simple: 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.
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'.
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.
Reveal card-by-card review
The whole API surface you’ll ever import
Section titled “The whole API surface you’ll ever import”One last thing, and it’s meant as reassurance more than instruction. The full set of names you import from 'vitest' across this entire course is small. That smallness is the Jest-compatibility payoff made concrete: there’s no sprawling API to memorize. Here’s the whole map, one line each.
Structure. describe groups related tests, and it is one test. it.skip parks a test, and it.only runs just that one while you focus on it. (Leave an it.only in a commit and you’ve silently disabled the rest of the file, which a lint rule catches in the CI chapter.)
Assertions. expect plus a handful of matchers you’ll reach for constantly: toBe for primitives and identity, toEqual for deep value equality, toContain for membership, toThrow for errors, and toMatchObject for “has at least these fields.” expect is typed, too, so comparing values of mismatched types is a compile error. A whole class of bugs gets caught while you’re writing the test rather than when it runs.
Lifecycle. beforeEach and afterEach run around every test, while beforeAll and afterAll run once around the whole file.
Test doubles. vi.fn() makes a mock function, vi.spyOn(obj, 'method') watches a real one, and vi.mock(specifier) replaces a whole module. These are test doubles , and the rules for using them well, including the order in which vi.mock runs, are the test-database chapter’s job.
That’s all of it. To see how little it takes, here’s a complete test, with every name it uses imported at the top and no ambient magic.
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); });});Imported names, one describe, one it, one expect. The whole runner you just configured exists to make that file fast to run and trustworthy when it passes. From here, the chapter turns from wiring to judgment: what shape the suite as a whole should take, and what makes a single test worth keeping.