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 |
|---|---|
|
|
|
Glob of files coverage is measured against — without it, an untested file is absent from the report rather than flagged as 0%. |
|
Minimum coverage percentages; |
|
Which transformer (e.g. |
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 |
|---|---|
|
Strict equality ( |
|
Recursive, deep-value equality — compares object/array contents rather than identity, ignoring |
|
Like |
|
Checks truthiness in a boolean context — broader than |
|
Strict |
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.