Testing with Jest
Writing test files with describe/test/expect, matchers, mocking dependencies, and setup/teardown hooks.
Why Jest
Jest is the most widely used JavaScript testing framework — a test runner, an assertion library, and a mocking library bundled together, needing almost no configuration to get started on a typical Node or React project. (Vitest is a newer, faster alternative with a near-identical API, commonly paired with Vite-based projects — the concepts on this page transfer directly.)
npm install --save-dev jest
Add a script to package.json so npm test runs the suite:
{
"scripts": {
"test": "jest"
}
}
The code under test
// mathUtils.js
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
// userService.js
export async function fetchUser(id, httpClient) {
const response = await httpClient.get(`/users/${id}`);
if (!response.ok) {
throw new Error(`User ${id} not found`);
}
return response.json();
}
A first test file
Jest discovers tests automatically in files named *.test.js (or inside a __tests__ folder). describe groups related tests; test (or its alias it) defines one individual test; expect makes assertions:
// mathUtils.test.js
import { add, divide } from "./mathUtils.js";
describe("add", () => {
test("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
test("handles negative numbers", () => {
expect(add(-1, 1)).toBe(0);
});
});
describe("divide", () => {
test("divides two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
test("throws when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
});
npm test
PASS ./mathUtils.test.js
add
✓ adds two positive numbers (1 ms)
✓ handles negative numbers
divide
✓ divides two numbers
✓ throws when dividing by zero
Tests: 4 passed, 4 total
Note that expect(() => divide(10, 0)).toThrow(...) wraps the call in an arrow function — toThrow needs to invoke the function itself to catch what it throws; calling divide(10, 0) directly inside expect(...) would throw immediately, before Jest ever gets a chance to check it.
Common matchers
expect(2 + 2).toBe(4); // strict equality (===) — primitives
expect({ name: "Ada" }).toEqual({ name: "Ada" }); // deep equality — objects/arrays
expect([1, 2, 3]).toContain(2);
expect("Hello World").toMatch(/World/);
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(5).toBeGreaterThan(3);
expect([1, 2, 3]).toHaveLength(3);
toBe uses Object.is (essentially ===) — fine for primitives, but comparing two different object instances with the same contents fails, because they're different references. toEqual instead checks that two objects/arrays are structurally equal, recursively comparing their contents — the one to reach for whenever comparing anything other than a primitive.
Mocking
A mock replaces a real dependency — a network call, a database, the current date — with a fake, fully controlled stand-in, so a test exercises only the code under test, not everything it happens to call. jest.fn() creates a mock function you can configure and later inspect:
test("fetchUser returns parsed JSON on success", async () => {
const mockHttpClient = {
get: jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Ada" }),
}),
};
const user = await fetchUser(1, mockHttpClient);
expect(user).toEqual({ id: 1, name: "Ada" });
expect(mockHttpClient.get).toHaveBeenCalledWith("/users/1");
expect(mockHttpClient.get).toHaveBeenCalledTimes(1);
});
test("fetchUser throws when the response is not ok", async () => {
const mockHttpClient = {
get: jest.fn().mockResolvedValue({ ok: false }),
};
await expect(fetchUser(1, mockHttpClient)).rejects.toThrow("User 1 not found");
});
mockResolvedValue/mockRejectedValue are shortcuts for a mock function that returns a resolved (or rejected) Promise — the async equivalent of mockReturnValue. toHaveBeenCalledWith and toHaveBeenCalledTimes let a test verify not just what a function returned, but how the code under test actually used its dependency — exactly once, with exactly these arguments.
jest.mock() replaces an entire imported module with an auto-generated mock, useful when the real dependency is imported directly rather than passed in as a parameter:
// api.js
export function fetchFromApi(url) {
return fetch(url).then(r => r.json());
}
// api.test.js
import { fetchFromApi } from "./api.js";
global.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ id: 1 }) })
);
test("fetchFromApi calls the global fetch and returns parsed JSON", async () => {
const result = await fetchFromApi("/users/1");
expect(result).toEqual({ id: 1 });
expect(fetch).toHaveBeenCalledWith("/users/1");
});
Setup and teardown
beforeEach/afterEach run before/after every test in their enclosing describe block — the standard place for shared setup and cleanup, avoiding repeating the same setup code in every test:
describe("BankAccount", () => {
let account;
beforeEach(() => {
account = new BankAccount(100); // fresh account before every single test
});
afterEach(() => {
jest.clearAllMocks(); // reset any mocks' call history between tests
});
test("deposit increases balance", () => {
account.deposit(50);
expect(account.balance).toBe(150);
});
test("withdraw decreases balance", () => {
account.withdraw(30);
expect(account.balance).toBe(70);
});
});
Each test gets its own freshly-constructed account because beforeEach runs again before every one — tests never leak mutated state into each other, even though both reference the same account variable name.
A complete test file
// bankAccount.js
export class InsufficientFundsError extends Error {}
export class BankAccount {
#balance;
constructor(balance = 0) {
this.#balance = balance;
}
get balance() {
return this.#balance;
}
deposit(amount) {
if (amount <= 0) throw new Error("Deposit amount must be positive");
this.#balance += amount;
}
withdraw(amount) {
if (amount > this.#balance) {
throw new InsufficientFundsError(`Cannot withdraw ${amount}, balance is ${this.#balance}`);
}
this.#balance -= amount;
}
}
// bankAccount.test.js
import { BankAccount, InsufficientFundsError } from "./bankAccount.js";
describe("BankAccount", () => {
let account;
beforeEach(() => {
account = new BankAccount(100);
});
describe("deposit", () => {
test("increases the balance", () => {
account.deposit(50);
expect(account.balance).toBe(150);
});
test("rejects a non-positive amount", () => {
expect(() => account.deposit(0)).toThrow("must be positive");
});
});
describe("withdraw", () => {
test("decreases the balance", () => {
account.withdraw(40);
expect(account.balance).toBe(60);
});
test("throws InsufficientFundsError when overdrawing", () => {
expect(() => account.withdraw(200)).toThrow(InsufficientFundsError);
});
});
});
Common mistakes
- Calling the function directly inside
expect(...)when testing for a thrown error (expect(divide(10, 0)).toThrow()) instead of wrapping it in an arrow function (expect(() => divide(10, 0)).toThrow()) — the direct call throws immediately, before Jest's matcher ever runs, producing a confusing test failure unrelated to the assertion itself. - Using
toBeto compare two objects or arrays — it uses reference equality, so two structurally identical but separately created objects will failtoBeeven though they "look the same." UsetoEqualfor anything beyond a primitive. - Forgetting
awaitonexpect(asyncFn()).rejects.toThrow(...)(or forgetting to mark the test functionasync) — without it, the test can report as passing before the assertion actually ran. - Mocking so much of the system under test that the test no longer verifies real behavior at all — mock the external dependency (network, database, clock), not the function actually being tested.
Interview questions
Q: What's the difference between Jest's toBe and toEqual matchers?
toBe checks strict reference equality (via Object.is), which is correct for primitives but fails for two separately created objects or arrays that merely have the same contents. toEqual performs a deep, structural comparison instead, recursively checking that all properties match — the matcher you want for comparing objects, arrays, or anything returned from a function that builds a new instance each time.
Q: Why does testing an async function that's expected to reject require await expect(promise).rejects.toThrow(...) instead of a synchronous assertion?
Because the failure happens asynchronously — the Promise returned by the function under test rejects at some future point, not the instant it's called. rejects unwraps that Promise's rejection so .toThrow(...) can inspect it, and await is required so Jest actually waits for the Promise to settle before the test function returns; without await, the test could finish (and report as passing) before the assertion ever had a chance to run.