书接上文,上篇说到了 React Testing library 的安装和最基本用法。本篇继续深挖一些较复杂的场景。
mock 测试
开始 RTL 测试前,我们稍微回顾一下 Jest 的 Mock 测试。
所谓 mock 测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象代替具体实现,以便继续测试进度的方式。
其中最常用的 mock 方式有两种:
- 创建一个 mock 函数,注入到目标用例里, 如
jest.fn
- 设计一个手工的 mock 对象,来覆盖依赖模块,如
jest.mock
jest.fn()
先来说 mock 函数注入。我们写一个最最基础的 repeatTen
函数,功能就是调用10
次其他函数。
function repeatTen(fn) {
for (let i = 0; i < 10; i++) {
fn();
}
}
repeatTen(() => console.log("Onion"));
repeatTen
函数的特点就是:它根本不关心 fn
的具体实现,只要保证 fn
一次性能跑到10
趟就行。这类单元测试就非常适合使用 mock 函数了。如下所示,我们以参数的形式,为 repeatTen
传入一个 mock 函数(onChang
),然后检查 mock 函数的调用状态,判定函数是否如期运行就行了。
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
函数的实现比较单纯,现实世界则复杂很多;有些函数的实现会依赖于三方库。在测试环境里你很难以传入一个 mock 函数的形式来模拟真实的运行状态。比如下面这个函数,通过 axios
调用 API 来返回数据,大家想想该怎么写 test case。
const loadStories = () => axios.get("/stories");
我们要测试 loadStories
但又不大可能调用真实的 API,就只能对 axios
的内部模块动点小脑筋了——用 jest.mock("axios")
自动模拟 axios 模块。
Jest 提供了一个很有意思的依赖覆盖方法——jest.mock("axios")
,它会给对象模块——axios——的.get
方法提供一个 mockResolvedValue
。通俗来说,就是让axios.get("/stories")
返回一个假的 response;而这个 response 的数据是我们事先准备好的。
看一下写 mock 依赖测试的主要流程:
- 利用
jest.mock(...)
覆盖用例函数内部 import 的依赖 - 为用例函数内部使用的某个方法构造一个 mock 返回
- 断言用例函数的返回结果
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
章节的内容,RTL 的 callback 测试就一目了然了——就是来测试 onChange 事件的。我们写一个简单的搜索条,通过 onChange 事件返给父组件输入的内容。
// CallbackSearch.js
export function CallbackSearch({ onChange }) {
return (
);
}
该组件的测试怎么写呢?很简单,反正只有一个 onChange 参数,我们只要测试它的调用状态就行了。
import userEvent from "@testing-library/user-event";
describe("Search", () => {
it("paste counts", () => {
const onChange = jest.fn();
render( );
const $e = screen.getByRole("textbox");
userEvent.paste($e, "Onion");
expect(onChange).toHaveBeenCalledTimes(1);
});
});
这次我们使用的是 userEvent 的paste
方法,输入框内黏贴一段文本,自然只触发一次事件,所以断言 onChange 被调用了 1 次即可。假如你用的是userEvent.type
,那就是每输入一个字符都会回调一次,就要断言 onChange 的掉用次数为输入字符串的长度了。
异步加载测试
上一章讲了 jest.fn
的案例,我们再说说 jest.mock
的案例。
React Hook
现在不是流行写 Hook 吗?我们就写个 loadStories
的加强版 Hook,主要功能还是老样子,利用 axios 远程调用 API;成功了就把数据写到 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 的初试状态是空数组
- 调用异步方法加载 stories
- 完成异步调用后,断言 stories 的最新状态
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,我们把它放到组件里。老规矩,先不看实现,看效果:
简单来说就是写了一个 button,点击后会异步调用数据,然后展示出所有的 stories 条目。
还是照搬上面的套路:
- 利用
jest.mock
覆盖 axios 模块 - 伪造一个
axios.get
的 response - 点击按钮,即异步加载 stories
- 结束后,断言屏幕上会出现了相应的 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( );
// 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 要求先写测试,再写实现的。我这里也尽量按照这个思路排版,大家看完单元测试,也应该有了大体的组件实现轮廓了吧?我把我的实现写出来:
// FetchButton.js
import { useStory } from "./useStory";
export function FetchButton() {
const { error, stories, handleFetch } = useStory();
return (
{error && Something went wrong ...}
{stories.map((story) => (
- {story.title}
))}
);
}
异常测试
我们接着说上面的 FetchButton
。在写实现的时候,我发现了之前测试里有个重大疏漏:异步调取的 API 可能会返回错误,之前的单元测试没有覆盖到抛异常的情况。所以我又在实现里加了个 error 的判断;有错误就显示 Something went wrong ...
。既然有疏漏,就继续补 test case。这种测试其实更简单,基本上就是套用上文的步骤,唯一不同之处就是让 axios.get
返回mockRejectedValue
。大家看看下面的 test case,应该已经没有难点了。
jest.mock("axios");
describe("FetchButton", () => {
it("fetch stories from an API but fail", async () => {
render( );
+ axios.get.mockRejectedValue(new Error()));
userEvent.click(screen.getByRole("button"));
const $errorMsg = await screen.findByText(/Something went wrong/);
expect($errorMsg).toBeInTheDocument();
});
});
小结
我最近又回看了2020 年 JS 满意度调查,发现 Testing Library 位居测试榜榜首,还是很有群众基础的。我们用了两期时间介绍了 Testing Library 的入门教程,也希望大家能尽快把 TDD 落实到自己的项目中。我见过好多项目几乎没有任何测试,一两年就烂掉了;一加新功能就四处漏风,后期 bug 数量急速上升,release 甚至能因此拖延半年之久;屎山一堆积,想改就再也改不了了。TDD 是软件行业多年来的最佳实践,我们作为该行业的从业人员,也应该坚定地按行业规律办事;这不仅是“干活勤快”这么简单,更多的是自己职业素养的体现,共勉。
相关
- 《Testing-library 101 (一)》