Skip to content

Testing JavaScript, TypeScript, and React Applications ​

A test is executable evidence about behavior. The valuable question is not “unit or integration?” in the abstract; it is which observable boundary can prove the requirement with the least accidental coupling.

Begin with behavior and risk ​

Consider a parser that accepts untrusted transaction data. Its important behavior includes successful normalization and useful rejection:

js
export function parseTransaction(value) {
  if (typeof value !== "object" || value === null) {
    throw new TypeError("transaction must be an object");
  }

  if (typeof value.id !== "string" || value.id.length === 0) {
    throw new TypeError("transaction.id must be a non-empty string");
  }

  if (!Number.isFinite(value.amount)) {
    throw new TypeError("transaction.amount must be finite");
  }

  return { id: value.id, amount: value.amount };
}

Node's built-in test runner can check it without a framework:

js
import test from "node:test";
import assert from "node:assert/strict";
import { parseTransaction } from "./parse-transaction.js";

test("parses a valid transaction", () => {
  assert.deepEqual(parseTransaction({ id: "tx-1", amount: 12.5 }), {
    id: "tx-1",
    amount: 12.5,
  });
});

test("rejects a non-finite amount", () => {
  assert.throws(
    () => parseTransaction({ id: "tx-1", amount: Infinity }),
    /amount must be finite/,
  );
});

The test names describe contracts. The assertions observe outputs and errors rather than implementation details such as how many internal helper functions ran.

Keep tests deterministic ​

Tests become unreliable when time, randomness, networks, or global state are uncontrolled. Put those capabilities behind narrow parameters:

js
export function createReference({ now = Date.now, random = Math.random } = {}) {
  const time = now().toString(36);
  const suffix = Math.floor(random() * 1000).toString().padStart(3, "0");
  return `${time}-${suffix}`;
}
js
test("creates a reference from supplied capabilities", () => {
  const reference = createReference({
    now: () => 1000,
    random: () => 0.042,
  });

  assert.equal(reference, "rs-042");
});

This is dependency injection in a small, ordinary form. It avoids replacing every module with a mock and keeps the production contract visible.

Fake timers are useful when the timer itself is incidental. Real timers are appropriate when integration with the event loop is the behavior under test. Never make a test “stable” by adding an arbitrary sleep.

Test asynchronous failure explicitly ​

Await the assertion or returned promise so the runner owns its completion:

js
test("reports an HTTP failure", async () => {
  await assert.rejects(
    () => loadReport({ fetch: async () => ({ ok: false, status: 503 }) }),
    /503/,
  );
});

A missing await can let the test finish before the rejection is observed. Also test cancellation, stale responses, and cleanup when those behaviors are part of the contract—not merely the happy result.

Integration tests cross a meaningful boundary ​

A unit test usually keeps failures local. An integration test deliberately connects pieces whose agreement is risky: a route and its parser, emitted package files and a consumer, or a component and browser-like DOM.

Avoid mocking the subject of the test. If testing an HTTP adapter, a controlled local server or request interceptor provides stronger evidence than replacing the adapter itself. If testing a database repository, decide whether an in-memory substitute can reproduce the constraints that matter; SQL behavior often requires a real disposable database.

Fixtures should be small and named for the behavior they demonstrate. A giant “realistic” JSON fixture hides why a test passes and makes unrelated schema changes noisy.

React tests should use the interface ​

Testing Library encourages queries that resemble how users and assistive technologies find controls:

tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBox } from "./SearchBox";

test("submits the entered query", async () => {
  const user = userEvent.setup();
  const queries: string[] = [];

  render(<SearchBox onSearch={query => queries.push(query)} />);

  await user.type(screen.getByRole("searchbox", { name: /catalog/i }), "closures");
  await user.click(screen.getByRole("button", { name: /search/i }));

  expect(queries).toEqual(["closures"]);
});

This test does not read component state or call the submit handler directly. It proves that the label, input, button, events, and callback work together. A role query failing can reveal an accessibility problem as well as a test problem.

Use getBy... when the element should exist now, findBy... when it should appear asynchronously, and queryBy... primarily when asserting absence. Keep framework-specific details aligned with the installed Testing Library version.

Browser tests cover browser responsibilities ​

A browser automation test is justified for routing, focus, cookies, layout-sensitive interactions, downloads, or a complete critical flow:

ts
import { test, expect } from "@playwright/test";

test("search state is reflected in the URL", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("searchbox", { name: /catalog/i }).fill("promises");
  await page.getByRole("button", { name: /search/i }).click();

  await expect(page).toHaveURL(/query=promises/);
  await expect(page.getByRole("heading", { name: /results/i })).toBeVisible();
});

Do not reproduce every unit assertion through a browser. Keep a small number of high-value flows and preserve failures with traces, screenshots, console logs, or videos as appropriate.

Type checking and tests prove different things ​

TypeScript can reject inconsistent calls without executing them. Runtime tests can exercise behavior using concrete values. Libraries sometimes add compile-time type tests for intended errors:

ts
declare function setMode(mode: "compact" | "expanded"): void;

setMode("compact");
// @ts-expect-error: unsupported modes must be rejected
setMode("automatic");

@ts-expect-error itself becomes an error if the following line stops producing a diagnostic, making it suitable for intentional negative cases. It should include an explanation and should not be used to silence unrelated failures.

Coverage is a map, not a target ​

Line and branch coverage reveal code that no test executed. They do not prove that assertions are meaningful, edge cases were selected well, or the implementation satisfies user needs. Use coverage to discover unexamined behavior, then add tests based on risk.

A balanced project often has many fast domain tests, fewer integration tests around important boundaries, and a small browser suite for critical flows. The exact pyramid matters less than clear ownership of behavior and reliable feedback.

Consumer tests validate a package from the outside ​

A library can pass its source tests while publishing missing declarations or unusable exports. Build and pack the library, install the resulting archive into a temporary consumer, then import only through its public package name. This checks the artifact users receive rather than source paths that will not exist after publishing.

Test at least one supported import style and environment. If a package publishes both ESM and CommonJS, each condition needs a real consumer test. The package-boundaries article develops this workflow further.

Optional prompts ​

Predict: A test calls assert.rejects(operation()) without awaiting or returning it. How can the test report success even when the assertion later fails?

Answer: The test function finishes synchronously, so the runner may mark it complete before the assertion promise settles. Await assert.rejects(...) or return its promise so the runner owns the asynchronous lifetime and attributes any failure to that test.

Explain: Why is getByRole("button", { name: /save/i }) usually stronger than selecting .primary-action?

Answer: The role-and-name query observes the interface exposed to users and assistive technology, so it verifies meaningful semantics and labeling. A CSS class is an implementation detail that may remain present even when the control becomes inaccessible or ceases to behave like a button.

Debug: A test waits 50 milliseconds and then expects a request result. It passes locally but fails under CI load. What assumption is unstable, and what should replace it?

Answer: The test assumes completion within an arbitrary wall-clock interval. Await the operation's actual promise or wait for an observable state transition. If timing itself is the behavior, use a controlled fake clock and advance it deliberately rather than depending on scheduler speed.

Modify: Add coverage proving parseTransaction rejects NaN without mutating the supplied object.

Answer: Pass a retained input object, assert that the call throws an amount error, and then deep-compare the object with its original shape. Number.isFinite(NaN) is false, so the existing parser should reject it; the postcondition protects the boundary function's non-mutating contract.

Further reference ​