Unit Testing with Jest

This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve.

This section’s bibliography lists the reference material consulted while preparing these pages.

Jest is the most widely used JavaScript test runner and assertion library, maintained by Meta and used as the default test framework for React, and increasingly common for plain Node.js libraries too. It bundles four things other ecosystems often split across separate packages — a test runner (finds and executes .test.js / .spec.js files), an assertion library (expect(…​).toBe(…​)), a mocking library (jest.fn(), jest.mock()), and a coverage reporter (--coverage) — all configured through a single jest.config.js. This page covers project setup, the describe/test API, the matchers used most often, mocking, testing async code (building on Asynchronous JavaScript), snapshot testing, and coverage reporting, closing with a worked example tying several of these together.

Installing and Configuring Jest

Jest is installed as a dev dependency, and a test script in package.json lets npm test (and CI) find it without a global install:

npm install --save-dev jest
{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" } }

By default Jest looks for a tests directory, or any file named .test.js / .spec.js, anywhere in the project except node_modules. A jest.config.js at the project root — generated interactively with npm init jest@latest, or written by hand — controls discovery and behavior in more detail:

/** @type {import('jest').Config} */
module.exports = {
  testEnvironment: "node",          // or "jsdom" for browser-like DOM APIs (window, document, ...)
  testMatch: ["**/__tests__/**/*.test.js"],
  collectCoverageFrom: ["src/**/*.js", "!src/**/*.test.js"],
  coverageThreshold: {
    global: { statements: 80, branches: 70, functions: 80, lines: 80 },
  },
  clearMocks: true,                 // automatically reset jest.fn() mocks between tests
};
Option What it controls

testEnvironment

"node" for server/library code, "jsdom" for tests touching DOM APIs (a separate package in modern Jest).

collectCoverageFrom

Glob of files coverage is measured against — without it, an untested file is absent from the report rather than flagged as 0%.

coverageThreshold

Minimum coverage percentages; --coverage exits non-zero below them, making it usable as a CI gate (see Code Coverage).

transform

Which transformer (e.g. babel-jest) preprocesses non-standard syntax — JSX, TypeScript, ESM — see Babel & Transpilation.

Structuring Tests: describe, test, and it

describe(name, fn) groups related tests under a shared label, purely for organizing output and shared setup/teardown — it has no effect on which tests run. test(name, fn) defines a single case, and it is a plain alias for test — the exact same function under a second name, provided so a case can read like a sentence:

describe("formatCurrency", () => {
  test("formats a whole number of dollars", () => {
    expect(formatCurrency(10)).toBe("$10.00");
  });

  it("rounds to two decimal places", () => {         // `it` and `test` are interchangeable
    expect(formatCurrency(10.005)).toBe("$10.01");
  });
});

Either name is fine; most style guides pick one and enforce it via ESLint rather than mixing both (see ESLint & Prettier). describe blocks nest arbitrarily deep to mirror the shape of the module under test.

beforeEach/afterEach run before/after every test in the enclosing describe (or the whole file, at the top level); beforeAll/afterAll run once for the whole scope. These are the usual place to create and dispose of fixtures:

describe("UserRepository", () => {
  let repository;

  beforeEach(() => { repository = new UserRepository(new InMemoryDatabase()); });
  afterEach(() => { repository.close(); });

  test("stores and retrieves a user", () => {
    repository.save({ id: 1, name: "Ada" });
    expect(repository.findById(1)).toEqual({ id: 1, name: "Ada" });
  });
});

test.only(…​)/describe.only(…​) restrict a run to just that case while debugging; test.skip(…​) does the opposite. Neither should be committed — ESLint’s jest/no-focused-tests and jest/no-disabled-tests rules catch them in CI.

Assertions with expect

Every assertion starts with expect(actualValue) and chains a matcher describing the expected shape. A failed matcher throws, which is what makes Jest report the enclosing test as failed.

Equality and truthiness matchers

Matcher Behavior

toBe(value)

Strict equality (Object.is, essentially ===). Right for primitives and for asserting two variables share the same object reference — wrong for comparing two structurally-equal but distinct objects.

toEqual(value)

Recursive, deep-value equality — compares object/array contents rather than identity, ignoring undefined properties.

toStrictEqual(value)

Like toEqual, but also requires undefined properties to match, sparse-array holes to line up, and both values to share the same class/prototype.

toBeTruthy() / toBeFalsy()

Checks truthiness in a boolean context — broader than toBe(true)/toBe(false), for values whose type isn’t boolean but whose truthiness is what matters.

toBeNull() / toBeUndefined() / toBeDefined()

Strict === null / === undefined / not-undefined checks.

const a = { flavor: "grapefruit", ounces: 12 };
const b = { flavor: "grapefruit", ounces: 12 };

expect(a).toEqual(b);        // passes -- same structure
expect(a).not.toBe(b);       // passes -- different object references

class Point { constructor(x, y) { this.x = x; this.y = y; } }
expect(new Point(1, 2)).toEqual({ x: 1, y: 2 });            // passes -- toEqual ignores the class
expect(new Point(1, 2)).not.toStrictEqual({ x: 1, y: 2 });  // passes -- toStrictEqual does not

expect(findUser(-1)).toBeNull();
expect(config.retries).toBeDefined();

As a rule of thumb: toBe for primitives, toEqual for most object/array comparisons, toStrictEqual when the result’s class — not just its shape — is part of what’s being verified.

Exception matchers

toThrow asserts that a function call throws. The function must be passed unevaluated, wrapped in an arrow function, so Jest can invoke it inside its own try/catch:

function withdraw(balance, amount) {
  if (amount > balance) throw new RangeError("insufficient funds");
  return balance - amount;
}

test("throws when overdrawing", () => {
  expect(() => withdraw(10, 20)).toThrow();                      // any error
  expect(() => withdraw(10, 20)).toThrow(RangeError);            // specific error class
  expect(() => withdraw(10, 20)).toThrow(/insufficient/);        // message matches this pattern
});

Testing async code: async/await and the resolves/rejects matchers

Jest test functions may themselves be declared async; Jest awaits the returned promise before deciding whether the test passed, so a test body can await the code under test and assert on the result as if it were synchronous, with any rejection propagating as a normal thrown error:

test("fetchUser returns the requested user", async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe("Ada");
});

.resolves/.rejects, chained onto expect(…​), unwrap a promise’s fulfilled/rejected value before applying the next matcher — convenient when the whole test is a single assertion, avoiding an extra local variable. See Asynchronous JavaScript for how promises and async/await behave outside of tests:

test("fetchUser resolves with the user record", () => {
  return expect(fetchUser(1)).resolves.toEqual({ id: 1, name: "Ada" });
});

test("fetchUser rejects for an unknown id", async () => {
  await expect(fetchUser(-1)).rejects.toThrow("user not found");
});

Both .resolves and .rejects return a promise themselves, so the expect(…​) call must be await`ed or `return`ed — an un-awaited assertion lets the test finish and report success before the promise settles. The older alternatives to all of this — returning a bare promise without `resolves, or accepting a done callback called manually — still work but are more error-prone (a missing return or a missed done() call silently produces a test that "passes" without checking anything), so async/await is the recommended default.

Other frequently used matchers: toContain(item)/toHaveLength(n) for array/string checks, toMatchObject(…​) for a partial object match, toBeCloseTo(n, digits?) for floating-point comparisons, and asymmetric matchers like expect.any(Constructor)/expect.objectContaining({…​}) usable inside another matcher’s argument.

Mocking

jest.fn(implementation?) creates a mock function — a callable stand-in that records every call it receives (arguments, return value, call count and order) so tests can assert on how it was used, independent of what it actually does:

const onSave = jest.fn();
onSave("first");
onSave("second");

expect(onSave).toHaveBeenCalledTimes(2);
expect(onSave).toHaveBeenCalledWith("second");
expect(onSave.mock.calls[0][0]).toBe("first");   // first argument of the first call

A mock function returns undefined unless given a behavior. mockReturnValue/mockImplementation set a default; …​Once variants (mockReturnValueOnce, etc.) queue one-off behaviors for successive calls; and mockResolvedValue/mockRejectedValue are shorthand for a promise-returning implementation, convenient for mocking an async dependency such as getUser.mockResolvedValue({ id: 1, name: "Ada" }).

jest.spyOn(object, methodName) replaces one method on a real object with a mock function while still tracking calls, and can be restored to the original implementation afterward with .mockRestore() so the spy doesn’t leak into unrelated tests:

import * as mathUtils from "./mathUtils.js";

test("calculate() uses mathUtils.add", () => {
  const spy = jest.spyOn(mathUtils, "add").mockReturnValue(42);
  expect(calculate(1, 2)).toBe(42);
  expect(spy).toHaveBeenCalledWith(1, 2);
  spy.mockRestore();   // mathUtils.add is now the real function again
});

jest.mock(modulePath) replaces an entire imported module with an auto-mocked version (every export becomes a jest.fn()) for the whole test file — essential for isolating a unit from a real network call, filesystem access, or database. Calls are hoisted above imports by Jest’s transform, so the mocked module is already in place by the time the real import executes:

import axios from "axios";
import { fetchUsers } from "./userService.js";

jest.mock("axios");   // axios.get, axios.post, etc. are now mock functions

test("fetchUsers returns the response data", async () => {
  axios.get.mockResolvedValue({ data: [{ id: 1, name: "Ada" }] });
  const users = await fetchUsers();
  expect(users).toEqual([{ id: 1, name: "Ada" }]);
  expect(axios.get).toHaveBeenCalledWith("/users");
});

A second, factory-function argument to jest.mock() allows a partial mock — keeping some real exports via jest.requireActual — instead of auto-mocking everything.

Snapshot Testing

toMatchSnapshot() serializes a value — an object, a rendered component tree, CLI output — to text and compares it against a saved reference in a snapshots/*.snap file. The first run writes the snapshot; every later run compares against it and fails if the output changed:

test("formats an invoice", () => {
  expect(formatInvoice({ id: 1, total: 42.5 })).toMatchSnapshot();
});

It earns its keep for output that’s large, structural, and tedious to assert on field-by-field — rendered markup, generated logs, a serialized API response — where a hand-written toEqual({…​}) would just transcribe the snapshot anyway; the .snap diff in code review is often a faster way to catch an unintended change than a hand-written assertion.

It degrades quickly when treated as a substitute for thinking about the correct output, though. Non-deterministic input (timestamps, random ids) fails on every run unless excluded with property matchers (toMatchSnapshot({ createdAt: expect.any(Date) })) or mocked outright. Blind regeneration — running jest --updateSnapshot/-u on every failure without reading the diff first — turns the test into a no-op that can never catch a real regression, since "expected" is redefined to match whatever the code currently does. Overly large snapshots (a whole page’s HTML, a huge API response) are hard to review meaningfully in a PR diff. A snapshot is best treated as a reviewed, committed artifact worth a second look on every change, not a shortcut around writing a real assertion.

Code Coverage

Running Jest with --coverage instruments the code under test and reports how much of it the suite actually exercised:

npx jest --coverage
-----------------|---------|----------|---------|---------|-------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files         |   84.61 |    66.67 |     100 |   84.61 |
 formatCurrency.js |   84.61 |    66.67 |     100 |   84.61 | 12-13
-----------------|---------|----------|---------|---------|-------------------

The four columns measure different things: % Stmts (statements executed), % Branch (if/else, ? :, &&/|| branches taken in both directions), % Funcs (functions invoked), and % Lines (source lines executed) — a file can show 100% line coverage while still missing a branch, e.g. a test that only ever passes a truthy value into an if never exercises the else. "Uncovered Line #s" points at exactly which lines still need a test.

The coverageThreshold option shown earlier turns this report into a pass/fail CI gate rather than something a developer has to remember to check by eye. A high percentage is not proof of a good suite, though — it only shows which lines ran, not whether the assertions covering them were meaningful; use coverage to find completely untested code, not as a target to chase for its own sake.

Complete Worked Example

The following pairs a small module with a Jest test file exercising several techniques together: equality and exception matchers, an async function tested with async/await and resolves/rejects, and a dependency replaced with jest.fn() via dependency injection.

priceCalculator.js:

// A small pricing module: applies a discount and (async) looks up tax by region.

export function applyDiscount(price, percentOff) {
  if (percentOff < 0 || percentOff > 100) {
    throw new RangeError("percentOff must be between 0 and 100");
  }
  return Number((price * (1 - percentOff / 100)).toFixed(2));
}

// `taxService` is injected rather than imported directly, so tests can supply a mock
// without needing jest.mock() -- see "Mocking" above for the module-mocking alternative.
export async function totalWithTax(price, region, taxService) {
  const rate = await taxService.getRate(region);
  return Number((price * (1 + rate)).toFixed(2));
}

priceCalculator.test.js:

import { applyDiscount, totalWithTax } from "./priceCalculator.js";

describe("applyDiscount", () => {
  test("reduces the price by the given percentage", () => {
    expect(applyDiscount(100, 25)).toBe(75);
  });

  test("returns the original price for a 0% discount", () => {
    expect(applyDiscount(50, 0)).toBe(50);
  });

  test("rejects an out-of-range percentage", () => {
    expect(() => applyDiscount(100, 150)).toThrow(RangeError);
    expect(() => applyDiscount(100, -1)).toThrow(/between 0 and 100/);
  });
});

describe("totalWithTax", () => {
  test("applies the rate returned by the tax service", async () => {
    const taxService = { getRate: jest.fn().mockResolvedValue(0.2) };

    const total = await totalWithTax(100, "CA", taxService);

    expect(total).toBe(120);
    expect(taxService.getRate).toHaveBeenCalledWith("CA");
  });

  test("propagates a rejected lookup", async () => {
    const taxService = { getRate: jest.fn().mockRejectedValue(new Error("unknown region")) };

    await expect(totalWithTax(100, "XX", taxService)).rejects.toThrow("unknown region");
  });

  test("matches the previously reviewed shape for a snapshot region", async () => {
    const taxService = { getRate: jest.fn().mockResolvedValue(0.0875) };

    const total = await totalWithTax(19.99, "TX", taxService);

    expect(total).toMatchSnapshot();
  });
});

Running npx jest --coverage against this pair exercises every branch of applyDiscount (the valid path and both invalid-range throws) and both outcomes of totalWithTax (resolved and rejected getRate), which is enough for priceCalculator.js to show 100% statement, branch, and function coverage in the summary table above.