Testing JavaScript with Vitest

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Node.js 22 LTS · Vitest 3

Code that runs once and code that still runs after every change are different problems. A test suite is not proof that you were right; it is a fast, boring signal that something broke. This chapter wires up Vitest and focuses on the parts people get wrong: which assertion to reach for, how to test async code without sleeping, and where a test double stops being useful.

Setup

Starting from the Node.js 22 LTS project from the previous chapter:

npm install -D vitest @vitest/coverage-v8

Add the scripts:

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

vitest starts in watch mode and re-runs on save. vitest run executes once and exits — that is the one CI must use, otherwise the job never finishes.

Put the config in vitest.config.js. Import defineConfig from vitest/config, not from vite; the test field only exists in the former:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["src/**/*.test.js"],
    environment: "node",
    clearMocks: true,
    restoreMocks: true,
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      thresholds: { lines: 80, functions: 80, branches: 70 },
    },
  },
});

environment: "node" suits pure logic. For DOM work switch to jsdom or happy-dom and install it as a dev dependency.

Vitest runs ES modules and TypeScript without extra transpiler configuration, so a "type": "module" project needs no Babel or ts-jest equivalent.

The first test

The subject is a small price formatter with one deliberate guard:

// src/money.js
export function formatPrice(cents, currency = "USD") {
  if (!Number.isInteger(cents)) {
    throw new TypeError("cents must be an integer");
  }
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  }).format(cents / 100);
}

The test file sits next to it:

// src/money.test.js
import { describe, expect, it } from "vitest";
import { formatPrice } from "./money.js";

describe("formatPrice", () => {
  it("formats integer cents as currency", () => {
    expect(formatPrice(1999)).toBe("$19.99");
  });

  it("groups thousands", () => {
    expect(formatPrice(100000)).toBe("$1,000.00");
  });

  it("rejects non-integer input", () => {
    expect(() => formatPrice(19.9)).toThrow(TypeError);
  });
});

describe groups, it (alias of test) states one behaviour, expect asserts. Run it with npm run test:run. A failure prints the expected and received values plus the source line.

Importing describe, it and expect explicitly is the default and the better habit: no globals, no extra types, and the test file still reads fine on its own. globals: true exists for Jest migration, not as a target.

Choosing an assertion

The toBe versus toEqual mistake is the most common one:

Assertion Comparison Use for
toBe Object.is, identity numbers, strings, booleans, the same object
toEqual recursive, ignores undefined properties objects, arrays, plain data
toStrictEqual recursive, also checks undefined keys and prototypes exact structural equality
toMatchObject subset only large results where a few fields matter
toBeCloseTo approximate float comparison decimal arithmetic
toThrow thrown error or message substring validation and failure paths
expect({ a: 1 }).toEqual({ a: 1 });        // passes
expect({ a: 1 }).toBe({ a: 1 });           // fails: two different references
expect(0.1 + 0.2).toBeCloseTo(0.3);        // passes
expect({ id: 1, name: "Ada" }).toMatchObject({ id: 1 }); // passes

When the same logic needs several input/output pairs, test.each beats ten copy-pasted it blocks:

it.each([
  [0, "$0.00"],
  [100, "$1.00"],
  [1999, "$19.99"],
])("formatPrice(%i) => %s", (cents, expected) => {
  expect(formatPrice(cents)).toBe(expected);
});

%i and %s are replaced in order, so a failure names the exact row.

For a long assertion list where you want every failure instead of the first, expect.soft collects them and reports at the end of the test.

Testing async code

Await the promise and assert through resolves / rejects:

// src/users.js
export async function loadUser(fetchImpl, id) {
  const res = await fetchImpl(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}
import { expect, it, vi } from "vitest";
import { loadUser } from "./users.js";

it("returns parsed JSON", async () => {
  const fakeFetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: "Ada" }),
  });

  await expect(loadUser(fakeFetch, 1)).resolves.toEqual({ id: 1, name: "Ada" });
  expect(fakeFetch).toHaveBeenCalledWith("/api/users/1");
});

it("throws on a non-2xx response", async () => {
  const fakeFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });

  await expect(loadUser(fakeFetch, 1)).rejects.toThrow("HTTP 503");
});

Do not drop the await. Without it the assertion never runs before the test ends, and a failing case reports as passing. That kind of false green is the most expensive bug in a suite.

For timers, retries and debounce, use fake timers instead of real waiting:

import { afterEach, beforeEach, expect, it, vi } from "vitest";

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

it("retries only after the backoff delay", async () => {
  const task = retryOnce(fetchOnce, { delayMs: 1000 });

  await vi.advanceTimersByTimeAsync(1000);

  await expect(task).resolves.toBe("ok");
});

advanceTimersByTimeAsync flushes the microtasks produced by the timer callbacks, which is what you need when the callback awaits something. The synchronous advanceTimersByTime stops too early in that case.

Fake timers also freeze Date, so a test that formats “today” needs vi.setSystemTime(new Date("2026-01-15T00:00:00Z")) to stay deterministic.

Test doubles: fn, spy, mock

Three levels, three use cases:

// 1. A standalone function that records calls
const onClick = vi.fn();
onClick("save");
expect(onClick).toHaveBeenCalledTimes(1);

// 2. Replace one method, keep the object
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
doSomething();
expect(spy).toHaveBeenCalled();
spy.mockRestore();

// 3. Replace a whole module
vi.mock("./mailer.js", () => ({
  sendMail: vi.fn().mockResolvedValue({ queued: true }),
}));

vi.mock is hoisted above the imports, so the factory cannot close over module-level variables:

// Wrong: sendMock is still uninitialized when the factory runs
const sendMock = vi.fn();
vi.mock("./mailer.js", () => ({ sendMail: sendMock }));

// Right: hoist the variable together with the factory
const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() }));
vi.mock("./mailer.js", () => ({ sendMail: sendMock }));

Doubles leak between tests unless you clean them up. Either enable the config from earlier (clearMocks, restoreMocks) or call vi.restoreAllMocks() in afterEach. clearMocks only resets call history; restoreMocks also puts the original method back — most projects want both.

For stateful setups, test.extend builds a fixture once per test instead of repeating three lines of beforeEach in every file:

const it = test.extend({
  db: async ({}, use) => {
    const db = await createTestDb();
    await use(db);
    await db.close();
  },
});

it("stores a user", async ({ db }) => {
  await db.users.insert({ name: "Ada" });
  expect(await db.users.count()).toBe(1);
});

Coverage: a trend, not a target

npm run test:coverage

The v8 provider reads V8’s built-in coverage and needs no instrumentation, which makes it faster; istanbul instruments the source and covers a few edge cases the V8 data does not. The terminal prints line, branch and function percentages, and the html reporter writes coverage/index.html with red/yellow/green branch highlighting.

Thresholds live under coverage.thresholds. Below them the command exits non-zero, which is what makes coverage useful in CI. Coverage still says nothing about assertion quality:

// 100% covered, nothing verified
it("does not throw", () => {
  expect(() => formatPrice(1999)).not.toThrow();
});

Write the boundary and failure cases first, then look at the number. Ignoring a genuinely unreachable branch is fine — /* v8 ignore next */ documents the decision instead of adding a test that only exists to raise a metric.

Running it in CI

One command, no watch:

- run: npm ci
- run: npx vitest run --reporter=dot

A few flags worth knowing:

  • --bail=1 stops at the first failure and keeps the log short.
  • --changed runs only tests affected by uncommitted or branch changes; handy locally, risky as the only CI signal.
  • --shard=1/2 and --shard=2/2 split the suite across jobs once it gets slow.
  • --retry=1 is for genuinely flaky infrastructure, not for hiding an unstable test.

Locally, npm test for watch mode and npm run test:run before pushing, alongside lint and build.

Common mistakes

1. Comparing objects with toBe

Same shape, different reference, guaranteed failure. Use toEqual.

2. Forgetting await on an async assertion

expect(promise).resolves without await finishes the test before the assertion runs, and failures disappear. The way to catch this is to break the code on purpose and confirm the test goes red.

3. Closing over variables inside vi.mock

The hoisted factory runs before module initialization and throws Cannot access before initialization. Use vi.hoisted or inline the value.

4. Leaving fake timers on

Without vi.useRealTimers() the next test’s setTimeout never fires, which surfaces as a random timeout in an unrelated file.

5. Shared state between tests

Module-level arrays, singletons and open connections make results order-dependent. Build state per test in beforeEach.

6. Asserting on implementation details

Checking that a private helper was called makes every refactor red. Assert on observable input/output instead.

7. Only testing the happy path

Empty arrays, null, timeouts and thrown errors are where bugs live. Every throw deserves at least one test.

Summary

  • vitest watches, vitest run exits; defineConfig comes from vitest/config.
  • toBe checks identity, toEqual checks structure, toBeCloseTo handles floats, test.each covers input tables.
  • Await every async assertion; fake timers plus advanceTimersByTimeAsync replace sleeps.
  • vi.fn, vi.spyOn and vi.mock are different tools; mock factories are hoisted, so hoist their variables too.
  • Coverage is a trend line. Enforce thresholds in CI, but write the failure cases first.

Next: put the tooling, async and testing pieces together in a real Node.js CLI tool.