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

Bash
npm install --save-dev jest

Add a script to package.json so npm test runs the suite:

JSON
{
  "scripts": {
    "test": "jest"
  }
}

The code under test

Javascript
// 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;
}
Javascript
// 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:

Javascript
// 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");
  });
});
Bash
npm test
Plaintext
 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

Javascript
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:

Javascript
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:

Javascript
// api.js
export function fetchFromApi(url) {
  return fetch(url).then(r => r.json());
}
Javascript
// 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:

Javascript
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

Javascript
// 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;
  }
}
Javascript
// 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 toBe to compare two objects or arrays — it uses reference equality, so two structurally identical but separately created objects will fail toBe even though they "look the same." Use toEqual for anything beyond a primitive.
  • Forgetting await on expect(asyncFn()).rejects.toThrow(...) (or forgetting to mark the test function async) — 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.