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 断言时,用来检查值是否满足一定的条件。例如上面的例子中:
代码语言:javascript复制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。
代码语言:javascript复制// 定义一个 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 请求,作为前端只需要去确认这个异步请求发送成功就好了,至于后端接口返回什么内容我们就不关注了,这是后端自动化测试要做的事情。
代码语言:javascript复制// users.js 获取所有user信息
import axios from'axios';
class Users {
staticall() {
return axios.get('.../users.json').then(resp => resp.data);
}
}
exportdefault Users;
代码语言:javascript复制// 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 组件的例子:
代码语言:javascript复制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 应该是元素的首选定位类型。
代码语言:javascript复制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 秒后再显示这行字。
代码语言:javascript复制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
toBeDisabled
toBeEnabled
toBeEmptyDOMElement
toBeInTheDocument
toBeInvalid
toBeRequired
toBeValid
toBeVisible
toContainElement
toContainHTML
toHaveAccessibleDescription
toHaveAccessibleName
toHaveAttribute
toHaveClass
toHaveFocus
toHaveFormValues
toHaveStyle
toHaveTextContent
toHaveValue
toHaveDisplayValue
toBeChecked
toBePartiallyChecked
toHaveErrorMessage
- Deprecated matchers
toBeEmpty
toBeInTheDOM
toHaveDescription
3.5 事件:FireEvent
实际的用户交互可以通过 RTL 的 fireEvent 函数去模拟。
代码语言:javascript复制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,相较于整个前端单测来说只是冰山一角。希望在日后工作的每一天能不断地探索这个领域,也希望在不久的将来,我也能 “快乐编码,自信发布”。
紧追技术前沿,深挖专业领域
扫码关注我们吧!