在上一篇測試指南中,我們介紹了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
中定義回撥函式,或者透過 mockReturnValue
、mockReturnValueOnce
方法定義返回值。
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);
});
});
也可以直接透過 resolves
和 rejects
獲取響應的所有引數並進行匹配:
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 的基本使用和高階配置。更多有關前端工程化的內容,請參考我的其他博文,持續更新中~