在上一篇測試指南中,我們介紹了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 的基本使用和高級配置。更多有關前端工程化的內容,請參考我的其他博文,持續更新中~