Anatomy of a test: structure, matchers, async
- Write a test as arrange-act-assert with
describe/it, and build fresh state inbeforeEachso tests can't leak into each other - Choose
toBe,toEqual,toStrictEqual,toMatchObjectortoBeCloseTo— or thenode:assert/strictequivalent — for a given value - Predict which async tests pass without checking anything in Vitest 4, and fix them with
await,.resolves/.rejectsorexpect.hasAssertions() - Use
.only,.skipand.tododeliberately, and explain why a leftover.onlyfails the CI run
Read
Three steps — arrange, act, assert
FlagAlmost every good test has the same three steps, in order:
- Arrange — build the inputs and the world the code runs in.
- Act — call the one thing you're testing.
- Assert — check what came out.
// cart.test.ts — Vitest 4
import { describe, expect, it } from "vitest";
import { Cart } from "./cart";
describe("Cart", () => {
it("counts the same item twice", () => {
// Arrange
const cart = new Cart();
const tea = { sku: "tea", priceCents: 450 };
// Act
cart.add(tea);
cart.add(tea);
// Assert
expect(cart.count("tea")).toBe(2);
expect(cart.total()).toBe(900);
});
});
The pieces:
it(name, fn)declares one test.testis the same function under another name — Vitest callsit"just an alias". Pick one per codebase.describe(name, fn)groups tests and prefixes their names in the report:Cart > counts the same item twice. Keep nesting shallow.expect(actual).matcher(expected)throws when the check fails; a test fails when something in it throws. A test with no failingexpectpasses — even one with noexpectat all.- The imports are explicit. Vitest doesn't put
describe/it/expecton the global scope unless you turn onglobals: true(the vitest-in-practice unit covers that).
Name tests as a sentence about behaviour — "counts the same item twice", not "test add 2". When it fails, the report reads like the broken promise.
Node has the same shape built in, with no dependency: node:test plus node:assert.
// cart.test.ts — node:test, Node 24
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { Cart } from "./cart.ts";
describe("Cart", () => {
it("counts the same item twice", () => {
const cart = new Cart();
const tea = { sku: "tea", priceCents: 450 };
cart.add(tea);
cart.add(tea);
assert.equal(cart.count("tea"), 2);
});
});
Run it with node --test. Different assertion style, same three steps. Node 24 runs the .ts file
by stripping its types, and unlike Vitest it needs the extension: "./cart" fails with
ERR_MODULE_NOT_FOUND.
Setup, teardown, and tests that leak into each other
FlagWhen several tests need the same starting point, it's tempting to build it once at the top of the file:
const cart = new Cart(); // one cart, shared by every test below
it("adds tea to the total", () => {
cart.add({ sku: "tea", priceCents: 450 });
expect(cart.total()).toBe(450);
});
it("starts empty", () => {
expect(cart.total()).toBe(0); // ✗ expected 450 to be +0
});
Run alone (vitest run -t "starts empty"), the second test passes. Run with the file, it fails —
the first test left tea in the shared cart. Tests that depend on what ran before them pass or fail
depending on order and filtering, which makes every failure a puzzle.
Build fresh state in beforeEach; undo outside effects in afterEach.
let cart: Cart;
beforeEach(() => {
cart = new Cart(); // every test starts from a new cart
});
afterEach(() => {
vi.useRealTimers(); // put back anything a test swapped out globally
});
beforeEach/afterEachrun around every test in thedescribe(or file) they're in.beforeAll/afterAllrun once — for expensive, read-only setup like starting a test server. Never put mutable state there.- What needs undoing is anything outside the test's own variables: fake timers, a replaced
globalThis.fetch, an environment variable, rows in a test database.
.only, .skip, .todo
it.onlyruns only the marked tests in that file — handy while you're working on one. Vitest 4'sallowOnlydefaults to!process.env.CI, so a forgotten.onlyfails the CI run withUnexpected .only modifierinstead of silently skipping the rest of the file. (Node's runner honoursonlywhen started with--test-only.)it.skipkeeps a test in the file but doesn't run it. Leave a reason next to it, and a ticket — a skipped test is a promise nobody is checking.it.todo("refunds a partial order")lists a test you haven't written yet. The report counts it separately, so the gap stays visible instead of living in your head.
Async tests that pass without checking anything
FlagThese three tests are wrong — each expects "WRONG" from a function that returns "Ada" — and all
three are reported passed by Vitest 4, run on their own with exit code 0.
const fetchUser = async (id: number) => {
await new Promise((r) => setTimeout(r, 10));
return { id, name: "Ada" };
};
// 1. The assertion is inside a .then, and nothing returns or awaits the chain
it("then-chain", () => {
fetchUser(1).then((u) => {
expect(u.name).toBe("WRONG");
});
});
// 2. async callbacks inside forEach — forEach ignores the promises they return
it("async forEach", async () => {
[1, 2].forEach(async (id) => {
const u = await fetchUser(id);
expect(u.name).toBe("WRONG");
});
});
// 3. A promise object is truthy, whatever it will resolve to
it("promise truthiness", async () => {
expect(saveDraft()).toBeTruthy(); // saveDraft() resolves to false
});
In 1 and 2 the test function finishes before the 10 ms timer, so it's reported green; the
expect runs later, when nobody is listening. If other tests keep the worker busy long enough,
Vitest catches the late failure as an Unhandled Rejection and fails the run — but the test
itself still shows ✓, and run alone or last in a file, the failure is never seen at all. Node's
runner also marks the test passed, but it waits for the file's pending timers, so the late failure
does surface: a warning that the test "generated asynchronous activity after the test ended", and
a failed run (checked on Node 24).
The fixes. With the waiting in place, the original "WRONG" expectations fail as they should; here
they're corrected to what the code really returns:
// 1: await the value instead of chaining .then
const u = await fetchUser(1);
expect(u.name).toBe("Ada");
// 2: for…of awaits each iteration…
for (const id of [1, 2]) {
expect((await fetchUser(id)).name).toBe("Ada");
}
// …or start them together and await them all
const users = await Promise.all([1, 2].map((id) => fetchUser(id)));
expect(users.map((u) => u.name)).toEqual(["Ada", "Ada"]);
// 3: await the promise, then assert on its value
expect(await saveDraft()).toBe(true);
Make "no assertion ran" a failure
expect.hasAssertions() at the top of a test fails it if no expect ran before it ended;
expect.assertions(2) demands exactly two. Test 1 with expect.hasAssertions() added fails with
"expected any number of assertion, but got none" — the false pass becomes a real failure. To get
that for every test, Vitest 4's config has expect: { requireAssertions: true }.
It can't save test 3: that test did run an assertion — a wrong one. Only awaiting the value fixes it, which is why "await first, then assert" is the habit, and the counters are the safety net.
Quiz
const cart = new Cart() sits at the top of a test file and every test uses it. "starts empty" passes when run alone but fails when the whole file runs. Why?
expect(getUser()).toBe({ id: 1, name: "Ada" }) fails with expected { id: 1, name: 'Ada' } to be { id: 1, name: 'Ada' }. Why?
createUser() returns { name: "Ada", role: "admin", createdAt: "…" } and createdAt changes every run. Which assertion passes while ignoring it?
You'll write the partial-refund test next sprint and want the gap to stay visible in every test report until then. Which line?
FlagA test does import assert from "node:assert" and assert.equal(count, "1") where count is the number 1. What happens?
load(id) resolves to "Ada". Under Vitest 4, which of these tests passes even though it expects the wrong value?
A PR leaves an it.only in a file. Locally the run is green; in CI the run fails. Why?
expect(parse("{")).toThrow(SyntaxError) — parse throws on bad input. What happens?
Fill in the blank
Give every test a fresh cart, and assert the empty total with the matcher for primitives.
Flagdescribe("Cart", () => { let cart: Cart; (() => { cart = new Cart(); }); it("starts empty", () => { expect(cart.total()).(0); }); });
Assert that an async function rejects, in a form that means the same thing in every Vitest version.
Flagit("rejects an unknown code", async () => { expect(applyCode(cart, "NOPE"))..toThrow("Unknown code"); });
Make toThrow able to catch the synchronous error.
expect( parse("{")).toThrow(SyntaxError);
Compare a floating-point result.
Flagexpect(0.1 + 0.2).(0.3);
Use Node's built-in assertions in strict mode, and compare two objects by structure.
Flagimport assert from "node:assert/"; assert.(cart.lines(), [{ sku: "tea", qty: 2 }]);
Predict the output
class User {
constructor(public name: string) {}
}
it("A", () => expect(new User("Ada")).toEqual({ name: "Ada" }));
it("B", () => expect(new User("Ada")).toStrictEqual({ name: "Ada" }));
it("C", () => expect({ name: "Ada", nick: undefined }).toEqual({ name: "Ada" }));const fetchUser = async (id: number) => {
await new Promise((r) => setTimeout(r, 10));
return { id, name: "Ada" };
};
it("every user is called Bo", async () => {
[1, 2].forEach(async (id) => {
const user = await fetchUser(id);
expect(user.name).toBe("Bo");
});
});
// The only test in the file. Run with `vitest run` (Vitest 4).const fetchUser = async (id: number) => ({ id, name: "Ada" });
it("loads Bo", () => {
expect(fetchUser(1)).resolves.toEqual({ id: 1, name: "Bo" }); // no await
});