大家好,我是若川。持续组织了6个月源码共读活动,感兴趣的可以点此加我微信 ruochuan02 参与,每周大家一起学习200行左右的源码,共同进步。同时极力推荐订阅我写的《学习源码整体架构系列》 包含20余篇源码文章。历史面试系列
1、背景
以前还是学生的时候,有学习一门与测试相关的课程。那个时候,觉得测试就是写 test case,写断言,跑测试,以及查看 test case 的 coverage。整个流程和写法也不是特别难,所以就理所当然地觉得,写测试也不是特别难。
加上之前实际的工作中,也没有太多的写测试的经历,所以当自己需要对组件库补充单元测试的时候,发现并不能照葫芦画瓢来写单测。一时不知道该如何下手,也不知道如何编写有效的单测,人有点懵,于是就比较粗略地研究了一下前端组件单测。
1.1 单测的目的
在频繁的需求变动中可控地保障代码变动的影响范围
提升代码质量和开发测试效率
保证代码的整洁清晰
......
总之单测是一个保证产品质量的非常强大的手段。
1.2 测试框架和 UI 组件测试工具
而说起前端的测试框架和工具,比较主流的 JavaScript 测试框架有 Jest、Jasmine、Mocha 等等,并且还有一些 UI 组件测试工具,比如 testing-libraray,enzyme 等等。
测试框架和 UI 组件测试工具之间并不是相互依赖、非此即彼的,而是可以根据不同工具的性质做不同的搭配。目前腾讯课堂基于 Tdesign 开发的素材库组件的单测,就是使用 Jest + React Testing Library 来完成。
1.3 组件单测须知
在开始进行组件单测的时候,有几个因素我们需要考虑:
组件是否按照既定的条件 / 逻辑进行渲染
组件的事件回调是否正确
异步接口如何校验
异步执行完毕后的操作如何校验
......
当然不止这些列举出来的,根据不同的业务场景,我们考虑的因素需要更全面更细致。
2、Jest 的使用
Jest 的安装这里就不赘述了,如果使用 create-react-app 来创建项目,Jest 和 React Testing Library(RTL) 都已经默认安装了。
如果想要看如何安装 Jest,可以参考:Jest 上手。
Jest 常用的配置项在根目录中的 jest.config.js 中,常用的配置可以参考:Jest 配置文件。
2.1 Jest 基础 API
Jest 的最基础,最常用的三个 API 是:describe、test 和 expect。
describe 是 test suite(测试套件)
test (也可以写成 it) 是 test case(测试用例)
expect 是断言
import aFunction from'./function.js';// 假设 aFunction 读取一个 bool 参数,并返回该 bool 参数
describe('a example test suite', () => {test('function return true', () => {expect(aFunction(true)).toBe(true);// 测试通过});test('function return false', () => {expect(aFunction(false)).toBe(false);// 测试通过});
});通过运行 npm run jest (运行所有的 test suite 和 test case,以及断言),或者 npm run jest -t somefile.test.tsx(运行指定文件中的测试用例),就可以得到测试结果,如:

当然,如果想要看到覆盖率的报告,可以使用 jest --coverage,或者 jest-report。
在 VS Code 中,我们也可以安装插件:Jest Runner。

在代码中,就可以快速跑测试用例,可以说非常的方便了。

如果在使用 Jest runner 的时候出现 Node.js 相关的报错,可以查看一下当前 Node.js 的使用版本,切换到 14.17.0 版本即可。

2.2 Jest 匹配器
Jest 匹配器是在 expect 断言时,用来检查值是否满足一定的条件。例如上面的例子中:
expect(aFunction(true)).toBe(true)其中 toBe () 就是用来比较 aFunction (true) 的值是否为 true。
完整的 Jest 匹配器可以在 这里 查看,下面也列举一些常用的匹配器:
| 匹配器 | 说明 |
|---|---|
| .toBe(value) | 相等性,检查规则为 === + Object.is |
| .toEqual(value) | 相等性,递归对比对象字段 |
| .toBeInstanceOf(Class) | 检查是否属于某一个 Class 的 instance |
| .toHaveProperty(keyPath, value) | 检查断言中的对象是否包含 keyPath 字段,或可以检查该对象的值是否等于 value |
| .toBeGreaterThan(number) | 大于 number |
| .toBeGreaterThanOrEqual(number) | 大于等于 number |
| .toBeNaN() | 值是否是 NaN |
| .toMatch(regexp or String) | 字符串的相等性,可以填入 string 或者一个正则 |
| .toContain(item) | substring |
| .toHaveLength(number) | 字符串长度 |
其实在 Testing Library 库中,还提供了一些匹配器专门用来测试前端组件,这些扩展的匹配器会让前端组件的测试变得更灵活。除了前端组件的匹配器,一些扩展库也依据不同的测试场景衍生出了很多其他的匹配器。
2.3 Jest Mock
在查看官方文档的时候,Jest 匹配器中还有一类匹配器专门用来检查 Jest Mock 函数的。在组件单测中,有的时候我们可能只关注一个函数是否被正确地调用了,或者只想要某个函数的返回值来支持该组件渲染逻辑是否正确,而并不关心这个函数本身的逻辑。正如官方文档中强调的那样:
Testing Library encourages you to avoid testing implementation details like the internals of a component you're testing.
测试库鼓励您避免测试实现细节,例如您正在测试的组件的内部结构。
所以,Jest Mock 的意义就在于可以帮助我们完成下面这些事情:
有些模块可能在测试环境中不能很好地工作,或者对测试本身不是很重要,使用虚拟数据来 mock 这些模块,可以使你为代码编写测试变得更容易;
如果不想在测试中加载这个组件,我们可以将依赖 mock 到一个虚拟组件;
测试组件处于不同状态下的表现;
mock 一些子组件,可以帮助减小快照的大小,并使它们在代码评审中保持可读性;
......
Jest Mock 的常用 API 是:jest.fn () 和 jest.mock ()。
2.3.1 jest.fn()
通过 jest.fn(implementation) 可以创建 mock 函数。如果没有定义函数内部的实现,mock 函数会返回 undefined。
// 定义一个 mock 的函数,因为没有函数体,所以 mockFn 会 return undefined
const mockFn = jest.fn();// mockFn 调用
mockFn();
// 虽然没有定义函数体,但是 mockFn 被调用过了
expect(mockFn).toHaveBeenCalled();const res = mockFn('a','b','c');// 断言 mockFn 的执行后返回 undefined
expect(res).toBeUndefined();// 断言mockFn被调用了两次
expect(mockFn).toBeCalledTimes(2);// 断言mockFn传入的参数为a,b,c
expect(mockFn).toHaveBeenCalledWith('a','b','c');// 定义implementation,自定义函数体:
const returnsTrue = jest.fn(() =>true); // 定义了函数体
console.log(returnsTrue()); // true// 可以给mock的函数设置返回值
const returnSomething = jest.fn().mockReturnValue('hello world');
expect(returnSomething()).toBe('hello world');// mock也可以返回一个Promise
const promiseFn = jest.fn().mockResolvedValue('hello promise');
const promiseRes = await promiseFn();
expect(promiseRes).toBe('hello promise');2.3.2 jest.mock(moduleName, factory, options)
jest.mock() 可以帮助我们去 mock 一些 ajax 请求,作为前端只需要去确认这个异步请求发送成功就好了,至于后端接口返回什么内容我们就不关注了,这是后端自动化测试要做的事情。
// users.js 获取所有user信息
import axios from'axios';class Users {staticall() {return axios.get('.../users.json').then(resp => resp.data);}
}exportdefault Users;// user.test.js
import axios from'axios';
import Users from'./users';jest.mock('axios');test('should fetch users', () => {const users = [{name: 'Bob'}];const resp = {data: users};axios.get.mockResolvedValue(resp);// or you could use the following depending on your use case:// axios.get.mockImplementation(() => Promise.resolve(resp))return Users.all().then(data => expect(data).toEqual(users));
});2.3.3 Jest Mock 的匹配器
Jest 匹配器中还有一类匹配器专门用来检查 jest mock() 的,比如:
名字
mockFn.mockName(value)mockFn.getMockName()
运行情况
mockFn.mock.calls:传的参数mockFn.mock.results:得到的返回值mockFn.mock.instances:mock 包装器实例
模拟函数
mockFn.mockImplementation(fn):重新声明被 mock 的函数mockFn.mockImplementationOnce(fn)
模拟结果
mockFn.mockReturnThis()mockFn.mockReturnValue(value)mockFn.mockReturnValueOnce(value)mockFn.mockResolvedValue(value)mockFn.mockResolvedValueOnce(value)mockFn.mockRejectedValue(value)mockFn.mockRejectedValueOnce(value)
2.4 Jest 的扩展阅读材料
Jest 学习指南
那些年错过的 React 组件单元测试
使用 Jest 测试 JavaScript (Mock 篇)
3、React Testing Library
testing library 是一个测试 React 组件的测试库,它的核心理念就是:
The more your tests resemble the way your software is used, the more confidence they can give you.
测试越类似于软件使用方式,就越能给测试信心。
3.1 render & debug
在测试用例中渲染内容,可以使用 RTL 库中的 render,render 函数可以为我们在测试用例中渲染 React 组件。
被渲染的组件,可以通过 debug 函数或者 screen 的 debug 函数在控制台输出组件的 HTML 结构。例如下面的 Dropdown 组件的例子:
import { render, screen } from '@testing-library/react';
import Dropdown from '../index'; // 要测试的组件describe('dropdown test', () => {it('render Dropdown', () => {// 渲染 Dropdown 组件const comp = render(<Dropdown />);comp.debug();screen.debug();// 这两种都可以打印出来渲染组件的结构});
});其实,在我们编写组件测试用例时,都可以通过 debug 函数把组件渲染结果打印出来,这可以提高我们编写用例时的效率,同时,这一特点也很符合 RTL 的设计观念。
3.2 screen
在上面的例子中,其实我们也使用到了库中的 screen。screen 为测试用例提供了一个全局 DOM 环境,通过这个环境,我们就可以去使用库中提供的不同函数去定位元素,定位后的元素可以用于断言判断或者用户交互。
3.3 定位元素
3.3.1 Query 类型
定位元素的方法在 RTL 中称为 Query,Query 帮助我们去找到页面上的元素。RTL 提供了三种 Query 的类型:"get", "find", "query"。
| Query 类型 | 未找到元素 | 找到 1 个元素 | 找到多个元素 | Retry (Async/Await) |
|---|---|---|---|---|
| Single Element | ||||
| getBy... | Throw error | Return element | Throw error | No |
| queryBy... | Return null | Return element | Throw error | No |
| findBy... | Throw error | Return element | Throw error | Yes |
| Multiple Elements | ||||
| getAllBy... | Throw error | Return array | Return array | No |
| queryAllBy... | Return [] | Return array | Return array | No |
| findAllBy... | Throw error | Return array | Return array | Yes |
从上面的表格可以看出来,定位的方法在找单个元素时和多个元素时会做了一些区别,比如 getBy... 如果找到了多个元素就会 throw error,这时就需要使用 getAllBy...。
get 和 query 的区别主要是在未找到元素时,queryBy 会返回 null,这对于我们测试一个元素是否存在时非常有帮助。
而 findby 的作用主要用于那些最终会显示在页面当中的异步元素。
3.3.2 Query 内容
那么,getBy...、queryBy... 和 findBy... 后面具体可以查询什么内容呢?
主要
ByLabelText:用于表单的 label
ByPlaceholderText:用于表单
ByText:查询 TextNode
ByDisplayValue:输入框等当前值
语义
ByAltText:img 的 alt 属性
ByTitle:title 属性或元素
ByRole:ARIA role,可以定位到辅助树中的元素
Id
getByTestId:函数需要在源代码中添加 data-testid 属性才能使用
一般而言,getByText 和 getByRole 应该是元素的首选定位类型。
import { render, screen } from'@testing-library/react';
import Dropdown from'../index'; // 要测试的组件const propsRender = {commonStyle: {},data: {btnTheme: 'default',btnVariant: 'text',btnText: 'test', // 给 dropdown 的 button 设置文字 'test'trigger: 'click',},style: {},meta: {previewMode: true,isEditor: false},on: jest.fn(),off: jest.fn(),emit: jest.fn(),
};describe('dropdown test', () => {it('render Dropdown', () => {// 渲染 Dropdown 组件const comp = render(<Dropdown />);// 使用 queryByText("test") 定位这个 button 的文字内容,然后使用断言+匹配做测试expect(screen.queryByText("test")).toBeInTheDocument();});
});findBy 的使用方法
假如在 Component 组件中定义一行文字 “hello world” 和一个定时器,在组件渲染 3 秒后再显示这行字。
describe('test hello world', () => {test('renders component', async () => {render(<Component />);// 在组件的初始化渲染中,我们在 HTML 中无法通过 queryBy 找到 “hello world”,因为它三秒后才能出现expect(screen.queryByText(/hello world/)).toBeNull();// await 一个新的元素被找到,并且最终确实被找到当 promise resolves 并且组件重新渲染之后。expect(await screen.findByText(/hello world/)).toBeInTheDocument();});
});对于任何开始不显示、但迟早会显示的元素,要使用 findBy。如果你想要验证一个元素不在页面中,使用 queryBy,否则默认使用 getBy。
RTL 所有定位方法可 点击 查看。
3.4 RTL + Jest 匹配器
在 2.2 Jest 匹配器 中可以看到 Jest 提供了一些匹配器,然而 Jest 自己提供的匹配器很难去实现组件测试的一些特殊条件,所以 RTL 自己实现了一个 Jest 匹配器的扩展包:jest-dom。
Custom matchers
toBeDisabledtoBeEnabledtoBeEmptyDOMElementtoBeInTheDocumenttoBeInvalidtoBeRequiredtoBeValidtoBeVisibletoContainElementtoContainHTMLtoHaveAccessibleDescriptiontoHaveAccessibleNametoHaveAttributetoHaveClasstoHaveFocustoHaveFormValuestoHaveStyletoHaveTextContenttoHaveValuetoHaveDisplayValuetoBeCheckedtoBePartiallyCheckedtoHaveErrorMessage
Deprecated matchers
toBeEmptytoBeInTheDOMtoHaveDescription
3.5 事件:FireEvent
实际的用户交互可以通过 RTL 的 fireEvent 函数去模拟。
fireEvent(node: HTMLElement, event: Event)
fireEvent[eventName](node: HTMLElement, eventProperties: Object)// <button>Submit</button>
fireEvent(getByText(container, 'Submit'),new MouseEvent('click', {bubbles: true,cancelable: true,}),
);// 两种写法
fireEvent(element, new MouseEvent('click', options?));
fireEvent.click(element, options?);fireEvent 函数需要两个参数,一个参数是定位的元素 node,另一个参数是 event。这个例子中就模拟了用户点击了 button,同时 fireEvent 有两种写法。
事件 options 描述
| 属性 / 方法 | 描述 |
|---|---|
| bubbles | 返回特定事件是否为冒泡事件。 |
| cancelBubble | 设置或返回事件是否应该向上层级进行传播。 |
| cancelable | 返回事件是否可以阻止其默认操作。 |
| composed | 指示该事件是否可以从 Shadow DOM 传递到一般的 DOM。 |
| composedPath() | 返回事件的路径。 |
| createEvent() | 创建新事件。 |
| currentTarget | 返回其事件侦听器触发事件的元素。 |
| defaultPrevented | 返回是否为事件调用 preventDefault () 方法。 |
| eventPhase | 返回当前正在评估事件流处于哪个阶段。 |
| isTrusted | 返回事件是否受信任。 |
| target | 返回触发事件的元素。 |
| timeStamp | 返回创建事件的时间(相对于纪元的毫秒数)。 |
| type | 返回事件名称。 |
常用 fireEvent:
键盘:
keyDown
keyPress
keyUp
聚焦:
focus
blur
表单:
change
input
invalid
submit
reset
鼠标:
click
dblClick
drag
fireEvent API 列表可 点击 查看。
4、写在最后
测试在整个需求开发的流程中起着重要作用,它对于需求产品的质量提供了强而有力的保障。但是在实际的工作中,产品的迭代、需求的变更以及各种不确定的因素,我们经常会陷入“bug的轮回” —— 关上一个bug,点亮另一个bug。
随着业务复杂度的提升,测试的人力成本也会越来越高。面对这些痛点,作为“懒而聪明”的前端开发,我也常常在思考有什么方法可以在解放双(ren)手(li)的同时,又能保证产品的质量,也不必在每次需求上线时紧张兮兮地盯着告警看板,生怕发的版本影响了其他的功能。所以,我相信借助于测试的力量,这些痛点终有一天会逐个击破。
就像开头提到的,本文只是“比较粗略”地浏览了 Jest + RTL,相较于整个前端单测来说只是冰山一角。希望在日后工作的每一天能不断地探索这个领域,也希望在不久的将来,我也能 “快乐编码,自信发布”。

················· 若川简介 ·················
你好,我是若川,毕业于江西高校。现在是一名前端开发“工程师”。写有《学习源码整体架构系列》20余篇,在知乎、掘金收获超百万阅读。
从2014年起,每年都会写一篇年度总结,已经写了7篇,点击查看年度总结。
同时,最近组织了源码共读活动,帮助3000+前端人学会看源码。公众号愿景:帮助5年内前端人走向前列。

识别上方二维码加我微信、拉你进源码共读群
今日话题
略。分享、收藏、点赞、在看我的文章就是对我最大的支持~