書接上文戈钢,上篇說到了 React Testing library 的安裝和最基本用法。本篇繼續(xù)深挖一些較復(fù)雜的場(chǎng)景。
mock 測(cè)試
開始 RTL 測(cè)試前,我們稍微回顧一下 Jest 的 Mock 測(cè)試。
所謂 mock 測(cè)試就是在測(cè)試過程中钉寝,對(duì)于某些不容易構(gòu)造或者不容易獲取的對(duì)象,用一個(gè)虛擬的對(duì)象代替具體實(shí)現(xiàn)闸迷,以便繼續(xù)測(cè)試進(jìn)度的方式嵌纲。
其中最常用的 mock 方式有兩種:
- 創(chuàng)建一個(gè) mock 函數(shù),注入到目標(biāo)用例里腥沽, 如
jest.fn
- 設(shè)計(jì)一個(gè)手工的 mock 對(duì)象逮走,來覆蓋依賴模塊,如
jest.mock
jest.fn()
先來說 mock 函數(shù)注入今阳。我們寫一個(gè)最最基礎(chǔ)的 repeatTen
函數(shù)师溅,功能就是調(diào)用10
次其他函數(shù)。
function repeatTen(fn) {
for (let i = 0; i < 10; i++) {
fn();
}
}
repeatTen(() => console.log("Onion"));
repeatTen
函數(shù)的特點(diǎn)就是:它根本不關(guān)心 fn
的具體實(shí)現(xiàn)盾舌,只要保證 fn
一次性能跑到10
趟就行墓臭。這類單元測(cè)試就非常適合使用 mock 函數(shù)了。如下所示妖谴,我們以參數(shù)的形式窿锉,為 repeatTen
傳入一個(gè) mock 函數(shù)(onChang
),然后檢查 mock 函數(shù)的調(diào)用狀態(tài)膝舅,判定函數(shù)是否如期運(yùn)行就行了嗡载。
it("Count the calls of the onChange", () => {
const onChange = jest.fn();
repeatTen(onChange);
// assert that it is called 10 times
expect(onChange).toHaveBeenCalledTimes(10);
});
jest.mock()
上文 repeatTen
函數(shù)的實(shí)現(xiàn)比較單純,現(xiàn)實(shí)世界則復(fù)雜很多仍稀;有些函數(shù)的實(shí)現(xiàn)會(huì)依賴于三方庫鼻疮。在測(cè)試環(huán)境里你很難以傳入一個(gè) mock 函數(shù)的形式來模擬真實(shí)的運(yùn)行狀態(tài)。比如下面這個(gè)函數(shù)琳轿,通過 axios
調(diào)用 API 來返回?cái)?shù)據(jù)判沟,大家想想該怎么寫 test case。
const loadStories = () => axios.get("/stories");
我們要測(cè)試 loadStories
但又不大可能調(diào)用真實(shí)的 API崭篡,就只能對(duì) axios
的內(nèi)部模塊動(dòng)點(diǎn)小腦筋了——用 jest.mock("axios")
自動(dòng)模擬 axios 模塊挪哄。
Jest 提供了一個(gè)很有意思的依賴覆蓋方法——jest.mock("axios")
,它會(huì)給對(duì)象模塊——axios——的.get
方法提供一個(gè) mockResolvedValue
琉闪。通俗來說迹炼,就是讓axios.get("/stories")
返回一個(gè)假的 response;而這個(gè) response 的數(shù)據(jù)是我們事先準(zhǔn)備好的颠毙。
看一下寫 mock 依賴測(cè)試的主要流程:
- 利用
jest.mock(...)
覆蓋用例函數(shù)內(nèi)部 import 的依賴 - 為用例函數(shù)內(nèi)部使用的某個(gè)方法構(gòu)造一個(gè) mock 返回
- 斷言用例函數(shù)的返回結(jié)果
import axios from "axios";
// step1: override a module
jest.mock("axios");
const mockStories = [
{ objectID: "1", title: "Hello" },
{ objectID: "2", title: "React" },
];
it("load stories by axios", async () => {
// step2: make a fake response
const response = { data: mockStories };
axios.get.mockResolvedValue(response);
// step3: assert the data
const { data } = await loadStories();
expect(data).toEqual(mockStories);
});
callback 測(cè)試
OK斯入,兜兜轉(zhuǎn)轉(zhuǎn)說了很長(zhǎng)篇幅的 Jest 方法。我們還是回到 React Testing library(以下簡(jiǎn)稱RTL)蛀蜜。
如果大家看懂了 Jest.fn
章節(jié)的內(nèi)容刻两,RTL 的 callback 測(cè)試就一目了然了——就是來測(cè)試 onChange 事件的。我們寫一個(gè)簡(jiǎn)單的搜索條滴某,通過 onChange 事件返給父組件輸入的內(nèi)容磅摹。
// CallbackSearch.js
export function CallbackSearch({ onChange }) {
return (
<div>
<label htmlFor="search">Search:</label>
<input id="search" type="text" onChange={onChange} />
</div>
);
}
該組件的測(cè)試怎么寫呢?很簡(jiǎn)單霎奢,反正只有一個(gè) onChange 參數(shù)户誓,我們只要測(cè)試它的調(diào)用狀態(tài)就行了。
import userEvent from "@testing-library/user-event";
describe("Search", () => {
it("paste counts", () => {
const onChange = jest.fn();
render(<CallbackSearch onChange={onChange} />);
const $e = screen.getByRole("textbox");
userEvent.paste($e, "Onion");
expect(onChange).toHaveBeenCalledTimes(1);
});
});
這次我們使用的是 userEvent 的paste
方法幕侠,輸入框內(nèi)黏貼一段文本帝美,自然只觸發(fā)一次事件,所以斷言 onChange 被調(diào)用了 1 次即可晤硕。假如你用的是userEvent.type
悼潭,那就是每輸入一個(gè)字符都會(huì)回調(diào)一次,就要斷言 onChange 的掉用次數(shù)為輸入字符串的長(zhǎng)度了窗骑。
異步加載測(cè)試
上一章講了 jest.fn
的案例女责,我們?cè)僬f說 jest.mock
的案例。
React Hook
現(xiàn)在不是流行寫 Hook 嗎创译?我們就寫個(gè) loadStories
的加強(qiáng)版 Hook抵知,主要功能還是老樣子,利用 axios 遠(yuǎn)程調(diào)用 API软族;成功了就把數(shù)據(jù)寫到 stories 這個(gè) state 里刷喜,失敗了就把錯(cuò)誤寫到 error 這個(gè) state 里。
// userStory.js
import axios from "axios";
import { useState, useCallback } from "react";
export function useStory() {
const [stories, setStories] = useState([]);
const [error, setError] = useState(null);
const handleFetch = useCallback(() => {
const loadStories = async () => {
try {
const { data } = await axios.get("/stories");
setStories(data);
} catch (error) {
setError(error);
}
};
loadStories();
}, []);
return { error, stories, handleFetch };
}
給 React Hook 寫單元測(cè)試需要額外安裝一個(gè)依賴立砸,@testing-library/react-hooks
掖疮;主要是用了它的一個(gè)方法 readerHook
來模擬 react 組件的場(chǎng)景。
我們看一下怎么寫這個(gè) Hook 的 test case颗祝,套路是相似的:
- 利用
jest.mock
覆蓋 axios 模塊 - 偽造一個(gè)
axios.get
的 response - 斷言 stories 的初試狀態(tài)是空數(shù)組
- 調(diào)用異步方法加載 stories
- 完成異步調(diào)用后浊闪,斷言 stories 的最新狀態(tài)
import { renderHook } from "@testing-library/react-hooks";
import axios from "axios";
import { useStory } from "./useStory";
// Step 1: override a module
jest.mock("axios");
const mockStory = [
{ objectID: "1", title: "Hello" },
{ objectID: "2", title: "React" },
];
it("load stories and succeed", async () => {
// Step 2: fake a response
const response = { data: mockStory };
axios.get.mockResolvedValue(response);
const { result, waitForNextUpdate } = renderHook(() => useStory());
// Step 3: test initial state
expect(result.current.stories).toEqual([]);
// Step 4: fetch stories
result.current.handleFetch();
// Step 5: test after load axios api
await waitForNextUpdate();
expect(result.current.stories).toEqual(mockStory);
});
React Component
測(cè)試完 Hook恼布,我們把它放到組件里。老規(guī)矩搁宾,先不看實(shí)現(xiàn)折汞,看效果:
簡(jiǎn)單來說就是寫了一個(gè) button,點(diǎn)擊后會(huì)異步調(diào)用數(shù)據(jù)盖腿,然后展示出所有的 stories 條目爽待。
還是照搬上面的套路:
- 利用
jest.mock
覆蓋 axios 模塊 - 偽造一個(gè)
axios.get
的 response - 點(diǎn)擊按鈕,即異步加載 stories
- 結(jié)束后翩腐,斷言屏幕上會(huì)出現(xiàn)了相應(yīng)的 story 條目
import axios from "axios";
// Step 1: override a module
jest.mock("axios");
const mockStory = [
{ objectID: "1", title: "Hello" },
{ objectID: "2", title: "React" },
];
describe("FetchButton", () => {
it("fetches stories from an API and displays them", async () => {
render(<FetchButton />);
// Step 2: fake a response
const response = { data: mockStory };
axios.get.mockResolvedValue(response);
// Step 3: click button
userEvent.click(screen.getByRole("button"));
// Step 4: assert that screen will display 2 items
const $items = await screen.findAllByRole("listitem");
expect($items).toHaveLength(2);
});
});
教課書里的 TDD 要求先寫測(cè)試鸟款,再寫實(shí)現(xiàn)的。我這里也盡量按照這個(gè)思路排版茂卦,大家看完單元測(cè)試何什,也應(yīng)該有了大體的組件實(shí)現(xiàn)輪廓了吧?我把我的實(shí)現(xiàn)寫出來:
// FetchButton.js
import { useStory } from "./useStory";
export function FetchButton() {
const { error, stories, handleFetch } = useStory();
return (
<div>
<button onClick={handleFetch}>Fetch Stories</button>
{error && <span>Something went wrong ...</span>}
<ul>
{stories.map((story) => (
<li key={story.objectID}>{story.title}</li>
))}
</ul>
</div>
);
}
異常測(cè)試
我們接著說上面的 FetchButton
疙筹。在寫實(shí)現(xiàn)的時(shí)候富俄,我發(fā)現(xiàn)了之前測(cè)試?yán)镉袀€(gè)重大疏漏:異步調(diào)取的 API 可能會(huì)返回錯(cuò)誤,之前的單元測(cè)試沒有覆蓋到拋異常的情況而咆。所以我又在實(shí)現(xiàn)里加了個(gè) error 的判斷霍比;有錯(cuò)誤就顯示 Something went wrong ...
嗜愈。既然有疏漏偿曙,就繼續(xù)補(bǔ) test case。這種測(cè)試其實(shí)更簡(jiǎn)單宵睦,基本上就是套用上文的步驟涯捻,唯一不同之處就是讓 axios.get
返回mockRejectedValue
浅妆。大家看看下面的 test case,應(yīng)該已經(jīng)沒有難點(diǎn)了障癌。
jest.mock("axios");
describe("FetchButton", () => {
it("fetch stories from an API but fail", async () => {
render(<FetchButton />);
+ axios.get.mockRejectedValue(new Error()));
userEvent.click(screen.getByRole("button"));
const $errorMsg = await screen.findByText(/Something went wrong/);
expect($errorMsg).toBeInTheDocument();
});
});
小結(jié)
我最近又回看了2020 年 JS 滿意度調(diào)查凌外,發(fā)現(xiàn) Testing Library 位居測(cè)試榜榜首,還是很有群眾基礎(chǔ)的涛浙。我們用了兩期時(shí)間介紹了 Testing Library 的入門教程康辑,也希望大家能盡快把 TDD 落實(shí)到自己的項(xiàng)目中。我見過好多項(xiàng)目幾乎沒有任何測(cè)試轿亮,一兩年就爛掉了疮薇;一加新功能就四處漏風(fēng),后期 bug 數(shù)量急速上升我注,release 甚至能因此拖延半年之久按咒;屎山一堆積,想改就再也改不了了但骨。TDD 是軟件行業(yè)多年來的最佳實(shí)踐励七,我們作為該行業(yè)的從業(yè)人員智袭,也應(yīng)該堅(jiān)定地按行業(yè)規(guī)律辦事;這不僅是“干活勤快”這么簡(jiǎn)單呀伙,更多的是自己職業(yè)素養(yǎng)的體現(xiàn)补履,共勉。