Testing
Not optional at this level — and this whole site's own test suite is the example.
Nothing below is a .try block — describe,
it, and expect are test-runner globals, not
part of the language, so they don't exist in the sandboxed worker
this page's other examples run in. Every example here is real code,
though — most of it lifted directly from
tests/content.test.ts, the file that actually runs
every time this project's own npm test does.
Unit vs integration vs E2E
| Tests | Fast? | Catches | |
|---|---|---|---|
| Unit | one function or module, isolated | very — milliseconds, hundreds run in a blink | logic bugs in that one piece |
| Integration | several real pieces working together — no mocking the database, say | slower — real I/O | the seams: "does my code actually talk to the real DB/API correctly" |
| E2E | the whole app, driven like an actual user — click, type, assert on the screen | slowest — a real browser, a real app | "does the feature work end to end," including things no unit test ever touches (routing, real rendering, real network) |
describe / it / expect
A real example, verbatim, from this project's own test suite:
import { describe, expect, it } from "vitest";
describe("chapter integrity", () => {
it("balances <pre> and <code> tags in every body", () => {
for (const { topicId, ch } of allChapters) {
const open = (ch.body.match(/<pre>/g) || []).length;
const close = (ch.body.match(/<\/pre>/g) || []).length;
expect(open, `${topicId}/${ch.id} has unbalanced <pre> tags`).toBe(close);
}
});
});
describe is purely organizational — a named group, shows
up as a heading in the output. it (same thing as
test in most runners) is one actual test case, and only
passes if nothing inside it throws. expect(value) wraps
a value so you can chain a matcher onto it —
.toBe() for exact equality, .toEqual() for
deep equality on objects/arrays, .toContain(),
.toThrow(), dozens more. That optional second string
argument to expect() above is the failure message — the
difference between a red X and "topic 'js', chapter
'operators-flow' has unbalanced <pre> tags" when something
actually breaks.
Underneath, a test runner is doing something close to what
the errors chapter already covered:
each it block runs inside a try/catch the
runner controls. A thrown error (which is exactly what a failed
expect() does) is caught, recorded as a failure with its
message, and the runner moves on to the next test instead of the
whole suite crashing on the first red result.
Mocking and spies
A mock replaces a real dependency with a fake, controllable stand-in. A spy wraps a real function so it still runs normally, but every call gets recorded — arguments, return value, call count.
import { vi, expect, it } from "vitest";
it("calls the save callback exactly once", () => {
const onSave = vi.fn(); // a mock function — records every call
submitForm({ name: "Ana" }, onSave);
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith({ name: "Ana" });
});
This is exactly where
dependency injection earns
its keep: submitForm(data, onSave) takes its dependency
as a parameter instead of importing and calling a real save function
directly, so a test can hand it vi.fn() instead of
hitting a real API. A function that reaches out and imports its own
dependencies has nothing a test can substitute — mocking often isn't
a testing-library feature so much as a reason to write the function
injectable in the first place.
Fake timers
A real setTimeout(fn, 5000) in a test means the test
genuinely waits 5 real seconds — multiply that across a suite and
testing gets unbearably slow. Fake timers replace the clock itself,
so time can be fast-forwarded instantly.
import { vi, expect, it } from "vitest";
it("debounce only calls the function once after the delay", () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled(); // synchronous — no real time has passed at all
vi.advanceTimersByTime(200); // jump the fake clock forward, instantly
expect(fn).toHaveBeenCalledTimes(1); // only the LAST call actually fired — debounce, verified
vi.useRealTimers();
});
This tests the exact debounce
implementation from several chapters back — three rapid calls,
one real invocation — and it runs in milliseconds despite testing a
200ms delay, because vi.advanceTimersByTime() moves the
fake clock, it doesn't actually wait.
Testing async code, and what makes a test flaky
it("resolves with the fetched user", async () => {
const user = await loadUser(1); // await works inside a test exactly like anywhere else
expect(user.name).toBe("Ana");
});
A flaky test is one that passes most of the time and fails
occasionally, with no code change in between — almost always one of
a short list of causes: a real timer racing against the assertion
instead of a fake one, a real network call to something not always
reliable, tests that share mutable state and run in an
order-dependent way, or a hardcoded wait
(setTimeout(..., 100), hoping 100ms is always enough)
instead of actually waiting for the specific condition to become
true.
Testing the DOM: query by role, not by class
// fragile — breaks the moment a class name changes for purely visual reasons
container.querySelector(".btn-primary-lg");
// Testing Library's approach — query the way an actual user/assistive tech would
screen.getByRole("button", { name: /submit/i });
screen.getByLabelText("Email address");
screen.getByText("Welcome back");
The DOM chapter already covered
querySelector, and it still works fine here — the
problem is what it couples the test to. A CSS class is a styling
detail; renaming it for a purely visual refactor shouldn't break a
test that never cared about styling. Querying by role, label, or
visible text couples the test to what the feature actually
is to a user — which also means a test written this way
incidentally checks that the markup is accessible enough to query
that way at all.
What coverage % actually tells you
Coverage measures which lines (or branches, or functions) ran at least once while the test suite executed — nothing more. A line running is not the same as that line being correctly checked.
function divide(a, b) {
return a / b; // one line, so one test calling divide(4, 2) hits 100% line coverage
}
// ...without a single assertion ever checking behavior at b === 0
100% coverage with weak assertions is completely possible, and common. Coverage is genuinely useful for one specific job — finding code nobody's tests touch at all, which is a real blind spot worth knowing about — but a coverage percentage on its own is a measure of what ran, never a measure of what was actually verified.
Opens in the editor — write it, run it, and check it against real tests.