Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Advanced/A9
37 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSA9 · Part 3 · Advanced

Testing

Not optional at this level — and this whole site's own test suite is the example.

8 min read10 sections2 exercises
37 of 41

In this chapter

  • Unit vs integration vs E2E
  • describe / it / expect
  • Mocking and spies
  • Mocking the network
  • Fake timers

and 5 more

about 8 min
10On this page
  1. Unit vs integration vs E2E
  2. describe / it / expect
  3. Mocking and spies
  4. Mocking the network
  5. Fake timers
  6. Testing async code, and what makes a test flaky
  7. Property-based testing — describing the rule, not the example
  8. Testing the DOM: query by role, not by class
  9. End-to-end, briefly: Playwright
  10. 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
Unitone function or module, isolatedvery — milliseconds, hundreds run in a blinklogic bugs in that one piece
Integrationseveral real pieces working together — no mocking the database, sayslower — real I/Othe seams: "does my code actually talk to the real DB/API correctly"
E2Ethe whole app, driven like an actual user — click, type, assert on the screenslowest — 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

LineWhat happensfn call count
debounced() — 1st callschedules a timer 200ms out0
debounced() — 2nd callcancels the 1st call's timer, schedules a new one0
debounced() — 3rd callcancels the 2nd call's timer, schedules a new one0
expect(fn).not.toHaveBeenCalled()passes — no real or fake time has advanced yet0
vi.advanceTimersByTime(200)fake clock jumps forward; only the 3rd call's timer is still alive, so it fires1
expect(fn).toHaveBeenCalledTimes(1)passes1

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.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 10 testsMake it testable: extract the pure partadvanced
  • 9 testsInject the clock instead of calling Date.now directlyadvanced
All 31 for this level →
A9

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. A8Previous · A8Ecosystem & professional
  2. A9you are here
  3. Next · A10Browser observabilityThe bug is on somebody else's device, on a network you cannot reproduce.5 min read

01On this page

10 sections · 0%

  1. 01Unit vs integration vs E2E
  2. 02describe / it / expect
  3. 03Mocking and spies
  4. 04Mocking the network
  5. 05Fake timers
  6. 06Testing async code, and what makes a test flaky
  7. 07Property-based testing — describing the rule, not the example
  8. 08Testing the DOM: query by role, not by class
  9. 09End-to-end, briefly: Playwright
  10. 10What coverage % actually tells you

[] chapters · t top