AniUI Academy

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.

12 min read

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:

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

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

  3. 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 testing Array.prototype. A test that mounts a <Button> and asserts onClick fires 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 for loop instead of map locks 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 --test is 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 waitFor over 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

up to 50
  1. 1.What's the practical difference between a unit test, an integration test, and an end-to-end test?

  2. 2.A common test-shape question: should you mock fetch calls, or should you intercept them at the network layer with a library like MSW (Mock Service Worker)?

  3. 3.Which of these is a good rule for what *not* to unit-test?

  4. 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 post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.