Mocking and Async Testing

Most of the code you write is not a pure function that takes a number and returns a number. It fetches data from APIs, reads files, and depends on other modules. To test that kind of code you need two things: a way to handle asynchronous operations, and a way to mock dependencies so you can test one piece in isolation.

Testing Async Code

Jest can test async code without any extra setup. The most common approach is async/await:

test("fetches user data", async () => {
  const data = await fetchUser(1);
  expect(data.name).toBe("Alice");
});

For testing that a promise rejects, use rejects:

test("handles not found error", async () => {
  await expect(fetchUser(-1)).rejects.toThrow("Not found");
});

You may also encounter the older done callback style in legacy code:

test("callback-based async", (done) => {
  fetchData((error, data) => {
    if (error) return done(error);
    expect(data).toBe("result");
    done();
  });
});

My advice is to prefer async/await for new tests. It is cleaner and easier to debug.

Creating Mock Functions with jest.fn()

A mock function is a fake function that records how it was called. You create one with jest.fn():

const mockFn = jest.fn();
mockFn("hello");

expect(mockFn).toHaveBeenCalled();  // true
expect(mockFn).toHaveBeenCalledWith("hello");  // true
expect(mockFn).toHaveBeenCalledTimes(1);  // true

This is useful when you need to verify that your code calls a callback or handler with the right arguments and the right number of times, without depending on the real implementation.

Controlling Mock Return Values and Implementations

You can tell a mock what to return:

const mockFn = jest.fn();

// Always return the same value
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);  // 42

// Return different values on consecutive calls
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2);
expect(mockFn()).toBe(1);  // 1
expect(mockFn()).toBe(2);  // 2

// Provide a custom implementation
mockFn.mockImplementation((x) => x * 2);
expect(mockFn(5)).toBe(10);  // 10

For async code, use mockResolvedValue (or mockRejectedValue):

const fetchMock = jest.fn();
fetchMock.mockResolvedValue({ name: "Alice" });

const result = await fetchMock();
expect(result.name).toBe("Alice");  // "Alice"

Mocking Entire Modules

Sometimes you want to replace an entire module with mocks. This is common when testing code that depends on an API client or database layer:

jest.mock("./api", () => ({
  fetchUser: jest.fn().mockResolvedValue({ name: "Alice" }),
}));

const { fetchUser } = require("./api");

test("uses mocked API", async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe("Alice");
});

The jest.mock call replaces the real module with your fake version. The code under test imports the mock the same way it would import the real module.

If you are testing native ESM modules, mocking works differently. You use jest.unstable_mockModule together with a dynamic import().

Managing State with Setup and Teardown

When multiple tests share setup logic (like connecting to a database or resetting data), Jest provides lifecycle hooks:

describe("Database tests", () => {
  beforeAll(async () => {
    await connectDatabase(); // runs once before all tests
  });

  afterAll(async () => {
    await disconnectDatabase(); // runs once after all tests
  });

  beforeEach(() => {
    resetTestData(); // runs before each test
  });

  afterEach(() => {
    cleanupTestData(); // runs after each test
  });

  test("reads a record", () => {
    // test logic here
  });
});

Keep in mind that beforeEach and afterEach run for every test in the describe block, while beforeAll and afterAll run only once. Use beforeEach to make sure each test starts with a clean state. That way one test cannot leave behind data that changes the result of the next one.