全面掌握 Jest:從零開(kāi)始的測(cè)試指南(下篇)
在上一篇測(cè)試指南中,我們介紹了Jest 的背景、如何初始化項(xiàng)目、常用的匹配器語(yǔ)法以及鉤子函數(shù)的使用。這一篇篇將繼續(xù)深入探討 Jest 的高級(jí)特性,包括 Mock 函數(shù)、異步請(qǐng)求的處理、Mock 請(qǐng)求的模擬、類(lèi)的模擬以及定時(shí)器的模擬、snapshot 的使用。通過(guò)這些技術(shù),我們將能夠更高效地編寫(xiě)和維護(hù)測(cè)試用例,尤其是在處理復(fù)雜異步邏輯和外部依賴(lài)時(shí)。
Mock 函數(shù)
假設(shè)存在一個(gè) runCallBack 函數(shù),其作用是判斷入?yún)⑹欠駷楹瘮?shù),如果是,則執(zhí)行傳入的函數(shù)。
export const runCallBack = (callback) => {
typeof callback == "function" && callback();
};
編寫(xiě)測(cè)試用例
我們先嘗試編寫(xiě)它的測(cè)試用例:
import { runCallBack } from './func';
test("測(cè)試 runCallBack", () => {
const fn = () => {
return "hello";
};
expect(runCallBack(fn)).toBe("hello");
});
此時(shí),命令行會(huì)報(bào)錯(cuò)提示 runCallBack(fn) 執(zhí)行的返回值為 undefined,而不是 "hello"。如果期望得到正確的返回值,就需要修改原始的 runCallBack 函數(shù),但這種做法不符合我們的測(cè)試預(yù)期——我們不希望為了測(cè)試而改變?cè)械臉I(yè)務(wù)功能。
這時(shí),mock 函數(shù)就可以很好地解決這個(gè)問(wèn)題。mock 可以用來(lái)模擬一個(gè)函數(shù),并可以自定義函數(shù)的返回值。我們可以通過(guò) mock 函數(shù)來(lái)分析其調(diào)用次數(shù)、入?yún)⒑统鰠⒌刃畔ⅰ?/p>
使用 mock 解決問(wèn)題
上述測(cè)試用例可以改為如下形式:
test("測(cè)試 runCallBack", () => {
const fn = jest.fn();
runCallBack(fn);
expect(fn).toBeCalled();
expect(fn.mock.calls.length).toBe(1);
});
這里,toBeCalled() 用于檢查函數(shù)是否被調(diào)用過(guò),fn.mock.calls.length 用于檢查函數(shù)被調(diào)用的次數(shù)。
mock 屬性中還有一些有用的參數(shù):
- calls: 數(shù)組,保存著每次調(diào)用時(shí)的入?yún)ⅰ?/li>
- instances: 數(shù)組,保存著每次調(diào)用時(shí)的實(shí)例對(duì)象。
- invocationCallOrder: 數(shù)組,保存著每次調(diào)用的順序。
- results: 數(shù)組,保存著每次調(diào)用的執(zhí)行結(jié)果。
自定義返回值
mock 還可以自定義返回值。可以在 jest.fn 中定義回調(diào)函數(shù),或者通過(guò) mockReturnValue、mockReturnValueOnce 方法定義返回值。
test("測(cè)試 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");
});
構(gòu)造函數(shù)的模擬
構(gòu)造函數(shù)作為一種特殊的函數(shù),也可以通過(guò) mock 實(shí)現(xiàn)模擬。
// func.js
export const createObject = (constructFn) => {
typeof constructFn == "function" && new constructFn();
};
// func.test.js
import { createObject } from './func';
test("測(cè)試 createObject", () => {
const fn = jest.fn();
createObject(fn);
expect(fn).toBeCalled();
expect(fn.mock.calls.length).toBe(1);
});
通過(guò)使用 mock 函數(shù),我們可以更好地模擬函數(shù)的行為,并分析其調(diào)用情況。這樣不僅可以避免修改原有業(yè)務(wù)邏輯,還能確保測(cè)試的準(zhǔn)確性和可靠性。
異步代碼
在處理異步請(qǐng)求時(shí),我們期望 Jest 能夠等待異步請(qǐng)求結(jié)束后再對(duì)結(jié)果進(jìn)行校驗(yàn)。測(cè)試請(qǐng)求接口地址使用 http://httpbin.org/get,可以將參數(shù)通過(guò) query 的形式拼接在 URL 上,如 http://httpbin.org/get?name=alice。這樣接口返回的數(shù)據(jù)中將攜帶 { name: 'alice' },可以依此來(lái)對(duì)代碼進(jìn)行校驗(yàn)。

以下分別通過(guò)異步請(qǐng)求回調(diào)函數(shù)、Promise 鏈?zhǔn)秸{(diào)用、await 的方式獲取響應(yīng)結(jié)果來(lái)進(jìn)行分析。
回調(diào)函數(shù)類(lèi)型
回調(diào)函數(shù)的形式通過(guò) done() 函數(shù)告訴 Jest 異步測(cè)試已經(jīng)完成。
在 func.js 文件中通過(guò) Axios 發(fā)送 GET 請(qǐng)求:
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 文件中引入發(fā)送請(qǐng)求的方法:
import { getDataCallback } from "./func";
test("回調(diào)函數(shù)類(lèi)型-成功", (done) => {
getDataCallback("http://httpbin.org/get?name=alice", (data) => {
expect(data.args).toEqual({ name: "alice" });
done();
});
});
test("回調(diào)函數(shù)類(lèi)型-失敗", (done) => {
getDataCallback("http://httpbin.org/xxxx", (data) => {
expect(data.message).toContain("404");
done();
});
});
promise類(lèi)型
在 Promise 類(lèi)型的用例中,需要使用 return 關(guān)鍵字來(lái)告訴 Jest 測(cè)試用例的結(jié)束時(shí)間。
// func.js
export const getDataPromise = (url) => {
return axios.get(url);
};
Promise 類(lèi)型的函數(shù)可以通過(guò) then 函數(shù)來(lái)處理:
// func.test.js
test("Promise 類(lèi)型-成功", () => {
return getDataPromise("http://httpbin.org/get?name=alice").then((res) => {
expect(res.data.args).toEqual({ name: "alice" });
});
});
test("Promise 類(lèi)型-失敗", () => {
return getDataPromise("http://httpbin.org/xxxx").catch((res) => {
expect(res.response.status).toBe(404);
});
});
也可以直接通過(guò) resolves 和 rejects 獲取響應(yīng)的所有參數(shù)并進(jìn)行匹配:
test("Promise 類(lèi)型-成功匹配對(duì)象t", () => {
return expect(
getDataPromise("http://httpbin.org/get?name=alice")
).resolves.toMatchObject({
status: 200,
});
});
test("Promise 類(lèi)型-失敗拋出異常", () => {
return expect(getDataPromise("http://httpbin.org/xxxx")).rejects.toThrow();
});
await 類(lèi)型
上述 getDataPromise 也可以通過(guò) await 的形式來(lái)編寫(xiě)測(cè)試用例:
test("await 類(lèi)型-成功", async () => {
const res = await getDataPromise("http://httpbin.org/get?name=alice");
expect(res.data.args).toEqual({ name: "alice" });
});
test("await 類(lèi)型-失敗", async () => {
try {
await getDataPromise("http://httpbin.org/xxxx")
} catch(e){
expect(e.status).toBe(404)
}
});
通過(guò)上述幾種方式,可以有效地編寫(xiě)異步函數(shù)的測(cè)試用例。回調(diào)函數(shù)、Promise 鏈?zhǔn)秸{(diào)用以及 await 的方式各有優(yōu)劣,可以根據(jù)具體情況選擇合適的方法。
Mock 請(qǐng)求/類(lèi)/Timers
在前面處理異步代碼時(shí),是根據(jù)真實(shí)的接口內(nèi)容來(lái)進(jìn)行校驗(yàn)的。然而,這種方式并不總是最佳選擇。一方面,每個(gè)校驗(yàn)都需要發(fā)送網(wǎng)絡(luò)請(qǐng)求獲取真實(shí)數(shù)據(jù),這會(huì)導(dǎo)致測(cè)試用例執(zhí)行時(shí)間較長(zhǎng);另一方面,接口格式是否滿(mǎn)足要求是后端開(kāi)發(fā)者需要著重測(cè)試的內(nèi)容,前端測(cè)試用例并不需要涵蓋這部分內(nèi)容。
在之前的函數(shù)測(cè)試中,我們使用了 Mock 來(lái)模擬函數(shù)。實(shí)際上,Mock 不僅可以用來(lái)模擬函數(shù),還可以模擬網(wǎng)絡(luò)請(qǐng)求和文件。
Mock 網(wǎng)絡(luò)請(qǐng)求
Mock 網(wǎng)絡(luò)請(qǐng)求有兩種方式:一種是直接模擬發(fā)送請(qǐng)求的工具(如 Axios),另一種是模擬引入的文件。
直接模擬 Axios
首先,在 request.js 中定義發(fā)送網(wǎng)絡(luò)請(qǐng)求的邏輯:
import axios from "axios";
export const fetchData = () => {
return axios.get("/").then((res) => res.data);
};
然后,使用 jest 模擬 axios 即 jest.mock("axios"),并通過(guò) axios.get.mockResolvedValue 來(lái)定義響應(yīng)成功的返回值:
const axios = require("axios");
import { fetchData } from "./request";
jest.mock("axios");
test("測(cè)試 fetchData", () => {
axios.get.mockResolvedValue({
data: "hello",
});
return fetchData().then((data) => {
expect(data).toEqual("hello");
});
});
模擬引入的文件
如果希望模擬 request.js 文件,可以在當(dāng)前目錄下創(chuàng)建 __mocks__ 文件夾,并在其中創(chuàng)建同名的 request.js 文件來(lái)定義模擬請(qǐng)求的內(nèi)容:
// __mocks__/request.js
export const fetchData = () => {
return new Promise((resolve, reject) => {
resolve("world");
});
};
使用 jest.mock('./request') 語(yǔ)法,Jest 在執(zhí)行測(cè)試用例時(shí)會(huì)自動(dòng)將真實(shí)的請(qǐng)求文件內(nèi)容替換成 __mocks__/request.js 的文件內(nèi)容:
// request.test.js
import { fetchData } from "./request";
jest.mock("./request");
test("測(cè)試 fetchData", () => {
return fetchData().then((data) => {
expect(data).toEqual("world");
});
});
如果部分內(nèi)容需要從真實(shí)的文件中獲取,可以通過(guò) jest.requireActual() 函數(shù)來(lái)實(shí)現(xiàn)。取消模擬則可以使用 jest.unmock()。
Mock 類(lèi)
假設(shè)在業(yè)務(wù)場(chǎng)景中定義了一個(gè)工具類(lèi),類(lèi)中有多個(gè)方法,我們需要對(duì)類(lèi)中的方法進(jìn)行測(cè)試。
// util.js
export default class Util {
add(a, b) {
return a + b;
}
create() {}
}
// util.test.js
import Util from "./util";
test("測(cè)試add方法", () => {
const util = new Util();
expect(util.add(2, 5)).toEqual(7);
});
此時(shí),另一個(gè)文件如 useUtil.js 也用到了 Util 類(lèi):
// useUtil.js
import Util from "./util";
export function useUtil() {
const util = new Util();
util.add(2, 6);
util.create();
}
在編寫(xiě) useUtil 的測(cè)試用例時(shí),我們只希望測(cè)試當(dāng)前文件,并不希望重新測(cè)試 Util 類(lèi)的功能。這時(shí)也可以通過(guò) Mock 來(lái)實(shí)現(xiàn)。
在 __mock__ 文件夾下創(chuàng)建模擬文件
可以在 __mock__ 文件夾下創(chuàng)建 util.js 文件,文件中定義模擬函數(shù):
// __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();
});
在當(dāng)前 .test.js 文件定義模擬函數(shù)
也可以在當(dāng)前 .test.js 文件中定義模擬函數(shù):
// 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();
});
這兩種方式都可以模擬類(lèi)。
Timers
在定義一些功能函數(shù)時(shí),比如防抖和節(jié)流,經(jīng)常會(huì)使用 setTimeout 來(lái)推遲函數(shù)的執(zhí)行。這類(lèi)功能也可以通過(guò) Mock 來(lái)模擬測(cè)試。
// timer.js
export const timer = (callback) => {
setTimeout(() => {
callback();
}, 3000);
};
使用 done 異步執(zhí)行
一種方式是使用 done 來(lái)異步執(zhí)行:
import { timer } from './timer'
test("timer", (done) => {
timer(() => {
done();
expect(1).toBe(1);
});
});
使用 Jest 的 timers 方法
另一種方式是使用 Jest 提供的 timers 方法,通過(guò) useFakeTimers 啟用假定時(shí)器模式,runAllTimers 來(lái)手動(dòng)運(yùn)行所有的定時(shí)器,并使用 toHaveBeenCalledTimes 來(lái)檢查調(diào)用次數(shù):
beforeEach(()=>{
jest.useFakeTimers()
})
test('timer測(cè)試', ()=>{
const fn = jest.fn();
timer(fn);
jest.runAllTimers();
expect(fn).toHaveBeenCalledTimes(1);
})
此外,還有 runOnlyPendingTimers 方法用來(lái)執(zhí)行當(dāng)前位于隊(duì)列中的 timers,以及 advanceTimersByTime 方法用來(lái)快進(jìn) X 毫秒。
例如,在存在嵌套的定時(shí)器時(shí),可以通過(guò) advanceTimersByTime 快進(jìn)來(lái)模擬:
// timer.js
export const timerTwice = (callback) => {
setTimeout(() => {
callback();
setTimeout(() => {
callback();
}, 3000);
}, 3000);
};
// timer.test.js
import { timerTwice } from "./timer";
test("timerTwice 測(cè)試", () => {
const fn = jest.fn();
timerTwice(fn);
jest.advanceTimersByTime(3000);
expect(fn).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(3000);
expect(fn).toHaveBeenCalledTimes(2);
});
無(wú)論是模擬網(wǎng)絡(luò)請(qǐng)求、類(lèi)還是定時(shí)器,Mock 都是一個(gè)強(qiáng)大的工具,可以幫助我們構(gòu)建可靠且高效的測(cè)試用例。
snapshot
假設(shè)當(dāng)前存在一個(gè)配置,配置的內(nèi)容可能會(huì)經(jīng)常變更,如下所示:
export const generateConfig = () => {
return {
server: "http://localhost",
port: 8001,
domain: "localhost",
};
};
toEqual 匹配
如果對(duì)它進(jìn)行測(cè)試用例編寫(xiě),最簡(jiǎn)單的方式就是使用 toEqual 匹配,如下所示:
import { generateConfig } from "./snapshot";
test("測(cè)試 generateConfig", () => {
expect(generateConfig()).toEqual({
server: "http://localhost",
port: 8001,
domain: "localhost",
});
});
但是這種方式存在一些問(wèn)題:每當(dāng)配置文件發(fā)生變更時(shí),都需要修改測(cè)試用例。為了避免測(cè)試用例頻繁修改,可以通過(guò) snapshot 快照來(lái)解決這個(gè)問(wèn)題。
toMatchSnapshot
通過(guò) toMatchSnapshot 函數(shù)生成快照:
test("測(cè)試 generateConfig", () => {
expect(generateConfig()).toMatchSnapshot();
});
第一次執(zhí)行 toMatchSnapshot 時(shí),會(huì)生成一個(gè) __snapshots__ 文件夾,里面存放著 xxx.test.js.snap 這樣的文件,內(nèi)容是當(dāng)前配置的執(zhí)行結(jié)果。
第二次執(zhí)行時(shí),會(huì)生成一個(gè)新的快照并與已有的快照進(jìn)行比較。如果相同則測(cè)試通過(guò);如果不相同,測(cè)試用例不通過(guò),并且在命令行會(huì)提示你是否需要更新快照,如 “1 snapshot failed from 1 test suite. Inspect your code changes or press u to update them”。
按下 u 鍵之后,測(cè)試用例會(huì)通過(guò),并且覆蓋原有的快照。
快照的值不同
如果該函數(shù)每次的值不同,生成的快照也不相同,例如每次調(diào)用函數(shù)返回時(shí)間戳:
export const generateConfig = () => {
return {
server: "http://localhost",
port: 8002,
domain: "localhost",
date: new Date()
};
};
在這種情況下,toMatchSnapshot 可以接受一個(gè)對(duì)象作為參數(shù),該對(duì)象用于描述快照中的某些字段應(yīng)該如何匹配:
test("測(cè)試 generateConfig", () => {
expect(generateConfig()).toMatchSnapshot({
date: expect.any(Date)
});
});
行內(nèi)快照
上述的快照是在 __snapshots__ 文件夾下生成的,還有一種方式是通過(guò) toMatchInlineSnapshot 在當(dāng)前的 .test.js 文件中生成。需要注意的是,這種方式通常需要配合 prettier 工具來(lái)使用。
test("測(cè)試 generateConfig", () => {
expect(generateConfig()).toMatchInlineSnapshot({
date: expect.any(Date),
});
});
測(cè)試用例通過(guò)后,該用例的格式如下:
test("測(cè)試 generateConfig", () => {
expect(generateConfig()).toMatchInlineSnapshot({
date: expect.any(Date)
}, `
{
"date": Any<Date>,
"domain": "localhost",
"port": 8002,
"server": "http://localhost",
}
`);
});
使用 snapshot 測(cè)試可以有效地減少頻繁修改測(cè)試用例的工作量。無(wú)論配置如何變化,只需要更新一次快照即可保持測(cè)試的一致性。
本篇及上一篇文章的內(nèi)容合在一起涵蓋了 Jest 的基本使用和高級(jí)配置。更多有關(guān)前端工程化的內(nèi)容,請(qǐng)參考我的其他博文,持續(xù)更新中~

浙公網(wǎng)安備 33010602011771號(hào)