企业级前端基础建设之我需要一个公共组件库之公共组件库非常适合也需要写单元测试之所以我先简单介绍下单元测试
系列介绍
本系列是前端团队所需要的一些提高工作效率和交付质量的基础建设的实战文档,包括公共组件库,脚手架,CI/CD,server端等方面。
公共组件库是单元测试最好的实践地
我们知道在国内前端层面的单元测试是不受重视的,一方面对开发的要求高,另一方面业务代码的单元测试可能在一次需求变更后就需要重新写。
所以由于公共组件库的对稳定性的高要求,基本结构稳定等特点,十分适合单元测试的落地。本文会使用单元测试介绍-框架对比-jest语法-Vue Test Utils-TDD实战的结构。
什么是单元测试
单元测试,是指对软件中的最小可测试单元进行检查和验证,也就是说一个测试单元往往是一个原子型函数。
拥有单元测试的程序有以下几个好处:
1、它可以验证结果
测试的意义就是验证正确性,程序中的每一项功能都是测试来验证它的正确性。
2、它是一种视野的转变
编写单元测试将使我们从用户或者说测试的角度观察、思考。迫使我们把程序设计成易于调用和可测试的,即迫使我们解除软件中的耦合。
3、写了单测相当于写了文档
单元测试的期望即是正确的结果嘛,所以说可以当文档来用。
4、非常节省回归测试的时间
良好的自动化的单元测试可以完全不需要qa进行回归测试。
前端测试框架对比
通用测试框架基本是做如下事
-
describe:描述你要测试的东西 -
test:对其进行测试 -
expect:判断是否符合预期
我们直接用表格来对比,其中详细的内容会在本篇文章后面一一详解
| 框架 | 特点 |
|---|---|
| Jest | * facebook 坐庄 * 基于 Jasmine 至今已经做了大量修改添加了很多特性 * 开箱即用配置少,API简单 * 支持断言和仿真 * 支持快照测试 * 在隔离环境下测试 * 互动模式选择要测试的模块 * 优雅的测试覆盖率报告,基于Istanbul * 智能并行测试(参考) * 较新,社区不十分成熟 * 全局环境,比如 describe 不需要引入直接用 * 较多用于 React 项目(但广泛支持各种项目) |
| Mocha | * 灵活(不包括断言和仿真,自己选对应工具)流行的选择:chai,sinon * 社区成熟用的人多,测试各种东西社区都有示例 * 需要较多配置 * 可以使用快照测试,但依然需要额外配置 |
| Jasmine | * 开箱即用(支持断言和仿真) * 全局环境 * 比较'老',坑基本都有人踩过了 |
| AVA | * 异步,性能好 * 简约,清晰 * 快照测试和断言需要三方支持 |
| Tape | * 体积最小,只提供最关键的东西 * 对比其他框架,只提供最底层的 API |
对我们小组来说,尽量开箱即用,所以我们选择Jest。
Jest使用
这一部分jest熟悉者可以跳过
断言:用于判断结果是否符合预期
jest会提供一系列的api(被称为matcher)来判断我们的结果,下面就是一个最简单的测试或者说是断言。
test('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
上面的toBe就是一个matcher,而所有的内容都包裹在test中,每一个test包裹的内容称为一个case,每一个case基本上就是一个独立的功能点。
上面的断言,使用expect这种语法,非常接近于英语的语法,即使没用过库,上面的这段代码我们也知道是在做什么。
异步支持
我们测试的异步方法大概分为如下几类
callback
const fetchXxx = callback => {
setTimeout(() => {
callback('jest')
}, 1000);
}
// it即test的别名
it('test callback' ,() => {
fetchXxx(data => {
expect(data).toBe('jest');
})
})
Promise,需要在断言中将promise返回,否则断言不会执行,直接通过
const fetchPromise = () => Promise.resolve('jest')
it('test promise', () => {
return fetchPromise().then(data => {
expect(data).toBe('jest');
})
})
async - await
const fetchPromise = () => Promise.resolve('jest')
it('test with async', async () => {
const data = await fetchPromise();
expect(data).toBe('jest');
})
还用两种jest自带的异步方法
resolves - rejects
const fetchPromise = () => Promise.resolve('jest')
it('test with resolves', async () => {
expect(fetchPromise()).resolves.toBe('jest');
})
it('test with reject', async () => {
expect(fetchPromise()).rejects.toBe('error');
})
Mock
Mock的两大功能
- 创建
mock function,在测试中使用,用来测试回调
// 当isNeedCall为true时,执行callback函数
function mockFunc(isNeedCall, callback) {
if(isNeedCall) {
callback(0);
}
}
it('test with mock fucntion', () => {
// 创建mock方法,可以认为是一个代理或者监听器
const mockCallback = jest.fn(x => x++);
mockFunc(true, mockCallback);
// 判断mockCallback是否被调用
expect(mockCallback).toHaveBeenCalled();
// 判断mockCallback被调用的参数
expect(mockCallback).toHaveBeenCalledWith(0);
// 判断mockCallback被调用的次数
expect(mockCallback).toHaveBeenCalledTimes(1);
// 入参
console.log(mockCallback.mock.calls);
// return
console.log(mockCallback.mock.results);
})
- 手动
mock,覆盖第三方实现,最常见的就是对网络请求axios的mock
const axios = require('axios');
const mockData = {};
jest.mock('axios');
// 覆盖axios的get方法
axios.get.mockImplementation(() => {
return Promise.resolve({data: mockData});
})
mock覆盖第三方实现还有一种方式,可以在项目根目录新建__mocks__文件夹,其中文件名和你要覆盖的第三方实现的名称一样
// __mocks__/axios.js
const mockData = {};
const axios = {
get: jest.fn(() => Promise.resolve({data: mockData}))
}
module.exports = axios;
这里面有一个小点,就是在使用ts的时候,我们用mock替换了第三方应用之后就不能使用自动补全了,所以我们使用jest提供的一个类型
const axios = require('axios');
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>
其他具体的api请参考jest官网
Vue Test Utils
Vue Test Utils是用于测试vue项目的工具,它提供特定的方法,在隔离的环境下,进行组件的挂载,以及一系列的测试。
vue add unit-jest
在vue cli项目中我们添加unit-jest插件,它会自动
-
添加
vue-test-utils依赖 -
添加 vue-jest依赖,负责将
vue文件转化文ts文件 -
添加
presets/typescript-babel将ts文件转化为js文件 -
注入新的命令
vue-cli-service test:unit
组件测试
组件测试大概分为如下过程
- 渲染组件
- 使用mount或shallowMount(区别:mount 是全都渲染,shallowMount 只渲染组件本身,外来的子组件都不渲染,更快,更适合单元测试)
- 传递属性(props)
- UI检查
- get 和 find(find 返回 null,case不会出错,get会throw 错误,case 报错)
- findComponent 和 getComponent
import {shallowMount} from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message';
const wrapper = shallowMount(HelloWorld, {
props: {msg}
});
expect(wrapper.get('h1').text()).toMatch(msg);
expect(wrapper.find('h1').text()).toMatch(msg);
});
});
- 事件检查
- trigger方法(注意:更新dom操作属于异步方法,需要async await)
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', async () => {
const msg = 'new message';
const wrapper = shallowMount(HelloWorld, {
props: {msg}
});
await wrapper.get('button').trigger('click');
expect(wrapper.get('button').text()).toBe('clicked');
});
});
- 表单检查
- setValue方法
- 自定义事件验证
- emitted方法
describe('HelloWorld.vue', () => {
it('测试一个todolist', async () => {
const msg = 'new message';
const wrapper = shallowMount(HelloWorld, {
props: {msg}
});
const todoContent = '背单词';
await wrapper.get('input').setValue(todoContent);
// 测试input有没有输入value
expect(wrapper.get('input').element.value).toBe(todoContent);
await wrapper.get('.add-todo-btn').trigger('click');
// 验证todolist的长度是否为1
expect(wrapper.findAll('.todo-item')).toHaveLength(1);
// 验证todoitem是否为背单词
expect(wrapper.get('.todo-item').text()).toBe(todoContent);
// 验证是否发送自定义事件
expect(wrapper.emitted()).toHaveProperty('add');
const event = wrapper.emitted('add');
// 验证发送的自定义事件中的值是否为背单词
expect(event[0]).toEqual([todoContent]);
});
});
- 第三方组件
当组件内有引用第三方组件的时候,mock后会有warning,这时候需要有一个全局注册的动作,注册一些mock compnent即可,至于第三方组件的测试,则由第三方组件自己来测试。
jest.mock('ant-design-vue');
const mockComponent = {
template: '<div><slot></div></slot>'
};
const globalComponent = {
'a-button': mockComponent,
'a-model': mockComponent
};
describe('HelloWorld.vue', () => {
it('组件内使用ant-design-vue', async () => {
const msg = 'new message';
const wrapper = mount(HelloWorld, {
props: {msg}
},
// 使用global注册组件
global: {
components: globalComponent
});
});
});
Test Driven Development - 测试驱动开发
TDD即测试驱动开发,其实就是将需求整理成测试用例,先编写测试用例,当然编写完后由于没有代码我们的测试用例一个也不会通过,之后再开始编写代码,一步步的将各个测试用例通过的这个过程,具体的定义可以看wiki
这里我们使用一个实际组件的开发场景来做实战
一个如下图所示的颜色选择器
UI
首先是UI层的测试,我们的结构大概就是左面是form的label,右面是一个color类型的input。所以我们这么写测试:
describe('CorlorInput.vue', () => {
it('UI测试', async () => {
const wrapper = mount(CorlorInput, {
props: {
value: '#ffffff'
}
});
// 存在label
expect(wrapper.find('.label').exists()).toBeTruthy();
const label = wrapper.get('.label').element;
// label为字体颜色
expect(label.text()).toBe('字体颜色');
// 存在input标签
expect(wrapper.find('input').exists).toBeTruthy();
const input = wrapper.get('input').element;
// input的type是color
expect(input.type).toBe('color');
// input的value是#ffffff
expect(input.value).toBe('#ffffff');
});
});
行为
然后是行为的测试,我们知道这种组件更改value后应该会发送一个change事件
describe('CorlorInput.vue', async () => {
it('行为测试', async () => {
const wrapper = mount(CorlorInput, {
props: {
value: '#ffffff'
}
});
const black = '#000000';
const input = wrapper.get('input');
await input.setValue(black);
// 发送自定义事件
expect(wrapper.emitted()).toHaveProperty('change');
const events = wrapper.emitted('change');
// 发送事件的内容,对象非引用相等应该使用toEqual
expect(events[0]).toEqual([black]);
});
});
这个时候我们可以看到我们的测试用例是全都失败的
接下来我们应该根据测试用例的逻辑逐步实现代码
最后呢,我们会实现一个循环用例失败 - 代码实现(用例通过) - 代码优化,然后又是一个循环,通过这个过程就完成了一个标准TDD流程。将编程的过程任务化,可以对进度做到更加进准的把握。
具体的实现就不写了,下一节课我们会用TDD的方式通过一个公共组件库的组件的完整实践同时来完成我们TDD的实战