Testing JavaScript with Vitest
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
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=1stops at the first failure and keeps the log short.--changedruns only tests affected by uncommitted or branch changes; handy locally, risky as the only CI signal.--shard=1/2and--shard=2/2split the suite across jobs once it gets slow.--retry=1is 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
vitestwatches,vitest runexits;defineConfigcomes fromvitest/config.toBechecks identity,toEqualchecks structure,toBeCloseTohandles floats,test.eachcovers input tables.- Await every async assertion; fake timers plus
advanceTimersByTimeAsyncreplace sleeps. vi.fn,vi.spyOnandvi.mockare 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.