组件库从开发到维护全链路讲解(四)覆盖单元测试的最佳实践
本篇文章为《前端组件库的开发与维护》系列的第四篇文章。本文案例在线文档:calendar.hxkj.vip。GitHub 仓库:github.com/TangSY/vue3…。
单元测试
一说到单元测试,对于业务繁忙的 jym 来说,会有一种抵触感油然而生,平时只是单纯的业务开发可能都要 996 了,难道要 007 去完成单元测试?
为什么需要单元测试
然而,对于组件开发者来说,提供完善的单元测试是必不可少的一环,相信大部分人在挑选组件库的时候都会看看该组件库有没有单元测试,没有的话,意味着不稳定,这是很可怕的,谁也不想在项目中引入一个定时炸弹。言外之意就是——我自己可以不写,但不允许你也不写。
如果在开发前期没有配备对应的测试用例,随着维护的时间拉长之后会面临以下几个问题:
- 有 issue 不敢修复。这是亲身体会到的,由于我在开发 Vue2 版本日历组件库的时候,没有提供单元测试。在某次修复一个 issue 之后的第二天,突然接到大量用户的反馈组件某个功能用不了了,搞得我一激灵赶紧拿出电脑来看看,结果发现正是由于我昨天修复 bug 时引出来的问题。这个事件导致后续我每次修复大家在 GitHub 上提交的 issue 的时候都战战兢兢。
- 每次版本迭代都需要花费大量的时间手动测试组件的各项功能是否正常,这个非常依赖咱们的细心程度,难以保证每次都能覆盖到所有的功能和场景。
- 组件功能的优化/重构困难。惧怕在优化时一个不小心给组件带来了新的 bug,于是乎组件越来越臃肿,越来越难以维护。
正是基于以上三个原因,让我下定决心在 Vue3 版本日历组件库项目中必须把单元测试安排上。
单元测试原则
先概括性的总结以下四条原则,后面结合具体案例详细讲解:
- 抛弃开发人员的身份,只考虑测试场景,不考虑内部代码实现。
- 测试数据尽可能模拟现实场景,越接近现实场景越好。
- 充分考虑数据的边界条件。
- 对重点、复杂、核心的代码,着重测试。
单元测试规范
相应的单元测试规范主要有以下四点:
- 测试文件一般统一在 src/tests 目录中维护。
- 测试文件命名与Vue组件命名保持一致,后面以 .spec.js 结尾,如果是 ts 项目就以 .spec.ts 结尾,比如:index.spec.ts
- 一个测试文件只能描述一个功能集合,这个功能集合可以是一个 Vue 组件,一个文档渲染示例,也可以是一个功能模块。
- 测试用例必须包含 API 覆盖性测试用例、DOM 结构等校验。
单元测试代码设计
由于咱们选的是 Vant-cli,里面内置的测试框架就是 jest + vue-test-utils,那就不挑其他的了,直接用这套即可。
开始之前先了解下常用的几个 API:
test(name, fn, timeout)
:test
有别名it
,两个方法是一样的。第一个参数是你想要描述的测试用例名称; 第二个参数是包含测试期望的函数,也是测试用例的核心。第三个参数(可选)是超时时间,也就是超过多久将会取消测试(默认是5秒钟);mount(component,options)
:创建一个包含被挂载和渲染的 Vue 组件的 Wrapper。如果我们不想同时挂载子组件的话,可以使用shallowMount(component,options)
,它和 mount 一样,创建一个包含被挂载和渲染的 Vue 组件的 Wrapper,不同的是 shallowMount 不会渲染子组件。第一个参数是要挂载的组件,第二个参数(可选)是要传入组件的参数;toBe(value)
:该函数内部使用了 Object.is 来进行精确匹配,它的特性类似于 ===,对于普通类型的数值可以进行比较,但是对于对象数组等复杂类型,就需要用到toEqual
来比较了。并且不能用于比较浮点数;toEqual(value)
:递归检查对象或数组的每个字段,若每个的字段顺序、属性、属性值均相等则判断为 true;toContain(value)
:对于数组、set或者字符串等可迭代类型的数据,可以通过该函数来判断内部是否有某一项;not
:就是取反的意思。它一般出现在上面三个比较函数的前面,例如:not.toBe('XXX')
toMatchSnapshot(snapshotName?)
:被称为 快照(对比)测试。每当你想要确保你的 UI 不会有意外改变时,快照测试就可以派上用场了。其原理是:当你第一次执行测试用例时,它会将你组件的渲染状态保存为纯文本,再次执行这个测试用例时,如果渲染的组件的任何部分发生更改,则该测试用例将失败并指出差异;
了解完了这些 API 之后,就可以开始上路了,接下来进入实战环节。
1、Prop 测试
首先我们对组件的 props 进行测试,这一步必须包含所有的 prop 属性以及所有可能的属性值,需要尽可能的预判到所有的输入输出(包括预期的正确/错误结果)。
例如:通过给 showArrow 传入不同的值(true/false)来控制是否展示日历的星期/月份切换按钮
test('show-arrow prop', async () => {
// 挂载组件。 showArrow 默认值为:false
const wrapper = mount(Calendar);
// 先断言切换按钮是否不存在
expect(wrapper.find('.ctrl-img').exists()).toBeFalsy();
// 将 showArrow 设置为:true
await wrapper.setProps({ showArrow: true });
// 再断言切换按钮是否存在
expect(wrapper.find('.ctrl-img').exists()).toBeTruthy();
});
所有 prop 相关的测试用例可以移步至 GitHub 查看:传送门
2、Event 测试
然后我们对组件的 event 进行测试,这一步必须包含所有的 event 事件。每个触发的时机都必须包含,每个事件触发之后的返回值都应该对其进行预期结果的断言。
例如:组件中的 overRange 事件,范围选择类型和多选类型,选择的天数超过最多可选天数时都会触发,所以需要对这两种场景分别进行测试
// 测试多选类型的 overRange 事件
test('test event of multiple overRange', async () => {
// mock overRange 事件
const onOverRange = jest.fn();
// 挂载组件并设置好对应的属性和事件
const wrapper = mount(Calendar, {
props: {
selectType: 'multiple', // 多选类型
maxRange: 3, // 最大可选三天
onOverRange, // overRange 事件
},
});
// 获取日历面板中的所有日期
const days = wrapper.findAll('.calendar_day');
// 模拟点击第 55 个日期
await days[55].trigger('click');
// 此时不触发 overRange 事件
expect(onOverRange).toHaveBeenCalledTimes(0);
// 模拟点击第 56 个日期
await days[56].trigger('click');
// 此时不触发 overRange 事件
expect(onOverRange).toHaveBeenCalledTimes(0);
// 模拟点击第 57 个日期
await days[57].trigger('click');
// 此时不触发 overRange 事件
expect(onOverRange).toHaveBeenCalledTimes(0);
// 模拟点击第 58 个日期
await days[58].trigger('click');
// 此时选中的日期已经超过三个了,正常触发 overRange 事件
expect(onOverRange).toHaveBeenCalledTimes(1);
});
// 测试范围类型的 overRange 事件
test('test event of range overRange', async () => {
const onOverRange = jest.fn();
const wrapper = mount(Calendar, {
props: {
selectType: 'range',
maxRange: 3,
onOverRange,
},
});
const days = wrapper.findAll('.calendar_day');
await days[55].trigger('click');
expect(onOverRange).toHaveBeenCalledTimes(0);
await days[57].trigger('click');
expect(onOverRange).toHaveBeenCalledTimes(0);
await days[55].trigger('click');
expect(onOverRange).toHaveBeenCalledTimes(0);
await days[58].trigger('click');
expect(onOverRange).toHaveBeenCalledTimes(1);
});
看到这,可能有的同学会问了,既然是判断超过三天才会触发 overRange
事件,为啥断言的那一段代码不直接写成下面这样,一步到位即可:
await days[55].trigger('click');
await days[57].trigger('click');
await days[55].trigger('click');
await days[58].trigger('click');
expect(onOverRange).toHaveBeenCalledTimes(1);
是因为,我们需要保证当且仅当超过三天的时候才触发事件,如果像上述代码一样,万一哪天改动了逻辑,导致选了一天就触发事件了,不就捕获不到这个异常了,很容易出问题。
所有 event 相关的测试用例可以移步至 GitHub 查看:传送门
3、Slot 测试
接下来我们对组件的 slot 进行测试,这一步必须包含所有的 slot 插槽。必须保证每个插槽在不同传参下的渲染结果能达到对应的预期效果。
例如:组件中的 slot 插槽,当日历面板切换按钮时,会展示传递给该插槽不同的值。
test('arrow slot', async () => {
// 定义 arrow slot
const arrowSlot = (data: any) => {
if (data.isShowWeek) {
return '展开'; // 当日历面板收起时,显示 展开
}
return '收起';// 当日历面板展开时,显示 收起
};
// 挂载组件,并且 arrow 插槽传入
const wrapper = mount(Calendar, {
props: { showArrow: true },
slots: {
arrow: arrowSlot,
},
});
// 查找日历切换按钮
const ctrl = wrapper.find('.ctrl-img');
// 初次渲染之后,切换按钮文字为:收起
expect(ctrl.text()).toBe('收起');
// 第一次点击日历切换按钮
await ctrl.trigger('click');
// 点击切换按钮之后,切换按钮文字变为:展开
expect(ctrl.text()).toBe('展开');
// 再次点击切换按钮
await ctrl.trigger('click');
// 再次点击切换按钮之后,切换按钮文字变为:收起
expect(ctrl.text()).toBe('收起');
});
所有 slot 相关的测试用例可以移步至 GitHub 查看:传送门
4、Demo 测试
如果咱们的组件库文档非常详细的话,demo 的数量无疑是巨多的,那怎么样对它进行一一测试呢?其实只需要进行快照测试即可。因为前面已经对组件里所有的 props、事件、方法覆盖测试过了,这些 demo 都是对前面的东西进行调用而已,就没必要测得特别详细了。每个 demo 一个 Snapshot 用例只是为了保证 demo 的渲染效果不被改变。
代码实现如下:
import Demo from '../demo/index.vue'; // 引入 demo 组件
const wrapper = mount(Demo); // 组件挂载
expect(wrapper.html()).toMatchSnapshot();// 进行快照测试
如果遇到无法生成测试覆盖率报告的问题,可以参考这条 issue:github.com/vuejs/vue-t…
总结
到此,如何对一个组件进行系统性的测试就已经讲完了,下一篇咱们来看看代码提交会有怎样的规范。
对此系列感兴趣的,不妨一键三连(点赞 + 关注 + 收藏),方便跟进后续文章。
欢迎大家在评论区留下宝贵的建议!
本系列往期文章
转载自:https://juejin.cn/post/7193857337059835961