Skip to content
⚠️ This article was written in 2019. Some content may be outdated.

Jest 模組 Mock 技巧

寫單元測試最頭疼的就是依賴問題——你測的是 A 模組,但 A 依賴了 B、C、D,其中 C 還會發網路請求。Mock 解決的就是這個問題:用假的替代品替換真實依賴,讓你只關注被測邏輯。

jest.fn():Mock 函式

最基礎的 Mock 工具,建立一個可以追蹤呼叫情況的空函式:

javascript
// 被測函式
function forEach(items, callback) {
  for (let i = 0; i < items.length; i++) {
    callback(items[i]);
  }
}

test("forEach 遍歷並呼叫回撥", () => {
  const mockCallback = jest.fn((x) => x * 10);
  forEach([1, 2, 3], mockCallback);

  // 斷言呼叫次數
  expect(mockCallback.mock.calls.length).toBe(3);

  // 斷言每次呼叫的引數
  expect(mockCallback.mock.calls[0][0]).toBe(1);
  expect(mockCallback.mock.calls[1][0]).toBe(2);

  // 斷言返回值
  expect(mockCallback.mock.results[0].value).toBe(10);
});

mock.calls 和 mock.results

javascript
const mockFn = jest.fn((a, b) => a + b);

mockFn(1, 2);
mockFn(3, 4);

// mock.calls: 每次呼叫的引數陣列
expect(mockFn.mock.calls).toEqual([[1, 2], [3, 4]]);

// mock.results: 每次呼叫的返回值
expect(mockFn.mock.results).toEqual([
  { type: "return", value: 3 },
  { type: "return", value: 7 },
]);

鏈式返回值

javascript
const mockFn = jest.fn();
mockFn
  .mockReturnValueOnce("first call")
  .mockReturnValueOnce("second call")
  .mockReturnValue("default");

expect(mockFn()).toBe("first call");
expect(mockFn()).toBe("second call");
expect(mockFn()).toBe("default");

非同步 Mock

javascript
const mockFn = jest.fn();

// 模擬 Promise 成功
mockFn.mockResolvedValue({ data: "ok" });
await expect(mockFn()).resolves.toEqual({ data: "ok" });

// 模擬 Promise 失敗
mockFn.mockRejectedValue(new Error("network error"));
await expect(mockFn()).rejects.toThrow("network error");

jest.mock():Mock 整個模組

當被測程式碼 import 了外部模組時,用 jest.mock() 替換整個模組:

javascript
// api.js
export function fetchUser(id) {
  return fetch(`/api/user/${id}`).then((r) => r.json());
}

// userService.js
import { fetchUser } from "./api";

export async function getUserName(id) {
  const user = await fetchUser(id);
  return user.name.toUpperCase();
}
javascript
// userService.test.js
import { getUserName } from "./userService";
import { fetchUser } from "./api";

// 自動 mock 整個 api 模組
jest.mock("./api");

test("getUserName 返回大寫使用者名稱", async () => {
  fetchUser.mockResolvedValue({ id: 1, name: "alice" });

  const name = await getUserName(1);
  expect(name).toBe("ALICE");
  expect(fetchUser).toHaveBeenCalledWith(1);
});

Mock 第三方庫

javascript
jest.mock("axios");
import axios from "axios";

test("getUserData 返回使用者資料", async () => {
  axios.get.mockResolvedValue({
    data: { id: 1, name: "Alice" },
  });

  const result = await getUserData(1);
  expect(result.name).toBe("Alice");
  expect(axios.get).toHaveBeenCalledWith("/api/user/1");
});

Mock 部分模組

javascript
jest.mock("./utils", () => ({
  ...jest.requireActual("./utils"), // 保留其他函式的真實實現
  formatDate: jest.fn(() => "2019-09-19"), // 只 mock 這一個
}));

jest.spyOn():監視函式呼叫

spyOn 在保留原始實現的同時,追蹤函式呼叫:

javascript
const calculator = {
  add: (a, b) => a + b,
  log: (msg) => console.log(msg),
};

test("add 方法正常工作並可被監視", () => {
  const spy = jest.spyOn(calculator, "add");

  const result = calculator.add(2, 3);
  expect(result).toBe(5); // 原始實現正常工作
  expect(spy).toHaveBeenCalledWith(2, 3);

  spy.mockRestore();
});

替換瀏覽器 API

javascript
test("臨時替換 localStorage.setItem", () => {
  const spy = jest.spyOn(Storage.prototype, "setItem");

  localStorage.setItem("token", "abc123");
  expect(spy).toHaveBeenCalledWith("token", "abc123");

  spy.mockRestore();
});

實戰:Mock 完整的 API 請求流程

javascript
// services/orderService.js
import axios from "axios";

export async function createOrder(items) {
  const res = await axios.post("/api/orders", { items });
  if (res.data.code !== 0) {
    throw new Error(res.data.message);
  }
  return res.data.data;
}
javascript
// services/orderService.test.js
jest.mock("axios");
import axios from "axios";
import { createOrder } from "./orderService";

describe("createOrder", () => {
  const items = [{ id: 1, qty: 2 }];

  test("建立成功返回訂單資料", async () => {
    axios.post.mockResolvedValue({
      data: { code: 0, data: { orderId: "ORD-001" } },
    });

    const order = await createOrder(items);
    expect(order.orderId).toBe("ORD-001");
    expect(axios.post).toHaveBeenCalledWith("/api/orders", { items });
  });

  test("業務錯誤丟擲異常", async () => {
    axios.post.mockResolvedValue({
      data: { code: 1001, message: "庫存不足" },
    });

    await expect(createOrder(items)).rejects.toThrow("庫存不足");
  });

  test("網路錯誤向上丟擲", async () => {
    axios.post.mockRejectedValue(new Error("Network Error"));
    await expect(createOrder(items)).rejects.toThrow("Network Error");
  });
});

Mock 定時器

javascript
test("3 秒後執行回撥", () => {
  jest.useFakeTimers();

  const callback = jest.fn();
  setTimeout(callback, 3000);

  jest.advanceTimersByTime(2000);
  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1000);
  expect(callback).toHaveBeenCalledTimes(1);

  jest.useRealTimers();
});

常見坑

1. Mock 位置不對

jest.mock() 必須在檔案頂層呼叫,不能放在 beforeEachtest 裡。Jest 會自動提升 mock 呼叫到檔案頂部。

2. 忘記 mockRestore

jest.spyOn 不呼叫 mockRestore,後續測試可能受影響。建議放在 afterEach 裡:

javascript
afterEach(() => {
  jest.restoreAllMocks();
});

3. Mock 的模組路徑不匹配

jest.mock('./api') 中的路徑必須和被測檔案裡的 import 路徑完全一致。

4. 混用 jest.mock 和 require

如果用了 jest.mock,在測試檔案裡用 import 而不是 require,否則 mock 可能不生效。

小結

  • jest.fn() 建立可追蹤的 mock 函式,可以檢查呼叫次數、引數、返回值
  • jest.mock() 替換整個模組,適合 mock 第三方庫和內部依賴
  • jest.spyOn() 保留原始實現的同時追蹤呼叫,適合監視而非替換
  • Mock 非同步操作用 mockResolvedValue / mockRejectedValue
  • Mock 定時器用 jest.useFakeTimers() + jest.advanceTimersByTime()
  • 記得在 afterEach 中呼叫 jest.restoreAllMocks() 避免測試間互相影響

MIT Licensed