Testing & Quality
Testing & Quality

Anatomy of a test: structure, matchers, async

Lesson · ~30 min
After this unit you can
  • Write a test as arrange-act-assert with describe/it, and build fresh state in beforeEach so tests can't leak into each other
  • Choose toBe, toEqual, toStrictEqual, toMatchObject or toBeCloseTo — or the node:assert/strict equivalent — for a given value
  • Predict which async tests pass without checking anything in Vitest 4, and fix them with await, .resolves/.rejects or expect.hasAssertions()
  • Use .only, .skip and .todo deliberately, and explain why a leftover .only fails the CI run

Read

Simple
1 / 2

Three steps — arrange, act, assert

Flag

Almost every good test has the same three steps, in order:

  1. Arrange — build the inputs and the world the code runs in.
  2. Act — call the one thing you're testing.
  3. 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. test is the same function under another name — Vitest calls it "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 failing expect passes — even one with no expect at all.
  • The imports are explicit. Vitest doesn't put describe/it/expect on the global scope unless you turn on globals: 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.

Medium
1 / 2

Setup, teardown, and tests that leak into each other

Flag

When 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/afterEach run around every test in the describe (or file) they're in.
  • beforeAll/afterAll run 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.only runs only the marked tests in that file — handy while you're working on one. Vitest 4's allowOnly defaults to !process.env.CI, so a forgotten .only fails the CI run with Unexpected .only modifier instead of silently skipping the rest of the file. (Node's runner honours only when started with --test-only.)
  • it.skip keeps 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.
Complex

Async tests that pass without checking anything

Flag

These 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?

Flag

expect(getUser()).toBe({ id: 1, name: "Ada" }) fails with expected { id: 1, name: 'Ada' } to be { id: 1, name: 'Ada' }. Why?

Flag

createUser() returns { name: "Ada", role: "admin", createdAt: "…" } and createdAt changes every run. Which assertion passes while ignoring it?

Flag

You'll write the partial-refund test next sprint and want the gap to stay visible in every test report until then. Which line?

Flag

A test does import assert from "node:assert" and assert.equal(count, "1") where count is the number 1. What happens?

Flag

load(id) resolves to "Ada". Under Vitest 4, which of these tests passes even though it expects the wrong value?

Flag

A PR leaves an it.only in a file. Locally the run is green; in CI the run fails. Why?

Flag

expect(parse("{")).toThrow(SyntaxError) — parse throws on bad input. What happens?

Flag

Fill in the blank

Give every test a fresh cart, and assert the empty total with the matcher for primitives.

Flag
describe("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.

Flag
it("rejects an unknown code", async () => {
   expect(applyCode(cart, "NOPE"))..toThrow("Unknown code");
});

Make toThrow able to catch the synchronous error.

Flag
expect( parse("{")).toThrow(SyntaxError);

Compare a floating-point result.

Flag
expect(0.1 + 0.2).(0.3);

Use Node's built-in assertions in strict mode, and compare two objects by structure.

Flag
import 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" }));
Flag
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).
Flag
const fetchUser = async (id: number) => ({ id, name: "Ada" });

it("loads Bo", () => {
  expect(fetchUser(1)).resolves.toEqual({ id: 1, name: "Bo" }); // no await
});
Flag

Further reading