A9 · Part 3 · Advanced
Testing
Not optional at this level — and this whole site's own test suite is the example.
10On this page
- Unit vs integration vs E2E
- describe / it / expect
- Mocking and spies
- Mocking the network
- Fake timers
- Testing async code, and what makes a test flaky
- Property-based testing — describing the rule, not the example
- Testing the DOM: query by role, not by class
- End-to-end, briefly: Playwright
- What coverage % actually tells you
Nothing below is a runnable example — 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) |
Rule The classic shape is a pyramid: many
unit tests, fewer integration tests, a handful of E2E
tests. Each layer up costs more to run and more to maintain — E2E
tests are the most convincing and the most expensive, not the
default choice for everything.
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.
Mocking the network
import { vi, expect, it, beforeEach } from "vitest";
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Ana" }),
})
);
});
it("renders the fetched user's name", async () => {
const user = await loadUser(1);
expect(fetch).toHaveBeenCalledWith("/api/users/1");
expect(user.name).toBe("Ana");
});
Replacing global.fetch directly works, but gets brittle
the moment more than one test needs different responses for different
URLs. A library like MSW (Mock Service Worker) intercepts
requests at the network layer instead — the code under test calls a
completely real fetch, unaware anything is mocked, while
MSW answers based on the URL and method. That's what lets the exact
same mock handlers run in tests and in local development.
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.
Dry run: the debounce fake-timer test, assertion by assertion
| Line | What happens | fn call count |
|---|---|---|
| debounced() — 1st call | schedules a timer 200ms out | 0 |
| debounced() — 2nd call | cancels the 1st call's timer, schedules a new one | 0 |
| debounced() — 3rd call | cancels the 2nd call's timer, schedules a new one | 0 |
| expect(fn).not.toHaveBeenCalled() | passes — no real or fake time has advanced yet | 0 |
| vi.advanceTimersByTime(200) | fake clock jumps forward; only the 3rd call's timer is still alive, so it fires | 1 |
| expect(fn).toHaveBeenCalledTimes(1) | passes | 1 |
Three calls, one real invocation — the same debounce behavior verified
in the closures chapter, now checked with real assertions instead of a
console.log, and in milliseconds instead of an actual
200ms 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.
⚠ A flaky test is worse than no test
Once a test fails intermittently, the instinct after enough false
alarms is to stop trusting it — and once nobody trusts it, a
real failure gets re-run and ignored right along with the
false ones. Fix or delete a flaky test; a red light nobody believes
isn't protecting anything.
Property-based testing — describing the rule, not the example
import fc from "fast-check";
it("reversing an array twice returns the original", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const twiceReversed = [...arr].reverse().reverse();
expect(twiceReversed).toEqual(arr);
})
);
});
An example-based test picks specific inputs ([1, 2, 3])
and checks a specific output. A property-based test instead states a
rule that must hold for any input matching a description
("any array of integers"), and the library generates hundreds of
random cases — including edge cases a person would rarely think to
write by hand: an empty array, a single element, deeply nested
duplicates. When one fails, most property-testing libraries
automatically shrink the failing input down to the smallest
case that still reproduces it, instead of leaving a 200-element array
to debug by eye.
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.
End-to-end, briefly: Playwright
import { test, expect } from "@playwright/test";
test("a visitor can add an item to the cart", async ({ page }) => {
await page.goto("/shop");
await page.getByRole("button", { name: "Add to cart" }).first().click();
await expect(page.getByText("1 item in cart")).toBeVisible();
});
The same getByRole/getByText querying style
from just above, now driving a real, actual browser instead of a
simulated DOM — Playwright launches Chromium, Firefox, or WebKit,
clicks for real, and waits automatically for elements to appear
rather than needing a manual sleep. It's the slowest, most expensive
layer of the testing pyramid from the top of this chapter, reserved
for the handful of flows (checkout, sign-up) where "does this
actually work in a real browser" is worth the cost.
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.
Say it like this → "Coverage tells me what's
untested, not what's correct. I use it to find gaps, not as a quality
score — a suite that hits every line with assertions that would pass
no matter what the code did isn't actually testing anything."
Interview answer, one sentence
"Test behaviour rather than implementation, query the DOM by role the way a user finds things, mock only at the boundary such as the network, use fake timers to keep async tests fast and deterministic, and treat coverage as a way to find untested code, not as proof of quality."
Before you move on
- Explain the difference between a unit, integration, and E2E test, and why the pyramid shape exists.
- Say why
submitForm(data, onSave)is easier to test than a version that imports its own save function. - Walk through the debounce fake-timer dry run and say why only one call ever reaches
fn. - Name the usual causes of a flaky test, and explain why a flaky test is worse than no test at all.
- Explain why 100% coverage doesn't guarantee correct behavior, using the
divide(a, b)example.
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series