JavaScript · lesson 27 of 32
Beginner
13- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Regular Expressions in JavaScript
- 10 Arrays in JavaScript
- 11 Objects in JavaScript
- 12 Map, Set, WeakMap, and WeakSet in JavaScript
- 13 JSON in JavaScript: stringify, parse, and What Gets Lost
Intermediate
15- 14 Error Handling in JavaScript
- 15 Dates and Times in JavaScript: Date Pitfalls and Temporal
- 16 Object-Oriented Programming in JavaScript
- 17 Prototypes and Prototype Chain in JavaScript
- 18 Closures in JavaScript
- 19 this, call, apply, and bind in JavaScript
- 20 JavaScript Event Loop Explained
- 21 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 22 Iterators and Generators in JavaScript
- 23 JavaScript Modules: ESM and CommonJS
- 24 DOM Manipulation in JavaScript
- 25 Browser Web APIs in JavaScript
- 26 Files, Streams, and the Command Line in Node.js
- 27 Testing JavaScript with Jest and Vitest
- 32 JavaScript Interview Preparation: Top 35 Questions
Advanced
4Also part of JavaScript
1Also part of: Software Testing React
Testing JavaScript with Jest and Vitest
Learn how to write reliable unit tests in JavaScript using Jest and Vitest — from basic assertions to mocking, async tests, and code coverage.
By SkillByExample · Updated
A test is a small program that runs your code and checks the result, so a machine — not your memory — notices when behaviour changes. The examples use Vitest, whose API matches Jest’s almost exactly (vi.fn() ↔ jest.fn()). Every output block below is a real npx vitest run in a project where npm install --save-dev vitest has been run.
The testing pyramid is a useful budget: many fast unit tests, fewer integration tests, very few end-to-end tests. This lesson is about the unit-test layer.
describe / it / expect
describe groups tests, it names one behaviour, expect(...) asserts.
// math.js
export function add(a, b) { return a + b; }
export function divide(a, b) {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
// math.test.js
import { describe, it, expect } from "vitest";
import { add, divide } from "./math.js";
describe("add", () => {
it("adds two positive numbers", () => expect(add(2, 3)).toBe(5));
it("handles negative numbers", () => expect(add(-1, -1)).toBe(-2));
});
describe("divide", () => {
it("divides", () => expect(divide(10, 4)).toBe(2.5));
it("throws on division by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
});
$ npx vitest run basics --reporter=verbose
RUN v5.0.3 /root/my-project
✓ basics/math.test.js > add > adds two positive numbers 2ms
✓ basics/math.test.js > add > handles negative numbers 0ms
✓ basics/math.test.js > divide > divides 0ms
✓ basics/math.test.js > divide > throws on division by zero 1ms
Test Files 1 passed (1)
Tests 4 passed (4)
Start at 18:29:40
Duration 211ms (transform 47%, import 37%, tests 9%, worker 7%)
--reporter=verbose lists every test by its describe › it path, which is why test names should read as sentences about behaviour. Note toThrow takes a function — expect(divide(10, 0)) would throw before expect ever ran.
Matchers, and What a Failure Looks Like
Picking the right matcher gives a precise failure message. This file has one deliberately wrong assertion.
// matchers.test.js
import { it, expect } from "vitest";
it("passes with the right matchers", () => {
expect({ a: 1, b: [2] }).toEqual({ a: 1, b: [2] });
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect("hello world").toMatch(/world/);
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
expect({ id: 1, name: "Alice", role: "admin" }).toMatchObject({ name: "Alice" });
expect(null).toBeNull();
expect(() => JSON.parse("{")).toThrow(SyntaxError);
});
it("fails: toBe on objects compares identity", () => {
expect({ a: 1 }).toBe({ a: 1 });
});
$ npx vitest run matchers
RUN v5.0.3 /root/my-project
❯ matchers/matchers.test.js (2 tests | 1 failed) 13ms
× fails: toBe on objects compares identity 6ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL matchers/matchers.test.js > fails: toBe on objects compares identity
AssertionError: expected { a: 1 } to be { a: 1 } // Object.is equality
If it should pass with deep equality, replace "toBe" with "toStrictEqual"
Expected: { a: 1 }
Received: serializes to the same string
Compared values have no visual difference.
❯ matchers/matchers.test.js:16:20
14|
15| it("fails: toBe on objects compares identity", () => {
16| expect({ a: 1 }).toBe({ a: 1 });
| ^
17| });
18|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
Start at 18:29:41
Duration 231ms (transform 40%, import 25%, tests 24%, worker 11%)
toBe is Object.is — identity for objects — so two separate { a: 1 } objects fail even though they look equal; Vitest even suggests toStrictEqual. Use toBe for primitives and toEqual/toStrictEqual for structures. toBeCloseTo exists because 0.1 + 0.2 is not exactly 0.3. The failure report shows the assertion, a diff and the source line — this is why specific matchers beat expect(x === y).toBe(true), whose failure would only say “expected false to be true”.
Mocking a Module
To test code in isolation, replace its dependencies. vi.mock swaps a whole module for auto-mocked functions.
// service.js
export async function fetchUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
// greeter.js
import { fetchUser } from "./service.js";
export async function greetUser(id) {
const user = await fetchUser(id);
return `Hello, ${user.name}!`;
}
// greeter.test.js
import { describe, it, expect, vi, beforeEach } from "vitest";
import { greetUser } from "./greeter.js";
import { fetchUser } from "./service.js";
vi.mock("./service.js");
beforeEach(() => vi.resetAllMocks());
describe("greetUser", () => {
it("greets the user by name", async () => {
fetchUser.mockResolvedValue({ id: 1, name: "Alice" });
await expect(greetUser(1)).resolves.toBe("Hello, Alice!");
expect(fetchUser).toHaveBeenCalledWith(1);
expect(fetchUser).toHaveBeenCalledTimes(1);
});
it("propagates fetch errors", async () => {
fetchUser.mockRejectedValue(new Error("Network error"));
await expect(greetUser(1)).rejects.toThrow("Network error");
});
});
$ npx vitest run mocking --reporter=verbose
RUN v5.0.3 /root/my-project
✓ mocking/greeter.test.js > greetUser > greets the user by name 4ms
✓ mocking/greeter.test.js > greetUser > propagates fetch errors 2ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 18:29:42
Duration 241ms (transform 56%, import 28%, tests 11%, worker 5%)
No network request was made: vi.mock is hoisted above the imports, so greeter.js received the mocked fetchUser. Each test sets its own behaviour, and vi.resetAllMocks() in beforeEach stops call counts and return values leaking from one test into the next. Mock at boundaries you don’t control — network, filesystem, clock — and let your own code run for real.
vi.fn and vi.spyOn
vi.fn() makes a standalone function that records its calls; vi.spyOn wraps a method on an existing object.
// fns.test.js
import { it, expect, vi } from "vitest";
import fs from "node:fs";
const processItems = (items, keep) => items.filter((item) => keep(item));
const loadConfig = (path) => JSON.parse(fs.readFileSync(path, "utf8"));
it("records every call to a callback", () => {
const keep = vi.fn((item) => item > 2);
expect(processItems([1, 2, 3, 4], keep)).toEqual([3, 4]);
expect(keep).toHaveBeenCalledTimes(4);
expect(keep).toHaveBeenNthCalledWith(1, 1);
expect(keep.mock.results.map((r) => r.value)).toEqual([false, false, true, true]);
});
it("replaces fs.readFileSync for one test", () => {
const spy = vi.spyOn(fs, "readFileSync").mockReturnValue('{ "port": 3000 }');
expect(loadConfig("/etc/app/config.json").port).toBe(3000);
expect(spy).toHaveBeenCalledWith("/etc/app/config.json", "utf8");
spy.mockRestore();
expect(() => loadConfig("/etc/app/config.json")).toThrow("ENOENT");
});
$ npx vitest run fns --reporter=verbose
RUN v5.0.3 /root/my-project
✓ fns/fns.test.js > records every call to a callback 4ms
✓ fns/fns.test.js > replaces fs.readFileSync for one test 1ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 18:29:44
Duration 235ms (import 40%, transform 37%, tests 12%, worker 10%)
keep.mock.results shows what every call returned — handy when a callback misbehaves. The spy test never touched the disk: /etc/app/config.json doesn’t exist, as the last assertion proves once mockRestore() puts the real readFileSync back. Forgetting mockRestore would leave the fake in place for later tests in the same file.
Async Code and Fake Timers
Return or await every promise you assert on. For time-based code, fake timers let a test “wait” 300 ms instantly.
// async.test.js
import { it, expect, vi } from "vitest";
const validateEmail = async (s) => { if (!s.includes("@")) throw new Error("Invalid email"); return s; };
function debounce(fn, delay) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); };
}
it("awaits resolves/rejects", async () => {
await expect(validateEmail("a@b.co")).resolves.toBe("a@b.co");
await expect(validateEmail("nope")).rejects.toThrow("Invalid email");
});
it("debounce fires once, after the delay", () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced("a"); debounced("b"); debounced("c");
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(299);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledExactlyOnceWith("c");
vi.useRealTimers();
});
$ npx vitest run async --reporter=verbose
RUN v5.0.3 /root/my-project
✓ async/async.test.js > awaits resolves/rejects 3ms
✓ async/async.test.js > debounce fires once, after the delay 4ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 18:29:45
Duration 198ms (transform 45%, import 28%, tests 18%, worker 9%)
The fake-timer test checks the boundary exactly: nothing at 299 ms, one call with the last argument at 300 ms — and it ran in milliseconds instead of waiting.
The un-awaited assertion
Forgetting await is the classic async-test bug. Here are two versions of it:
// forgot-await.test.js
import { it, expect } from "vitest";
const saveUser = (name) =>
new Promise((resolve) => setTimeout(() => resolve({ name: name.toUpperCase() }), 50));
it("BUG 1: un-awaited .resolves", () => {
expect(saveUser("bob")).resolves.toEqual({ name: "bob" });
});
it("BUG 2: assertion inside an un-awaited .then", () => {
saveUser("bob").then((user) => expect(user.name).toBe("bob"));
});
$ npx vitest run pitfall --reporter=verbose
RUN v5.0.3 /root/my-project
× pitfall/forgot-await.test.js > BUG 1: un-awaited .resolves 63ms
→ expected { name: 'BOB' } to deeply equal { name: 'bob' }
→ Promise returned by `expect(actual).resolves.toEqual(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes:
await expect(actual).resolves.toEqual(expected)
✓ pitfall/forgot-await.test.js > BUG 2: assertion inside an un-awaited .then 0ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL pitfall/forgot-await.test.js > BUG 1: un-awaited .resolves
AssertionError: expected { name: 'BOB' } to deeply equal { name: 'bob' }
- Expected
+ Received
{
- "name": "bob",
+ "name": "BOB",
}
❯ pitfall/forgot-await.test.js:8:26
6|
7| it("BUG 1: un-awaited .resolves", () => {
8| expect(saveUser("bob")).resolves.toEqual({ name: "bob" });
| ^
9| });
10|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
FAIL pitfall/forgot-await.test.js > BUG 1: un-awaited .resolves
Error: Promise returned by `expect(actual).resolves.toEqual(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes:
await expect(actual).resolves.toEqual(expected)
❯ pitfall/forgot-await.test.js:8:26
6|
7| it("BUG 1: un-awaited .resolves", () => {
8| expect(saveUser("bob")).resolves.toEqual({ name: "bob" });
| ^
9| });
10|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
Start at 18:29:46
Duration 347ms (tests 58%, transform 24%, import 12%, worker 6%)
Both assertions are wrong — saveUser upper-cases the name — but only the first test failed. Vitest 5 tracks expect(...).resolves/.rejects promises and fails a test that returns without awaiting one. The second test passed: the test function returned immediately, and by the time the .then callback ran its failing expect, nothing was listening. Any assertion inside a callback must be reached through an await (or a returned promise): const user = await saveUser("bob"); expect(user.name).toBe("bob");.
Setup and Teardown
beforeAll/afterAll run once per describe block; beforeEach/afterEach wrap every test.
// hooks.test.js
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
const log = [];
describe("users table", () => {
let db;
beforeAll(() => { db = new Map(); log.push("open db"); });
afterAll(() => { log.push("close db"); console.log(log.join(" -> ")); });
beforeEach(() => { db.set("seed", { name: "Seed" }); log.push("seed"); });
afterEach(() => { db.clear(); log.push("truncate"); });
it("inserts a user", () => {
db.set("alice", { name: "Alice" });
expect(db.size).toBe(2);
});
it("starts from a clean table", () => {
expect([...db.keys()]).toEqual(["seed"]);
});
});
$ npx vitest run hooks --reporter=verbose
RUN v5.0.3 /root/my-project
stdout | hooks/hooks.test.js > users table
open db -> seed -> truncate -> seed -> truncate -> close db
✓ hooks/hooks.test.js > users table > inserts a user 2ms
✓ hooks/hooks.test.js > users table > starts from a clean table 2ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 18:29:47
Duration 209ms (transform 47%, import 28%, tests 15%, worker 10%, environment 1%)
The log shows the order: one open, then seed → test → truncate around each test, then one close. The second test sees only the seed row even though the first test inserted Alice — that isolation is what lets tests run in any order. (Vitest shows console.log output from tests under a stdout | label.)
Snapshot Tests
A snapshot saves a value’s serialised form on the first run and fails if it later changes.
// card.js
export const renderUserCard = ({ name, role }) =>
`<div class="card">\n <h2>${name}</h2>\n <span class="badge">${role}</span>\n</div>`;
// card.test.js
import { it, expect } from "vitest";
import { renderUserCard } from "./card.js";
it("renders the user card", () => {
expect(renderUserCard({ name: "Alice", role: "admin" })).toMatchSnapshot();
});
$ npx vitest run snap && cat snap/__snapshots__/card.test.js.snap
RUN v5.0.3 /root/my-project
Snapshots 1 written
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 18:29:48
Duration 253ms (transform 51%, import 28%, worker 13%, tests 8%)
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`renders the user card 1`] = `
"<div class="card">
<h2>Alice</h2>
<span class="badge">admin</span>
</div>"
`;
The first run wrote the snapshot file instead of comparing. Commit it with the test. Now change the markup and run again:
$ sed -i 's/class="badge"/class="pill"/' snap/card.js && npx vitest run snap
RUN v5.0.3 /root/my-project
❯ snap/card.test.js (1 test | 1 failed) 11ms
× renders the user card 9ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL snap/card.test.js > renders the user card
Error: Snapshot `renders the user card 1` mismatched
- Expected
+ Received
"<div class="card">
<h2>Alice</h2>
- <span class="badge">admin</span>
+ <span class="pill">admin</span>
</div>"
❯ snap/card.test.js:6:60
4|
5| it("renders the user card", () => {
6| expect(renderUserCard({ name: "Alice", role: "admin" })).toMatchSnap…
| ^
7| });
8|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Snapshots 1 failed
Test Files 1 failed (1)
Tests 1 failed (1)
Start at 18:29:49
Duration 255ms (transform 42%, import 34%, tests 14%, worker 9%)
The diff pinpoints the change. If it was intended, npx vitest run -u rewrites the snapshot. That’s also the weakness: a snapshot says something changed, not whether the change is correct, and it’s tempting to press -u without reading. Keep snapshots small and review their diffs like code.
Coverage
Coverage reports which lines and branches your tests executed. It finds untested code; it can’t tell you the tests are good. (npm install --save-dev @vitest/coverage-v8 first.)
// shipping.js
export function shippingCost(total, country) {
if (total >= 100) return 0;
if (country === "US") return 5;
if (country === "CA") return 8;
return 15;
}
// shipping.test.js
import { it, expect } from "vitest";
import { shippingCost } from "./shipping.js";
it("free over 100", () => expect(shippingCost(150, "US")).toBe(0));
it("US rate", () => expect(shippingCost(20, "US")).toBe(5));
$ npx vitest run cov --coverage --coverage.include=cov/shipping.js
RUN v5.0.3 /root/my-project
Coverage enabled with v8
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 18:29:50
Duration 358ms (transform 43%, worker 32%, import 20%, tests 5%)
% Coverage report from v8
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 57.14 | 50 | 100 | 50 |
shipping.js | 57.14 | 50 | 100 | 50 | 5-6
-------------|---------|----------|---------|---------|-------------------
=============================== Coverage summary ===============================
Statements : 57.14% ( 4/7 )
Branches : 50% ( 3/6 )
Functions : 100% ( 1/1 )
Lines : 50% ( 2/4 )
================================================================================
Two tests, and the report names exactly what they missed: lines 5–6 of the file (counting the // shipping.js comment as line 1) — the Canada rate and the international fallback. Coverage is a map of blind spots, not a score to maximise — a test that calls shippingCost(20, "CA") without an expect would turn those lines green while checking nothing.
Jest vs Vitest
| Jest | Vitest | |
|---|---|---|
| Mock API | jest.fn(), jest.mock() | vi.fn(), vi.mock() |
| ESM | needs configuration | native |
| Config | jest.config.js | reuses vite.config.* |
| Globals | on by default | import from "vitest" (or globals: true) |
The matcher APIs are nearly identical; moving between them is mostly jest. → vi..
Node’s Built-in Test Runner
Node ships a test runner in core — node:test plus node:assert — so a small library gets real tests with zero dependencies. test and describe play the same roles as Vitest’s it and describe; node --test is the CLI. Unlike Vitest, which transforms modules itself, node --test runs files through Node’s own loader, so a plain .js file using import needs "type": "module" in package.json (or a .mjs extension) — otherwise every run below prints a MODULE_TYPELESS_PACKAGE_JSON warning before the real output:
// package.json
{ "type": "module" }
// inventory.js
export function reserve(stock, qty) {
if (qty > stock) throw new Error("insufficient stock");
return stock - qty;
}
// inventory.test.js
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { reserve } from "./inventory.js";
describe("reserve", () => {
test("subtracts the reserved quantity", () => {
assert.equal(reserve(10, 3), 7);
});
test("throws when quantity exceeds stock", () => {
assert.throws(() => reserve(5, 9), /insufficient stock/);
});
});
$ node --test inventory.test.js
▶ reserve
✔ subtracts the reserved quantity (0.5561ms)
✔ throws when quantity exceeds stock (0.3716ms)
✔ reserve (1.7365ms)
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 79.8888
node:assert/strict gives assert.equal the same strict (===) comparison Vitest’s toBe uses, and assert.throws takes a function plus an optional matcher — here a regex checked against the thrown message. The summary block (tests, suites, pass, fail, …) is Node’s own reporter, not a third-party library; durations vary run to run.
Mocking with mock.fn
node:test exports its own mock, with the same call-tracking shape as vi.fn().
// notifier.js
export function checkLowStock(stock, threshold, notify) {
if (stock < threshold) notify(stock);
return stock;
}
// notifier.test.js
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { checkLowStock } from "./notifier.js";
test("calls notify when stock drops below the threshold", () => {
const notify = mock.fn();
checkLowStock(2, 5, notify);
assert.equal(notify.mock.callCount(), 1);
assert.deepEqual(notify.mock.calls[0].arguments, [2]);
});
test("does not call notify when stock is sufficient", () => {
const notify = mock.fn();
checkLowStock(10, 5, notify);
assert.equal(notify.mock.callCount(), 0);
});
$ node --test notifier.test.js
✔ calls notify when stock drops below the threshold (1.4856ms)
✔ does not call notify when stock is sufficient (0.1357ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 82.9083
notify is a real function — checkLowStock calls it normally — but mock.fn() wraps it to record every call. notify.mock.calls[0].arguments is the exact argument list of the first call, which is how the test proves checkLowStock passed 2, not just that it was called.
A Failing Test’s Real Output
// failing.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { reserve } from "./inventory.js";
test("reserve leaves zero when fully depleted", () => {
assert.equal(reserve(5, 5), 1);
});
$ node --test failing.test.js
✖ reserve leaves zero when fully depleted (1.4802ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 96.5269
✖ failing tests:
test at failing.test.js:6:1
✖ reserve leaves zero when fully depleted (1.4802ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
0 !== 1
at TestContext.<anonymous> (file:///C:/tmp/jstest/failing.test.js:7:10)
at Test.runInAsyncScope (node:async_hooks:227:14)
at Test.run (node:internal/test_runner/test:1325:25)
at Test.start (node:internal/test_runner/test:1191:17)
at startSubtestAfterBootstrap (node:internal/test_runner/harness:385:17) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 0,
expected: 1,
operator: 'strictEqual',
diff: 'simple'
}
The reserve of 5 from a stock of 5 is 0, but the test expected 1 — a deliberately wrong assertion to show the failure shape. node --test exits with status 1 on any failure (so it fails a CI step), prints a ✖ failing tests section naming the exact test at file:line:column, and the AssertionError carries actual/expected as structured fields, not just a formatted message — useful if a tool parses the output.
Called with no file argument, node --test recurses through the current directory for every *.test.js and test/**/*.js file it finds — including, in a real project, the Vitest *.test.js files from earlier in this lesson. node --test’s file-name matching doesn’t know or care which test API a file imports, so it tries to load math.test.js too; its describe/it come from "vitest", not node:test, and calling them outside a running Vitest process crashes with TypeError: Cannot read properties of undefined (reading 'config') deep inside Vitest’s own internals — a failure with nothing to do with add or divide. Naming one file, as every example above does, avoids the collision; running a Jest or Vitest suite and a node:test suite from the same bare node --test/npx vitest run invocation in the same project doesn’t work either way.
Common Pitfalls
- Un-awaited async assertions can pass when they should fail — see above.
- Shared state between tests — reset mocks and data in
beforeEach. - Testing implementation details — assert on outputs and side effects, not on private helpers; tests tied to internals break on every refactor.
- Over-mocking — mock the boundary (network, disk, time), not your own modules, or you end up testing the mocks.
Practice
1. Write tests for clamp(n, min, max) covering below, inside and above the range.
// clamp.test.js
import { it, expect } from "vitest";
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
it.each([
[-5, 0, 10, 0],
[5, 0, 10, 5],
[50, 0, 10, 10],
])("clamp(%i, %i, %i) is %i", (n, min, max, expected) => {
expect(clamp(n, min, max)).toBe(expected);
});
$ npx vitest run p1 --reporter=verbose
RUN v5.0.3 /root/my-project
✓ p1/clamp.test.js > clamp(-5, 0, 10) is 0 3ms
✓ p1/clamp.test.js > clamp(5, 0, 10) is 5 0ms
✓ p1/clamp.test.js > clamp(50, 0, 10) is 10 0ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 18:29:51
Duration 216ms (import 49%, transform 35%, tests 8%, worker 7%)
it.each generates one named test per row, so a failure tells you exactly which case broke.
2. Mock Date so a greeting function can be tested at 9 am and 9 pm.
// greet.test.js
import { it, expect, vi, afterEach } from "vitest";
const greeting = () => (new Date().getHours() < 12 ? "Good morning" : "Good evening");
afterEach(() => vi.useRealTimers());
it("morning", () => {
vi.useFakeTimers({ now: new Date(2026, 0, 1, 9, 0) });
expect(greeting()).toBe("Good morning");
});
it("evening", () => {
vi.useFakeTimers({ now: new Date(2026, 0, 1, 21, 0) });
expect(greeting()).toBe("Good evening");
});
$ npx vitest run p2 --reporter=verbose
RUN v5.0.3 /root/my-project
✓ p2/greet.test.js > morning 5ms
✓ p2/greet.test.js > evening 1ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 18:29:52
Duration 248ms (import 43%, transform 40%, tests 10%, worker 7%)
Fake timers also freeze Date, so the clock becomes an input you control instead of a source of flaky tests.
3. Fix BUG 2 from the async section and confirm it now fails, as it should.
// fixed.test.js
import { it, expect } from "vitest";
const saveUser = (name) =>
new Promise((resolve) => setTimeout(() => resolve({ name: name.toUpperCase() }), 50));
it("assertion reached through await", async () => {
const user = await saveUser("bob");
expect(user.name).toBe("bob");
});
$ npx vitest run p3
RUN v5.0.3 /root/my-project
❯ p3/fixed.test.js (1 test | 1 failed) 65ms
× assertion reached through await 63ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL p3/fixed.test.js > assertion reached through await
AssertionError: expected 'BOB' to be 'bob' // Object.is equality
Expected: "bob"
Received: "BOB"
❯ p3/fixed.test.js:8:21
6| it("assertion reached through await", async () => {
7| const user = await saveUser("bob");
8| expect(user.name).toBe("bob");
| ^
9| });
10|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed (1)
Start at 18:29:54
Duration 302ms (tests 48%, import 26%, transform 21%, worker 4%)
With await, the wrong assertion finally fails — proving the test can detect the bug it was written for. Always watch a new test fail once.
4. Add the missing tests so shipping.js reaches 100% coverage.
// shipping.test.js
import { it, expect } from "vitest";
import { shippingCost } from "../cov/shipping.js";
it.each([
[150, "US", 0], [20, "US", 5], [20, "CA", 8], [20, "FR", 15],
])("shippingCost(%i, %s) = %i", (total, country, cost) => {
expect(shippingCost(total, country)).toBe(cost);
});
$ npx vitest run p4 --coverage --coverage.include=cov/shipping.js
RUN v5.0.3 /root/my-project
Coverage enabled with v8
Test Files 1 passed (1)
Tests 4 passed (4)
Start at 18:29:55
Duration 402ms (transform 48%, worker 26%, import 22%, tests 4%)
% Coverage report from v8
No files with missing coverage.
1 file fully covered.
=============================== Coverage summary ===============================
Statements : 100% ( 7/7 )
Branches : 100% ( 6/6 )
Functions : 100% ( 1/1 )
Lines : 100% ( 4/4 )
================================================================================
Every line and branch executed — and, more importantly, every result was asserted.