Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

27 chapters
⌕
Beginner9›
B1Setup & mental modelB2Types & valuesB3Operators & flowB4Functions (first half)B5Objects & arrays (first half)B6DOM & eventsB7Basic asyncB8Errors & tools★Cheat page
Intermediate10›
I1Scope & functions, properlyI2Objects deeplyI3Prototypes & OOPI4Async, properlyI5Modules & toolingI6Regex, dates & APIsI7Error handlingI8Real-time connectionsI9Offline & storage★Cheat page
Advanced10›
A1Engine & memoryA2Advanced asyncA3MetaprogrammingA4Types & dataA5Patterns & architectureA6PerformanceA7SecurityA8EcosystemA9Testing★Cheat page
/ search[ ] chaptert top

JavaScript levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
A9

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
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.

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.

⚠ 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.

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.

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."
Practice this layer

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

Make it testable: extract the pure part4 tests · advancedInject the clock instead of calling Date.now directly3 tests · advanced
←previousEcosystem↑ CovernextCheat page→