Skip to content
beginner Phase 13 · Testing Next.js Applications

Unit Testing Utilities

Test pure functions and helpers with a fast runner.

45m
0 problems
Topic Progress 0%

Wiring Vitest into a Next.js Repo Without Fighting Your tsconfig

You pulled a bug-fix branch and found that the pricing logic in lib/cart.ts has no tests. You want a failing test running before standup ends. Jest was the default answer for years, but Vitest is the practical choice in a modern Next.js repo: it executes your TypeScript and ESM files directly through the Vite pipeline, so there is no babel config, no ts-jest transform to tune, and a watch-mode rerun lands in tens of milliseconds instead of seconds.

Install the runner and a coverage provider:

npm install -D vitest @vitest/coverage-v8

A minimal vitest.config.ts is enough to start:

import { defineConfig } from 'vitest/config'
import path from 'node:path'

export default defineConfig({
  resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
  test: {
    environment: 'node',
    globals: true,
    include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
    coverage: { provider: 'v8', reporter: ['text', 'lcov'] },
  },
})

Two lines there decide whether your first test even starts. Vitest does not read next.config.js, so the @/ alias that resolves everywhere in Next must be restated in resolve.alias — or you install vite-tsconfig-paths and let it import every alias from tsconfig.json. Skip that and the run dies with Cannot find module '@/lib/cart' before a single assertion executes.

The other line is environment, and unit tests should stay on node:

Environment What it gives you Right for
node bare Node globals, no DOM helpers, mappers, schema logic
jsdom document, window, DOM events component tests — the next topic
happy-dom a lighter DOM, faster large component suites trading fidelity for speed

How Vitest actually differs from Jest, because interviewers do ask:

Concern Jest Vitest
TypeScript / ESM needs babel or ts-jest configuration executed natively by Vite
Config home separate jest.config.js often shares the Vite config
Test API jest.fn(), jest.mock() vi.fn(), vi.mock() — Jest-shaped, standard with globals: true
Watch rerun transform cache, still warm-up latency near-instant, HMR-style

Add three scripts to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

vitest with no subcommand watches and reruns on every save. vitest run is the only form CI may call — a watch process in a CI job hangs until the pipeline times out, which is a memorable way to learn the difference.

Two placement rules keep the suite out of Next's way. First, put tests beside the code they cover (lib/cart.test.ts) or under src/__tests__/, never inside app/. The App Router treats files in a route segment as part of that route's graph, and stray test files get type-checked — and in the worst case bundled — as route code. Second, keep the node environment here: the moment you reach for document or screen, you have crossed into component testing, where jsdom and React Testing Library take over.

Finally, wire it into the editor. Vitest's watch mode plus the VS Code extension gives you red/green feedback while you type, and that loop is the real reason to care about speed: a unit suite that finishes in under a second runs on every save; one that takes a minute runs in CI on Fridays. The configuration work above is what buys that second. Once the runner is green, the interesting question begins — which code deserves a test at all.

Which Code Deserves a Unit Test: Picking Targets That Catch Regressions

In a code review nobody asks whether the repo has tests. They ask which layer a new test belongs to. A unit test earns its place when the code under test is a pure function: same input, same output, no network, no DOM, no database handle. In a Next.js app that rule still covers most of the code that actually breaks in production.

The high-yield targets, in the order they usually bite:

  • Money and rounding. Cart totals with percentage or fixed discounts, tax splitting, prorations. Floating-point arithmetic mixed with business rules is where e-commerce bugs ship.
  • Mappers. A row like { created_at: Date, total_cents: 4200 } becomes { createdAt: 'Jan 3, 2026', total: '$42.00' }. Schema drift breaks the mapping silently at render time; a unit test catches it at commit time.
  • Validation schemas. The zod schema shared between a client form (you built those in Complex Forms with react-hook-form) and a server action has exactly two branches, accept and reject, and both must be pinned — including the error path the form renders.
  • URL state round-trips. Filters serialized into a query string and parsed back out. When the serializer and parser drift apart you get "filters reset on refresh" bugs that no type checker sees.
  • Authorization decisions. canEdit(order, user) is a boolean over a decision table: cheap to test exhaustively, expensive to get wrong.

Pick the layer before you pick the assertion:

Code under test Correct layer Harness
Pure helper, schema, mapper unit Vitest, environment: 'node'
React component behavior component Vitest + Testing Library (next topic)
Route handler API test handler invoked with a constructed Request
Server action direct invocation action called with a FormData
Multi-page journey, login end-to-end Playwright, closing this phase

A test that names the behavior instead of the function:

import { describe, it, expect } from 'vitest'
import { applyDiscount } from './cart'

describe('applyDiscount', () => {
  it('rounds fractional cents down so the store never undercharges', () => {
    expect(applyDiscount({ subtotal: 19.99, percent: 15 })).toBe(16.99)
  })

  it('returns 0 for an empty cart instead of NaN', () => {
    expect(applyDiscount({ subtotal: 0, percent: 20 })).toBe(0)
  })

  it('rejects a discount above 100% rather than paying the customer', () => {
    expect(() => applyDiscount({ subtotal: 10, percent: 150 })).toThrow(/discount/)
  })
})

Read the imports: no React, no fetch, no @/lib/db. That absence is the point. If a test needs a running server, a seeded row, or a browser before it can assert, you have not failed — you have just identified that the code belongs to a higher layer, and forcing it down produces the slow, brittle suite that developers stop running within a month.

Anti-targets, so you don't lose an afternoon:

  • React components. A shallow render here tells you the markup changed, not that the feature works. Rendering is the next topic's job.
  • Anything under app/. Route modules pull in server-only imports; loading them in the test runner throws or drags Next's renderer into your unit suite.
  • Third-party behavior. Asserting that React invokes your onChange handler tests React. Guard your own decisions, not the framework's.
  • Snapshot files as a substitute for assertions. A snapshot records markup; it cannot tell you a disabled button now submits anyway.

"How do you decide what to unit test?" is a standard interview question. Answer in three moves: a rule (purity plus regression cost), a counter-example (UI needs component tests, not unit tests), and an escape hatch (journeys move to end-to-end). That structure shows you see the whole testing pyramid rather than one rung — and the rung directly above this one is Component Tests with Testing Library, where the DOM becomes the subject under test.

Faking Time, Config, and Dependencies with vi Without Lying to Yourself

Three things pollute otherwise pure functions: configuration, the clock, and I/O. Instead of surrendering those code paths to slow integration tests, Vitest lets you fake each one. Where the fakes belong is the difference between a suite that catches regressions and a suite that just mirrors its own mocks.

Configuration. Never assign an environment variable by hand and hope cleanup runs:

import { it, expect, vi, afterEach } from 'vitest'

afterEach(() => vi.unstubAllEnvs())

it('formats euros when NEXT_PUBLIC_CURRENCY is EUR', () => {
  vi.stubEnv('NEXT_PUBLIC_CURRENCY', 'EUR')
  expect(formatPrice(1234.5)).toBe('€1,234.50')
})

vi.stubEnv patches both process.env and import.meta.env, which matters because NEXT_PUBLIC_ values are read through import.meta.env after Vite inlines them. A raw process.env.X = 'EUR' survives past the test that set it, and with Vitest's file isolation turned off — or a single-worker CI run — that stale value reaches a test that never intended to see it. The symptom is a failure that reproduces only on the full suite, never when the file runs alone.

The clock. A rule like "quotes expire after 15 minutes" is untestable against wall-clock time:

it('expires a quote once 15 minutes pass', () => {
  vi.useFakeTimers()
  vi.setSystemTime(new Date('2026-01-03T10:00:00Z'))
  const issued = Date.now()
  vi.setSystemTime(issued + 16 * 60_000)
  expect(isQuoteValid(issued)).toBe(false)
  vi.useRealTimers()
})

Restore in afterEach, and know the sharp edge: fake timers replace setTimeout, not promises. When the code awaits a delay (await sleep(50)), the synchronous vi.advanceTimersByTime(50) fires the callback and queues the promise continuation as a microtask — your assertion on the next line still sees the pending value. await vi.advanceTimersByTimeAsync(50) flushes timers and microtasks in order, which is what async code under test actually requires.

Modules. vi.mock swaps an import for every test in the file:

vi.mock('@/lib/db', () => ({
  findOrder: vi.fn(),
  createOrder: vi.fn(),
}))

Rules that keep the doubles honest:

  • Mock at the boundary — network, database, filesystem — never the module under test.
  • vi.mock is hoisted above your imports, so a factory that closes over a top-level const throws Cannot access 'db' before initialization. Build the doubles with vi.hoisted(() => ({ findOrder: vi.fn() })).
  • Use vi.importActual for partial mocks: keep the real implementation and wrap one export, such as a logger inside an otherwise real module.
  • Assert observable outcomes when you can. expect(result.items).toHaveLength(2) survives refactors; expect(findOrder).toHaveBeenCalledTimes(1) breaks the moment someone batches a query.
Situation Tool Restore with
Env var read at call time vi.stubEnv vi.unstubAllEnvs()
Date-bound logic vi.useFakeTimers + setSystemTime vi.useRealTimers()
Whole module at the I/O edge vi.mock factory scoped to that file
One method on a live object vi.spyOn(obj, 'method') mockRestore()

Two habits automate the rest. Put vi.clearAllMocks() in beforeEach so call counts and recorded arguments never bleed from one test into the next, and reserve vi.resetModules() for modules that keep state across imports — a cached database client or a singleton config object is the usual reason two tests disagree about what was initialized. Neither replaces the restore table above; they complement it.

Speed is the whole argument for this layer. A faked unit suite finishes in under a second, which is why developers run it on every save instead of trusting CI to notice things overnight. When a fake needs setup, teardown, and a paragraph of documentation to stay correct, the test has become an integration test wearing a costume — move it up a layer instead. The layer directly above is Component Tests with Testing Library, where React and a real DOM replace your mocks with something closer to what ships.

Quiz

1. Your test fails with `Cannot find module '@/lib/formatPrice'` even though the same import works across the app. What is the correct fix?

Question 1 options

2. Which of these tests belongs in Vitest's `node` environment rather than `jsdom`?

Question 2 options

3. A test sets `process.env.API_URL` directly, and a later test in the same file still observes the old value. What is the standard fix?

Question 3 options

4. The helper under test awaits a `setTimeout` before resolving. Your test calls `vi.advanceTimersByTime(1000)` synchronously and still observes the pending value. What should you do?

Question 4 options

Flashcards

Question

Why must the `@/` path alias be declared again in vitest.config.ts when Next.js already resolves it?

Answer

Vitest runs on Vite and does not read next.config.js or tsconfig paths by itself, so aliases must appear in `resolve.alias` (or be supplied by the vite-tsconfig-paths plugin). Without that, tests fail immediately with `Cannot find module '@/...'`.

Question

Which Vitest environment should a pure-helper unit test use, and what signals that you picked the wrong one?

Answer

`node` — no DOM globals. You picked the wrong environment the moment the test references `document`, `window`, or queries elements: that is jsdom territory and belongs to component testing, where the extra DOM machinery would only slow the unit layer down.

Question

How do you change an environment variable for a single test without leaking it into others?

Answer

Use `vi.stubEnv('NAME', value)` and restore with `vi.unstubAllEnvs()` (typically in afterEach). It patches both process.env and import.meta.env and reverts automatically; a raw assignment persists into later tests and, with isolation off or a single CI worker, into other files.

Question

What goes wrong when a test advances fake timers synchronously while the code under test awaits a delay?

Answer

The timer callback fires, but the promise continuation is a microtask that has not run when the assertion executes, so the value still looks pending. Fix it with `await vi.advanceTimersByTimeAsync(ms)`, which flushes timers and microtasks in order; restore real timers with `vi.useRealTimers()`.

Question

Why does a factory passed to `vi.mock` fail when it references a top-level variable, and what is the fix?

Answer

vi.mock is hoisted above your imports, so top-level consts are still in the temporal dead zone when the factory runs — you get `Cannot access 'db' before initialization`. Build the doubles inside `vi.hoisted(() => ({ findOrder: vi.fn() }))`, which is hoisted along with the mock.

Revision Notes

Key Takeaways

  • 1. Vitest executes TypeScript and ESM natively through Vite, but it reads neither next.config.js nor jest.config.js — restate the `@/` alias in `resolve.alias` or use vite-tsconfig-paths
  • 2. Choose the layer before the assertion: pure helpers stay in the `node` environment; reaching for `document` means the code belongs to component testing
  • 3. Fakes are what keep the unit layer fast enough to run on every save — `vi.stubEnv` for config, `vi.useFakeTimers` for time, `vi.mock` at the network/database/filesystem boundary
  • 4. Always restore automatically: `vi.unstubAllEnvs()`, `vi.useRealTimers()`, `vi.spyOn(...).mockRestore()` in `afterEach`, because order-dependent tests fail only in the full CI run
  • 5. Run `vitest run` in CI and `vitest` in development — a watch process in CI hangs the job, and test files must live outside `app/` so they never enter a route's module graph

Interview Tips

  • • "How do you decide what to unit test?" — give a rule (pure function, high regression cost), a counter-example (UI needs component tests), and an escape hatch (multi-page flows go end-to-end); the three-part answer shows you see the whole pyramid
  • • "Vitest or Jest — why?" — name concrete differences: native TS/ESM via Vite, shared config with your build tool, `vi` API that mirrors Jest's, and near-instant watch reruns; then say Jest remains fine in an existing `next/jest` repo
  • • "Your test passes alone but fails in the full suite — what happened?" — reach for leakage first: an unrestored env var, a mutated module singleton, or fake timers left on; ask whether files shared a worker and whether cleanup runs in `afterEach`
  • • "What is the cost of mocking too much?" — the suite starts asserting the mock's behavior instead of yours; mock only the I/O boundary, prefer behavior assertions over call counts, and move a heavily-faked test up to the API or integration layer

Cheat Sheet

Unit Testing Utilities — Cheat Sheet

Install & run

npm i -D vitest @vitest/coverage-v8
npx vitest          # watch, reruns on save (dev only)
npx vitest run      # single pass — what CI calls
npx vitest run --coverage

Config that must be right before the first test passes

// vitest.config.ts
resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, // Next's alias, restated
test: { environment: 'node', globals: true, include: ['src/**/*.test.ts'] }

Layer selection

Code under test Layer Harness
Pure helper, schema, mapper unit Vitest node
Component behavior component Testing Library + jsdom
Route handler API handler + constructed Request
Server action action direct call with FormData
Multi-page flow E2E Playwright

Faking the impure parts

Situation Tool Restore
Env var vi.stubEnv vi.unstubAllEnvs()
Clock / delays vi.useFakeTimers, setSystemTime vi.useRealTimers()
Module at the I/O edge vi.mock factory file-scoped
Single method vi.spyOn(obj, 'm') mockRestore()
Async delay under fake timers await vi.advanceTimersByTimeAsync(ms) —

Rules of thumb: mock at the boundary, never the module under test · hoisted factories need vi.hoisted · assert behavior, not call counts · test files never live in app/ · CI runs vitest run, never watch mode.

Continue Learning