書接上文,上篇說到了 React Testing library 的安裝和最基本用法。本篇繼續(xù)深挖一些較復雜的場景。
mock 測試
開始 RTL 測試前,我們稍微回顧一下 Jest 的 Mock 測試。
所謂 mock 測試就是在測試過程中,對于某些不容易構造或者不容易獲取的對象,用一個虛擬的對象代替具體實現(xiàn),以便繼續(xù)測試進度的方式。
其中最常用的 mock 方式有兩種:
- 創(chuàng)建一個 mock 函數(shù),注入到目標用例里, 如
jest.fn
- 設計一個手工的 mock 對象,來覆蓋依賴模塊,如
jest.mock
jest.fn()
先來說 mock 函數(shù)注入。我們寫一個最最基礎的 repeatTen
函數(shù),功能就是調(diào)用10
次其他函數(shù)。
function repeatTen(fn) {
for (let i = 0; i < 10; i++) {
fn();
}
}
repeatTen(() => console.log("Onion"));
repeatTen
函數(shù)的特點就是:它根本不關心 fn
的具體實現(xiàn),只要保證 fn
一次性能跑到10
趟就行。這類單元測試就非常適合使用 mock 函數(shù)了。如下所示,我們以參數(shù)的形式,為 repeatTen
傳入一個 mock 函數(shù)(onChang
),然后檢查 mock 函數(shù)的調(diào)用狀態(tài),判定函數(shù)是否如期運行就行了。
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ù)的實現(xiàn)比較單純,現(xiàn)實世界則復雜很多;有些函數(shù)的實現(xiàn)會依賴于三方庫。在測試環(huán)境里你很難以傳入一個 mock 函數(shù)的形式來模擬真實的運行狀態(tài)。比如下面這個函數(shù),通過 axios
調(diào)用 API 來返回數(shù)據(jù),大家想想該怎么寫 test case。
const loadStories = () => axios.get("/stories");
我們要測試 loadStories
但又不大可能調(diào)用真實的 API,就只能對 axios
的內(nèi)部模塊動點小腦筋了——用 jest.mock("axios")
自動模擬 axios 模塊。
Jest 提供了一個很有意思的依賴覆蓋方法——jest.mock("axios")
,它會給對象模塊——axios——的.get
方法提供一個 mockResolvedValue
。通俗來說,就是讓axios.get("/stories")
返回一個假的 response;而這個 response 的數(shù)據(jù)是我們事先準備好的。
看一下寫 mock 依賴測試的主要流程:
- 利用
jest.mock(...)
覆蓋用例函數(shù)內(nèi)部 import 的依賴 - 為用例函數(shù)內(nèi)部使用的某個方法構造一個 mock 返回
- 斷言用例函數(shù)的返回結果
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 測試
OK,兜兜轉轉說了很長篇幅的 Jest 方法。我們還是回到 React Testing library(以下簡稱RTL)。
如果大家看懂了 Jest.fn
章節(jié)的內(nèi)容,RTL 的 callback 測試就一目了然了——就是來測試 onChange 事件的。我們寫一個簡單的搜索條,通過 onChange 事件返給父組件輸入的內(nèi)容。
// CallbackSearch.js
export function CallbackSearch({ onChange }) {
return (
<div>
<label htmlFor="search">Search:</label>
<input id="search" type="text" onChange={onChange} />
</div>
);
}
該組件的測試怎么寫呢?很簡單,反正只有一個 onChange 參數(shù),我們只要測試它的調(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
,那就是每輸入一個字符都會回調(diào)一次,就要斷言 onChange 的掉用次數(shù)為輸入字符串的長度了。
異步加載測試
上一章講了 jest.fn
的案例,我們再說說 jest.mock
的案例。
React Hook
現(xiàn)在不是流行寫 Hook 嗎?我們就寫個 loadStories
的加強版 Hook,主要功能還是老樣子,利用 axios 遠程調(diào)用 API;成功了就把數(shù)據(jù)寫到 stories 這個 state 里,失敗了就把錯誤寫到 error 這個 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 寫單元測試需要額外安裝一個依賴,@testing-library/react-hooks
;主要是用了它的一個方法 readerHook
來模擬 react 組件的場景。
我們看一下怎么寫這個 Hook 的 test case,套路是相似的:
- 利用
jest.mock
覆蓋 axios 模塊 - 偽造一個
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
測試完 Hook,我們把它放到組件里。老規(guī)矩,先不看實現(xiàn),看效果:
簡單來說就是寫了一個 button,點擊后會異步調(diào)用數(shù)據(jù),然后展示出所有的 stories 條目。
還是照搬上面的套路:
- 利用
jest.mock
覆蓋 axios 模塊 - 偽造一個
axios.get
的 response - 點擊按鈕,即異步加載 stories
- 結束后,斷言屏幕上會出現(xiàn)了相應的 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 要求先寫測試,再寫實現(xiàn)的。我這里也盡量按照這個思路排版,大家看完單元測試,也應該有了大體的組件實現(xiàn)輪廓了吧?我把我的實現(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>
);
}
異常測試
我們接著說上面的 FetchButton
。在寫實現(xiàn)的時候,我發(fā)現(xiàn)了之前測試里有個重大疏漏:異步調(diào)取的 API 可能會返回錯誤,之前的單元測試沒有覆蓋到拋異常的情況。所以我又在實現(xiàn)里加了個 error 的判斷;有錯誤就顯示 Something went wrong ...
。既然有疏漏,就繼續(xù)補 test case。這種測試其實更簡單,基本上就是套用上文的步驟,唯一不同之處就是讓 axios.get
返回mockRejectedValue
。大家看看下面的 test case,應該已經(jīng)沒有難點了。
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();
});
});
小結
我最近又回看了2020 年 JS 滿意度調(diào)查,發(fā)現(xiàn) Testing Library 位居測試榜榜首,還是很有群眾基礎的。我們用了兩期時間介紹了 Testing Library 的入門教程,也希望大家能盡快把 TDD 落實到自己的項目中。我見過好多項目幾乎沒有任何測試,一兩年就爛掉了;一加新功能就四處漏風,后期 bug 數(shù)量急速上升,release 甚至能因此拖延半年之久;屎山一堆積,想改就再也改不了了。TDD 是軟件行業(yè)多年來的最佳實踐,我們作為該行業(yè)的從業(yè)人員,也應該堅定地按行業(yè)規(guī)律辦事;這不僅是“干活勤快”這么簡單,更多的是自己職業(yè)素養(yǎng)的體現(xiàn),共勉。