Lesson 36 of 37
Testing JavaScript
The three kinds of tests (unit, integration, end-to-end), modern runners (Vitest, Node's native runner), mocking network calls with MSW, and honest rules for what to test.
Everything in this track so far has been about writing code. Testing is how you make sure it keeps working after you — or anyone else — changes it later. A codebase without tests isn't a codebase that doesn't break; it's a codebase where nobody notices until a user does.
This lesson is about the shape of testing: the three levels, the one tool choice that actually matters, the one mocking pattern that pays off long-term, and the two quiet rules that distinguish tests that earn their keep from tests that just add to the CI bill.
The three levels, in one place
Tests come in three shapes, and the difference is what slice of the system is actually running:
- Step 1
Unit
One function, class, or hook, exercised in isolation. Its collaborators are faked or stubbed. Tests pure logic. Fast, cheap, specific; catches wrong logic inside a piece.
- Step 2
Integration
Several real pieces together — a component with its hooks and its API client, hitting a mocked network. Tests how pieces fit. Catches wrong assumptions between them.
- Step 3
End-to-end
The real app in a real browser against a real (or near-real) backend. Tests user-visible behaviour. Catches wrong assumptions about the environment.
A healthy test suite uses all three. The classic shape is more unit tests than integration tests, more integration tests than end-to-end — roughly, more of the cheap things and fewer of the expensive ones. The exact mix depends on where your bugs actually come from.
The three catch different bugs. A unit test with 100% coverage won't catch "the API shape changed." An end-to-end test won't tell you why a function returned the wrong value — it'll just show you the downstream broken UI. You want all three exactly because they fail in different ways.
The runner: pick one and stop thinking about it
The current Node ecosystem has one default worth knowing: Vitest. It's fast (it's built on Vite's module graph), it's compatible with Jest's API (so the muscle memory transfers), it runs TypeScript and ESM out of the box with no extra config, and it has a sensible built-in watch mode.
Node 20+ also ships a built-in test runner (node --test) that's
suitable for library code with no bundler. For most applications —
anything with React, Vue, Svelte, bundling, TypeScript paths — Vitest
is the one to reach for.
// example.test.js — runs under Vitest or Jest unchanged
import { describe, it, expect } from "vitest";
import { formatCurrency } from "./format.js";
describe("formatCurrency", () => {
it("renders a whole number with two decimals", () => {
expect(formatCurrency(10, "USD")).toBe("$10.00");
});
it("rounds to the nearest cent", () => {
expect(formatCurrency(10.005, "USD")).toBe("$10.01");
});
it("handles currencies with no minor unit", () => {
expect(formatCurrency(10, "JPY")).toBe("¥10");
});
});The shape above — describe, it, expect — is the same across
Vitest, Jest, and Node's native runner, which is why switching
between them is rarely the hard part of a project.
The one mocking pattern worth knowing: network-level
The most common wrong way to test code that calls fetch:
// Fragile — knows how the code calls fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ user: "alice" }),
});That test passes right up until the code switches from fetch to
axios, or adds a retry wrapper, or starts setting a header the mock
doesn't know about. The mock is a copy of the HTTP-client surface,
not of the server's behaviour, so every refactor of the client is a
test rewrite.
The better pattern is network-level interception, usually with MSW (Mock Service Worker):
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("/api/user", () => HttpResponse.json({ user: "alice" })),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it("shows the user's name", async () => {
render(<UserProfile />);
expect(await screen.findByText("alice")).toBeInTheDocument();
});Now the code under test runs unchanged. It calls fetch (or
axios, or XMLHttpRequest) exactly the way it does in production.
MSW matches the request URL and returns the canned response. Swap
fetch for axios later and the test still passes — because the
test depends on the HTTP contract, not on the client library.
This is the one pattern that most reliably pays off over years. The up-front cost of learning MSW is measurably less than the accumulated cost of rewriting mocks every time an API client changes.
What to test and what not to
The quietest unlock for most teams: not testing less, but testing the right things.
Don't bother testing:
- Code the framework or standard library has already tested. A
test that asserts
array.map(...).filter(...)returns the filtered array is testingArray.prototype. A test that mounts a<Button>and assertsonClickfires on click is testing React. These tests can't fail in a useful way. - Pass-through code. A function that just forwards its arguments to a library's function doesn't need a test; the library's test covers the behaviour. Add a test when your logic is involved.
- Private implementation details. Testing that a function uses a
forloop instead ofmaplocks you to one implementation. Test the observable output.
Do test:
- Your logic. Validation rules, formatting helpers, state machines, reducers, business rules — anywhere a wrong answer would be your fault.
- The seams between your code and other people's. The adapter that takes an API response and maps it to the shape your UI expects — that's where bugs live.
- The non-obvious cases. A test for the happy path is cheap insurance; a test for "what if the list is empty?", "what if the API returns a 500?", "what if the clock crosses midnight mid- calculation?" is where real bugs get caught.
Flaky tests are a signal, not a feature
A test that passes sometimes and fails sometimes — a flaky test — is the thing to fix most urgently in a test suite, even above adding new tests. The flake is almost always caused by one of:
- Shared mutable state between tests (a module-level variable, a database table, a file on disk).
- Shared timing assumptions — real timers, waiting on a
setTimeout, waiting on "the next tick." - Non-deterministic order — tests that depend on each other running in a particular order.
- Real network or real IO — anything that goes outside the process.
The fixes are the same everywhere: make the system deterministic.
import { vi } from "vitest";
it("debounces updates", () => {
vi.useFakeTimers();
const update = vi.fn();
const debounced = debounce(update, 100);
debounced("a");
debounced("b");
vi.advanceTimersByTime(100);
expect(update).toHaveBeenCalledOnce();
expect(update).toHaveBeenCalledWith("b");
});For UI tests, prefer findBy / waitFor from Testing Library over
fixed sleeps — they wait until a condition is true, with a
timeout, instead of guessing how long "long enough" is.
The temptation when a test is flaky is to add retries. Don't. Retries hide the bug, and the non-determinism that caused the flake is almost always also present in production — just invisible until it isn't.
End-to-end: Playwright is the current answer
For end-to-end tests — "drive the real app in a real browser" —
Playwright is the current default. Fast, multi-browser out of
the box (Chromium, Firefox, WebKit), good tracing when a test fails,
and a locator API that waits for elements intelligently instead of
flaking on timing.
import { test, expect } from "@playwright/test";
test("a user can sign in", async ({ page }) => {
await page.goto("/sign-in");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("secret");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText("Welcome, alice")).toBeVisible();
});Keep E2E tests sparse — they're the most expensive to write and the most expensive to run. Cover the handful of flows that would be catastrophic to break (sign-in, checkout, the one path that makes the product's money) and rely on unit + integration for the rest.
What to remember
- Three test levels: unit (one piece, isolated), integration (several pieces together, network mocked), end-to-end (real app in real browser). Each catches a different class of bug.
- Vitest is the practical default runner for application code;
node --testis the lightweight option for library code with no bundler. - Mock at the network layer with MSW, not at the fetch/axios layer. The test depends on the HTTP contract, not on the client library.
- Don't test what the framework or standard library already tested. Test your logic, the seams between your code and other people's, and the non-obvious cases.
- Flaky tests are a signal of non-determinism. Fake the clock, isolate shared state, prefer
waitForover fixed sleeps. Never paper over with retries. - Playwright is the current end-to-end default. Keep E2E tests sparse; cover the few flows that must not break.
Check yourself
4 questions · pass 3/4 to unlock Security in the Browser
1.What's the practical difference between a unit test, an integration test, and an end-to-end test?
2.A common test-shape question: should you mock
fetchcalls, or should you intercept them at the network layer with a library like MSW (Mock Service Worker)?3.Which of these is a good rule for what *not* to unit-test?
4.A test that passes locally sometimes fails in CI and sometimes passes. What's the useful name for this and what causes it?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.