全面掌握 Jest:從零開始的測試指南(下篇)

一颗冰淇淋發表於2024-09-18

在上一篇測試指南中,我們介紹了Jest 的背景、如何初始化專案、常用的匹配器語法以及鉤子函式的使用。這一篇篇將繼續深入探討 Jest 的高階特性,包括 Mock 函式、非同步請求的處理、Mock 請求的模擬、類的模擬以及定時器的模擬、snapshot 的使用。透過這些技術,我們將能夠更高效地編寫和維護測試用例,尤其是在處理複雜非同步邏輯和外部依賴時。

Mock 函式

假設存在一個 runCallBack 函式,其作用是判斷入參是否為函式,如果是,則執行傳入的函式。

export const runCallBack = (callback) => {
  typeof callback == "function" && callback();
};

編寫測試用例

我們先嚐試編寫它的測試用例:

import { runCallBack } from './func';
test("測試 runCallBack", () => {
  const fn = () => {
    return "hello";
  };
  expect(runCallBack(fn)).toBe("hello");
});

此時,命令列會報錯提示 runCallBack(fn) 執行的返回值為 undefined,而不是 "hello"。如果期望得到正確的返回值,就需要修改原始的 runCallBack 函式,但這種做法不符合我們的測試預期——我們不希望為了測試而改變原有的業務功能。

這時,mock 函式就可以很好地解決這個問題。mock 可以用來模擬一個函式,並可以自定義函式的返回值。我們可以透過 mock 函式來分析其呼叫次數、入參和出參等資訊。

使用 mock 解決問題

上述測試用例可以改為如下形式:

test("測試 runCallBack", () => {
  const fn = jest.fn();
  runCallBack(fn);
  expect(fn).toBeCalled();
  expect(fn.mock.calls.length).toBe(1);
});

這裡,toBeCalled() 用於檢查函式是否被呼叫過,fn.mock.calls.length 用於檢查函式被呼叫的次數。

mock 屬性中還有一些有用的引數:

  • calls: 陣列,儲存著每次呼叫時的入參。
  • instances: 陣列,儲存著每次呼叫時的例項物件。
  • invocationCallOrder: 陣列,儲存著每次呼叫的順序。
  • results: 陣列,儲存著每次呼叫的執行結果。

自定義返回值

mock 還可以自定義返回值。可以在 jest.fn 中定義回撥函式,或者透過 mockReturnValuemockReturnValueOnce 方法定義返回值。

test("測試 runCallBack 返回值", () => {
  const fn = jest.fn(() => {
    return "hello";
  });
  createObject(fn);
  expect(fn.mock.results[0].value).toBe("hello");
 
  fn.mockReturnValue('alice') // 定義返回值
  createObject(fn);
  expect(fn.mock.results[1].value).toBe("alice");
  fn.mockReturnValueOnce('x') // 定義只返回一次的返回值
  createObject(fn);
  expect(fn.mock.results[2].value).toBe("x");
  createObject(fn);
  expect(fn.mock.results[3].value).toBe("alice");
});

建構函式的模擬

建構函式作為一種特殊的函式,也可以透過 mock 實現模擬。

// func.js
export const createObject = (constructFn) => {
  typeof constructFn == "function" && new constructFn();
};

// func.test.js
import { createObject } from './func';
test("測試 createObject", () => {
    const fn = jest.fn();
    createObject(fn);
    expect(fn).toBeCalled();
    expect(fn.mock.calls.length).toBe(1);
});

透過使用 mock 函式,我們可以更好地模擬函式的行為,並分析其呼叫情況。這樣不僅可以避免修改原有業務邏輯,還能確保測試的準確性和可靠性。

非同步程式碼

在處理非同步請求時,我們期望 Jest 能夠等待非同步請求結束後再對結果進行校驗。測試請求介面地址使用 http://httpbin.org/get,可以將引數透過 query 的形式拼接在 URL 上,如 http://httpbin.org/get?name=alice。這樣介面返回的資料中將攜帶 { name: 'alice' },可以依此來對程式碼進行校驗。

以下分別透過非同步請求回撥函式、Promise 鏈式呼叫、await 的方式獲取響應結果來進行分析。

回撥函式型別

回撥函式的形式透過 done() 函式告訴 Jest 非同步測試已經完成。

func.js 檔案中透過 Axios 傳送 GET 請求:

const axios = require("axios");

export const getDataCallback = (url, callbackFn) => {
  axios.get(url).then(
    (res) => {
      callbackFn && callbackFn(res.data);
    },
    (error) => {
      callbackFn && callbackFn(error);
    }
  );
};

func.test.js 檔案中引入傳送請求的方法:

import { getDataCallback } from "./func";
test("回撥函式型別-成功", (done) => {
  getDataCallback("http://httpbin.org/get?name=alice", (data) => {
    expect(data.args).toEqual({ name: "alice" });
    done();
  });
});

test("回撥函式型別-失敗", (done) => {
  getDataCallback("http://httpbin.org/xxxx", (data) => {
    expect(data.message).toContain("404");
    done();
  });
});

promise型別

Promise 型別的用例中,需要使用 return 關鍵字來告訴 Jest 測試用例的結束時間。

// func.js
export const getDataPromise = (url) => {
  return axios.get(url);
};

Promise 型別的函式可以透過 then 函式來處理:

// func.test.js
test("Promise 型別-成功", () => {
  return getDataPromise("http://httpbin.org/get?name=alice").then((res) => {
    expect(res.data.args).toEqual({ name: "alice" });
  });
});

test("Promise 型別-失敗", () => {
  return getDataPromise("http://httpbin.org/xxxx").catch((res) => {
    expect(res.response.status).toBe(404);
  });
});

也可以直接透過 resolvesrejects 獲取響應的所有引數並進行匹配:

test("Promise 型別-成功匹配物件t", () => {
  return expect(
    getDataPromise("http://httpbin.org/get?name=alice")
  ).resolves.toMatchObject({
    status: 200,
  });
});

test("Promise 型別-失敗丟擲異常", () => {
  return expect(getDataPromise("http://httpbin.org/xxxx")).rejects.toThrow();
});

await 型別

上述 getDataPromise 也可以透過 await 的形式來編寫測試用例:

test("await 型別-成功", async () => {
  const res = await getDataPromise("http://httpbin.org/get?name=alice");
  expect(res.data.args).toEqual({ name: "alice" });
});

test("await 型別-失敗", async () => {
  try {
    await getDataPromise("http://httpbin.org/xxxx")
  } catch(e){
    expect(e.status).toBe(404)
  }
});

透過上述幾種方式,可以有效地編寫非同步函式的測試用例。回撥函式Promise 鏈式呼叫以及 await 的方式各有優劣,可以根據具體情況選擇合適的方法。

Mock 請求/類/Timers

在前面處理非同步程式碼時,是根據真實的介面內容來進行校驗的。然而,這種方式並不總是最佳選擇。一方面,每個校驗都需要傳送網路請求獲取真實資料,這會導致測試用例執行時間較長;另一方面,介面格式是否滿足要求是後端開發者需要著重測試的內容,前端測試用例並不需要涵蓋這部分內容。

在之前的函式測試中,我們使用了 Mock 來模擬函式。實際上,Mock 不僅可以用來模擬函式,還可以模擬網路請求和檔案。

Mock 網路請求

Mock 網路請求有兩種方式:一種是直接模擬傳送請求的工具(如 Axios),另一種是模擬引入的檔案。

直接模擬 Axios

首先,在 request.js 中定義傳送網路請求的邏輯:

import axios from "axios";

export const fetchData = () => {
  return axios.get("/").then((res) => res.data);
};

然後,使用 jest 模擬 axios 即 jest.mock("axios"),並透過 axios.get.mockResolvedValue 來定義響應成功的返回值:

const axios = require("axios");
import { fetchData } from "./request";

jest.mock("axios");
test("測試 fetchData", () => {
  axios.get.mockResolvedValue({
    data: "hello",
  });
  return fetchData().then((data) => {
    expect(data).toEqual("hello");
  });
});
模擬引入的檔案

如果希望模擬 request.js 檔案,可以在當前目錄下建立 __mocks__ 資料夾,並在其中建立同名的 request.js 檔案來定義模擬請求的內容:

// __mocks__/request.js
export const fetchData = () => {
  return new Promise((resolve, reject) => {
    resolve("world");
  });
};

使用 jest.mock('./request') 語法,Jest 在執行測試用例時會自動將真實的請求檔案內容替換成 __mocks__/request.js 的檔案內容:

// request.test.js
import { fetchData } from "./request";
jest.mock("./request");

test("測試 fetchData", () => {
  return fetchData().then((data) => {
    expect(data).toEqual("world");
  });
});

如果部分內容需要從真實的檔案中獲取,可以透過 jest.requireActual() 函式來實現。取消模擬則可以使用 jest.unmock()

Mock 類

假設在業務場景中定義了一個工具類,類中有多個方法,我們需要對類中的方法進行測試。

// util.js
export default class Util {
  add(a, b) {
    return a + b;
  }
  create() {}
}

// util.test.js
import Util from "./util";
test("測試add方法", () => {
  const util = new Util();
  expect(util.add(2, 5)).toEqual(7);
});

此時,另一個檔案如 useUtil.js 也用到了 Util 類:

// useUtil.js
import Util from "./util";

export function useUtil() {
  const util = new Util();
  util.add(2, 6);
  util.create();
}

在編寫 useUtil 的測試用例時,我們只希望測試當前檔案,並不希望重新測試 Util 類的功能。這時也可以透過 Mock 來實現。

__mock__ 資料夾下建立模擬檔案

可以在 __mock__ 資料夾下建立 util.js 檔案,檔案中定義模擬函式:

// __mock__/util.js
const Util = jest.fn()
Util.prototype.add = jest.fn()
Util.prototype.create = jest.fn();
export default Util;

// useUtil.test.js
jest.mock("./util");
import Util from "./util";
import { useUtilFunc } from "./useUtil";

test("useUtil", () => {
  useUtilFunc();
  expect(Util).toHaveBeenCalled();
  expect(Util.mock.instances[0].add).toHaveBeenCalled();
  expect(Util.mock.instances[0].create).toHaveBeenCalled();
});
在當前 .test.js 檔案定義模擬函式

也可以在當前 .test.js 檔案中定義模擬函式:

// useUtil.test.js
import { useUtilFunc } from "./useUtil";
import Util from "./util";
jest.mock("./util", () => {
  const Util = jest.fn();
  Util.prototype.add = jest.fn();
  Util.prototype.create = jest.fn();
  return Util
});
test("useUtil", () => {
  useUtilFunc();
  expect(Util).toHaveBeenCalled();
  expect(Util.mock.instances[0].add).toHaveBeenCalled();
  expect(Util.mock.instances[0].create).toHaveBeenCalled();
});

這兩種方式都可以模擬類。

Timers

在定義一些功能函式時,比如防抖和節流,經常會使用 setTimeout 來推遲函式的執行。這類功能也可以透過 Mock 來模擬測試。

// timer.js
export const timer = (callback) => {
  setTimeout(() => {
    callback();
  }, 3000);
};
使用 done 非同步執行

一種方式是使用 done 來非同步執行:

import { timer } from './timer'

test("timer", (done) => {
  timer(() => {
    done();
    expect(1).toBe(1);
  });
});
使用 Jest 的 timers 方法

另一種方式是使用 Jest 提供的 timers 方法,透過 useFakeTimers 啟用假定時器模式,runAllTimers 來手動執行所有的定時器,並使用 toHaveBeenCalledTimes 來檢查呼叫次數:

beforeEach(()=>{
    jest.useFakeTimers()
})

test('timer測試', ()=>{
    const fn = jest.fn();
    timer(fn);
    jest.runAllTimers();
    expect(fn).toHaveBeenCalledTimes(1);
})

此外,還有 runOnlyPendingTimers 方法用來執行當前位於佇列中的 timers,以及 advanceTimersByTime 方法用來快進 X 毫秒。

例如,在存在巢狀的定時器時,可以透過 advanceTimersByTime 快進來模擬:

// timer.js
export const timerTwice = (callback) => {
  setTimeout(() => {
    callback();
    setTimeout(() => {
      callback();
    }, 3000);
  }, 3000);
};

// timer.test.js
import { timerTwice } from "./timer";
test("timerTwice 測試", () => {
  const fn = jest.fn();
  timerTwice(fn);
  jest.advanceTimersByTime(3000);
  expect(fn).toHaveBeenCalledTimes(1);
  jest.advanceTimersByTime(3000);
  expect(fn).toHaveBeenCalledTimes(2);
});

無論是模擬網路請求、類還是定時器,Mock 都是一個強大的工具,可以幫助我們構建可靠且高效的測試用例。

snapshot

假設當前存在一個配置,配置的內容可能會經常變更,如下所示:

export const generateConfig = () => {
  return {
    server: "http://localhost",
    port: 8001,
    domain: "localhost",
  };
};
toEqual 匹配

如果對它進行測試用例編寫,最簡單的方式就是使用 toEqual 匹配,如下所示:

import { generateConfig } from "./snapshot";

test("測試 generateConfig", () => {
  expect(generateConfig()).toEqual({
    server: "http://localhost",
    port: 8001,
    domain: "localhost",
  });
});

但是這種方式存在一些問題:每當配置檔案發生變更時,都需要修改測試用例。為了避免測試用例頻繁修改,可以透過 snapshot 快照來解決這個問題。

toMatchSnapshot

透過 toMatchSnapshot 函式生成快照:

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchSnapshot();
});

第一次執行 toMatchSnapshot 時,會生成一個 __snapshots__ 資料夾,裡面存放著 xxx.test.js.snap 這樣的檔案,內容是當前配置的執行結果。

第二次執行時,會生成一個新的快照並與已有的快照進行比較。如果相同則測試透過;如果不相同,測試用例不透過,並且在命令列會提示你是否需要更新快照,如 “1 snapshot failed from 1 test suite. Inspect your code changes or press u to update them”。

按下 u 鍵之後,測試用例會透過,並且覆蓋原有的快照。

快照的值不同

如果該函式每次的值不同,生成的快照也不相同,例如每次呼叫函式返回時間戳:

export const generateConfig = () => {
  return {
    server: "http://localhost",
    port: 8002,
    domain: "localhost",
    date: new Date()
  };
};

在這種情況下,toMatchSnapshot 可以接受一個物件作為引數,該物件用於描述快照中的某些欄位應該如何匹配:

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchSnapshot({
    date: expect.any(Date)
  });
});
行內快照

上述的快照是在 __snapshots__ 資料夾下生成的,還有一種方式是透過 toMatchInlineSnapshot 在當前的 .test.js 檔案中生成。需要注意的是,這種方式通常需要配合 prettier 工具來使用。

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchInlineSnapshot({
    date: expect.any(Date),
  });
});

測試用例透過後,該用例的格式如下:

test("測試 generateConfig", () => {
  expect(generateConfig()).toMatchInlineSnapshot({
  date: expect.any(Date)
}, `
{
  "date": Any<Date>,
  "domain": "localhost",
  "port": 8002,
  "server": "http://localhost",
}
`);
});

使用 snapshot 測試可以有效地減少頻繁修改測試用例的工作量。無論配置如何變化,只需要更新一次快照即可保持測試的一致性。

本篇及上一篇文章的內容合在一起涵蓋了 Jest 的基本使用和高階配置。更多有關前端工程化的內容,請參考我的其他博文,持續更新中~

相關文章