大厂技术 坚持周更 精选好文
本文为来自 字节跳动-国际化电商-S 项目团队 成员的文章,已授权 ELab 发布。
单测的意义
组件作为一种被频繁复用的代码,出现线上 bug 的时候影响的是范围更广的业务,保障组件每次迭代的可靠性尤为重要。
对使用者来说,组件库的丰富的单测代表着其可靠性。
对组件开发者来说,单测的沉淀也让每次对旧代码的改动更有信心。
Jest 基础
Jest 是 React 推荐的测试框架,也是当前众多前端组件库的单测首选。
先看一个简单的 🌰
function sum (num1, num2) {
return num1 + num2;
}
上面是一个简单的加法函数,它的功能是是输入两个数 a 和 b,期望返回它们相加的结果,它的单测写出来是这样的。
import sum from './sum.ts';
describe('util 方法测试', () => {
test("sum function test", () => {
// 对函数进行调用,期望结果是 3
expect(sum(1, 2)).toBe(3);
})
});
上面是对 sum 方法的单元测试的例子,在该测试中,对函数进行调用,传入了 1 和 2,并对返回结果的正确性进行验证。里面出现了 describe、test、expect、toBe 方法,这些是 jest 相关的基础的东西,下面逐个介绍下。
Jest 相关概念
describe:描述一个模块
describe('buttonPromise 组件相关测试', () => {
// 此处编写 buttonPromise 测试用例
});
test :编写一个具体的测试用例
describe('buttonPromise 组件相关测试', () => {
test('描述测试功能:buttonpromise loading style', () => {
// 执行具体测试
})
});
expect:断言,期望某个结果符合什么样的预期
toBe:匹配器, 对某个结果进行判断是否与预期一致
test('two plus two is four', () => {
expect(sum(2, 2)).toEqual(4); // 断言结果为 4,通过测试
expect(sum(2, 2)).not.toEqual(5); // 断言结果不等于 5,通过测试
});
Jest 提供了大量的常用匹配器,针对数字、字符串、函数的调用情况、调用时的参数等
toBeGreaterThan(x: number); 比 x 更大
toBeLessThan(x: number); 比 x 更小
toEqual(x: number): 是否相等
toMatch(x: RegExp) 字符串匹配
toHaveBeenCalledTimes(x: number) 函数被调用 x 次
更多匹配器
Jest 提供的能力
测试断言、匹配器,对测试用例结果进行判断
支持异步函数测试
// 构造一个异步函数,2s 后返回 1 const fetchData = () => new Promise((resolve) => { setTimeout(() => { resolve(1) }, 2000) }) test('支持返回一个 promise,在 resove 中进行测试', () => { return fetchData().then(data => { expect(data).toBe(1); }); }); test('直接使用 .resolves / .rejects 对结果进行断言', () => { return expect(fetchData()).resolves.toBe(1); }); test('支持在测试用例中使用 async await', async () => { const data = await fetchData(); expect(data).toBe(1); });
Mock 能力
Jest 支持对一个函数、模块的 mock,有些测试场景用到了外部依赖,而这些外部依赖并不需要关心它们的执行过程,只需要它们返回一个执行后的结果,这种场景下可以使用 jest 的 mock 功能
例如下面这段代码对 axios 模块进行 mock
import axios from 'axios'
// 对整个 axios 模块进行 mock
jest.mock('axios', () => {
return {
get: jest.fn(() => Promise.resolve(1))
}
})
test('试试 mock axios', () => {
return axios.get('').then(data => expect(data).toBe(1))
})
甚至可以对引用文件内部的其他依赖做 mock
test.tsx
import axios from 'axios';
import b from './b';
export default async () => {
const data = await axios.get('./api/v1/user');
return data;
}
fn.test.tsx
import testFn from './test'
// test 文件内部使用到了 axios,在这里对其做 mock
jest.mock('axios', () => {
return {
get: jest.fn(() => Promise.resolve(1))
}
})
jest.mock('./b', () => {
return {
}
});
test('试试对引用文件内部的其他依赖做 mock', () => {
return testFn().then(data => expect(data).toBe(1))
})
jest 更多 mock 相关的知识
测试 UI 组件额外需要的工具库
通过上面可以了解到 Jest 提供了单元测试常用的一些基础能力,对测试结果的断言、匹配,对外部依赖的 mock 等,但是对于组件库来说,这对于写 UI 组件单测还不够便捷,例如,
如何去渲染一个 React 组件?将组件渲染出来是测试它的第一步
渲染后希望可以检查它在某些场景下的样式,又如何去操作节点
UI 组件的测试伴随着用户对它的交互,这些又如何操作?
...
针对上面这些问题,业界已有了相应的工具库配合 jest 一起使用
@testing-library/react
@testing-library/react 是一个用于测试 react 组件相关的库,提供了 react 组件渲染能力、 dom 操作能力,通过它在编写 react 组件单元测试的时候可以更加关注功能测试本身,无需编写大量代码去做组件的初始化等。
@testing-library/react 提供了 render api,当开始写一个组件的单测时,可以使用它将组件渲染出来,同时其返回了一个 container 对象,是组件被渲染的容器所在,通过 container,可以进行 dom 操作,如 获取对应的节点等。
import { render } from '@testing-library/react';
import ButtonPromise from '../src';
describe('Button Promise 组件测试', () => {
test('测试 loading 功能', async () => {
// 渲染组件
const { container } = render(
<ButtonPromise id="test-copy-icon" onClick={btnClick}>
按钮
</ButtonPromise>,
);
// render 函数渲染完会返回对应的组件被渲染的容器节点,通过它可查找对应的 dom
const btn = container.querySelector('#test-copy-icon');
... 后续操作
});
上面的例子展示了如何渲染一个按钮组件,组件渲染之后,需要用户去点击,这涉及到用户行为的模拟,针对这一类场景,对应的解决方案是 @testing-library/user-event
@testing-library/user-event
@testing-library/user-event 是一个模拟用户行为的库,包括对用户鼠标行为(单击、双击、鼠标 hover 等)、键盘行为的模拟。
继续上面的例子,在按钮成功渲染出来后,对其进行点击
import { render } from '@testing-library/react';
import ButtonPromise from '../src';
describe('Button Promise 组件测试', () => {
test('测试 loading 功能', async () => {
// userEvent 初始化
const user = userEvent.setup();
const { container } = render(
<ButtonPromise id="test-copy-icon" onClick={btnClick}>
按钮
</ButtonPromise>,
);
// render 函数渲染完会返回对应的组件被渲染的容器节点,通过它可查找对应的 dom
const btn = container.querySelector('#test-copy-icon');
// 点击按钮
user.click(btn);
});
用户的行为通常伴随着数据的变化、界面的变化,这些变化是检查组件经过用户交互后是否正确的判断标准。数据的变化通过 jest 提供的各类匹配器(toEqual、toMatch 等)可以进行判断,界面的变化则需要通过对 dom 状态的检验。
@testing-library/jest-dom
继续上面的例子,上面的部分已经做到了对 buttonPromise 组件的渲染,点击,而该组件的功能,是期望点击后用户可以看到 loading 效果.
import { render } from '@testing-library/react';
import ButtonPromise from '../src';
describe('Button Promise 组件测试', () => {
test('测试 loading 功能', async () => {
// 根据组件功能,传入 onClick 的函数返回一个 promise
// 在 resolve 之前,按钮会有 loading 效果
const btnClick = () => new Promise((resolve) => {
setTimeout(() => {
resolve()
}, 2000);
})
// userEvent 初始化
const user = userEvent.setup();
const { container } = render(
<ButtonPromise id="test-copy-icon" onClick={btnClick}>
按钮
</ButtonPromise>,
);
// render 函数渲染完会返回对应的组件被渲染的容器节点,通过它可查找对应的 dom
const btn = container.querySelector('#test-copy-icon');
// 点击按钮
user.click(btn);
await waitFor(() => {
// 期望按钮上有 loading 样式
expect(btn).toHaveClass('arco-btn-loading')
});
});
写单元测试的思路
通过上面 jest 与 test-library 相关的基础介绍,到最后完成了 button-promise 组件单元测试的编写。
单测的要点
明确测试目的(测什么功能,什么结果是正确的)
每个 test 只包含一个测试目的
多站在用户的视角进行考虑(把自己当作在使用组件的用户,做出某些行为后应该看到什么)
由于 arcoS 是对 arco 的封装,对于一些组件的基础功能可以不进行测试
例如上面的 button-promise 已经进入 loading 状态,在进入 loading 后,按钮的多次点击是否还会触发函数调用,这属于 button 组件基础功能,应该由 arco 去保障。
思路
以上面组件库中 button-promise 组件为例
梳理待测试的功能
功能 a:onClick 绑定一个返回了 promise 的函数,组件将进入 loading 状态,直到 promise 进入 resolve 状态)
编写测试用例
渲染待测试组件(像使用组件的用户一样,正常地绑定一个 返回了 promise 的函数)
根据组件功能模拟用户行为(点击按钮)
断言当前组件表现符合预期(判断组件上是否存在 loading 样式)
断言 promise 进入 resolve 状态后,loading 样式是否消失(判断组件上 loading 样式是否丢失)
完整流程
通过脚手架创建完组件后,会产生对应的文件夹 __tests__
,以及对应的单元测试文件 xxx.test.tsx,文件内容如下
import { render } from '@testing-library/react';
import xxx from '../src';
describe('组件名', () => {
test('测试功能1', () => {
// 在这里写单元测试
});
test('测试功能2', () => {
// 在这里写单元测试
});
编写完测试用例后,执行 yarn test (命令已在创建组件时被初始化),可以看到测试结果
成功的测试结果
失败的结果,可以看到测试结果期望有 arco-btn-loading1 的类名,但是实际 received 到的类并没有
🌰 Button-group
确定测试功能
支持传入数组渲染下拉菜单
数组内支持定义每个 item 的点击事件及文字的自定义
import ButtonGroup from './src';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
describe('Button-Group Test', () => {
// 不同功能验证编写不同用例
test('droplist show correct', async () => {
const user = userEvent.setup();
// 渲染按钮
const { container, getByText } = render(
<ButtonGroup
title={'更多'}
dropList={[{
title: '批量导出',
}, {
render: () => <span>操作记录</span>,
}]}
/>
);
// 模拟用户行为
user.click(getByText('更多'))
await waitFor(() => {
// 期望传入的两个按钮在点击更多后正常渲染
expect(getByText('批量导出')).toBeInTheDocument()
expect(getByText('操作记录')).toBeInTheDocument()
});
})
// 不同功能验证编写不同用例
test('droplist click correct', async () => {
const user = userEvent.setup({
pointerEventsCheck: 0
});
const exportFn = jest.fn(() => {});
const { getByText } = render(
<ButtonGroup
title={'更多'}
dropList={[{
title: '批量导出',
onClick: () => exportFn()
}]}
/>
);
user.click(getByText('更多'))
await waitFor(() => {
const exportBtn = getByText('批量导出');
user.click(exportBtn);
// 期望 exportFn 函数在点击批量导出后能够被调用一次
expect(exportFn).toBeCalledTimes(1)
});
})
})
更多学习资料
jest 官方文档
React Test Library 工具库
❤️ 谢谢支持
以上便是本次分享的全部内容,希望对你有所帮助^_^
喜欢的话别忘了 分享、点赞、收藏 三连哦~。
欢迎关注公众号 ELab团队 收货大厂一手好文章~
- END -