单元测试概念
为什么要做单元测试
- 提供描述组件行为的文档
- 节省手动测试的时间
- 减少研发新特性时产生的 bug
- 改进设计
- 促进重构
单元测试简介
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。
简单来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
举个栗子
比如,我们有这样一个函数,可以将时间js时间对象转换为我们想要的格式。
/**
* @param time datetime
* @param cFormat
* @returns {*} yyyy-mm-dd hh:min:ss
*/
export function parseTime(time, cFormat) {
if (arguments.length === 0) {
return null;
}
const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}';
let date;
if (typeof time === 'object') {
date = time;
} else {
if (('' + time).length === 10) time = parseInt(time) * 1000;
date = new Date(time);
}
const formatObj = {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate(),
h: date.getHours(),
i: date.getMinutes(),
s: date.getSeconds(),
a: date.getDay()
};
return format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => {
let value = formatObj[key];
if (key === 'a') {
return ['一', '二', '三', '四', '五', '六', '日'][value - 1];
}
if (result.length > 0 && value < 10) {
value = '0' + value;
}
return value || 0;
});
}
我们需要简单测试下,这个函数是否能正确执行并转换格式。我们编写我们的测试文件。
import { parseTime } from '@/utils/date-time.js'
let myDate = new Date();
describe("检查parseTime函数", ()=>{
it("传入js时间对象,应该获得正确格式化的时间", ()=>{
expect( parseTime(myDate, '{y}-{m}-{d}') ).toBe("2019-04-16");
});
it("只传入时间,应该获得正确格式化的时间", ()=>{
expect( parseTime(myDate) ).toBe("2019-04-16");
});
});
得到结果是
可以看到,第二个没有经过测试,得到的不是2019-04-16。
在单元测试中,如果没有通过测试,要么是因为测试的对象有问题,或者是测试代码有问题。
所以我们调整后即可。
由此,我们对一次单元测试的过程有了基本的了解。
首先,对所谓“单元”的定义是灵活的,可以是一个函数,可以是一个模块,也可以是一个 Vue Component。
其次,由于测试结果中,成功的用例会用绿色表示,而失败的部分会显示为红色,所以单元测试也常常被称为 “Red/Green Testing” 或 “Red/Green Refactoring”,其一般步骤可以归纳为:
添加一个测试
运行所有测试,看看新加的这个测试是不是失败了;如果能成功则重复步骤1
根据失败报错,有针对性的编写或改写代码;这一步的唯一目的就是通过测试,先不必纠结细节
再次运行测试;如果能成功则跳到步骤5,否则重复步骤3
重构已经通过测试的代码,使其更可读、更易维护,且不影响通过测试
重复步骤1,直到所有功能测试完毕。
断言
测试框架的作用是提供一些方便的语法来描述测试用例,以及对用例进行分组。
断言是单元测试框架中核心的部分,断言失败会导致测试不通过,或报告错误信息。
对于常见的断言,举一些例子如下:
同等性断言 Equality Asserts
expect(sth).toEqual(value)
expect(sth).not.toEqual(value)
比较性断言 Comparison Asserts
expect(sth).toBeGreaterThan(number)
expect(sth).toBeLessThanOrEqual(number)
类型性断言 Type Asserts
expect(sth).toBeInstanceOf(Class)
条件性测试 Condition Test
expect(sth).toBeTruthy()
expect(sth).toBeFalsy()
expect(sth).toBeDefined()
断言库
断言库主要提供上述断言的语义化方法,用于对参与测试的值做各种各样的判断。这些语义化方法会返回测试的结果,要么成功、要么失败。常见的断言库有 Should.js, Chai.js 等。
测试用例 test case
为某个特殊目标而编制的一组测试输入、执行条件以及预期结果,以便测试某个程序路径或核实是否满足某个特定需求。
一般的形式为:
it('should ...', function() {
...
expect(sth).toEqual(sth);
});
测试套件 test suite
describe('test ...', function() {
it('should ...', function() { ... });
it('should ...', function() { ... });
...
});
mock
mock一般指在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法
广义的讲,spy 和 stub 等,以及一些对模块的模拟,对 ajax 返回值的模拟、对 timer 的模拟,都叫做 mock 。
测试覆盖率(code coverage)
回顾一下上面的图:
表格中的第2列至第5列,分别对应了四个衡量维度:
- 语句覆盖率(statement coverage):是否每个语句都执行了
- 分支覆盖率(branch coverage):是否每个
if
代码块都执行了 - 函数覆盖率(function coverage):是否每个函数都调用了
- 行覆盖率(line coverage):是否每一行都执行了
测试结果根据覆盖率被分为“绿色、黄色、红色”三种,应该关注这些指标,测试越全面,就能提供更高的保证。
同时也没有必要一味追求行覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。
Vue Test Utils
基础
明白要测试什么
对于 UI 组件来说,我们不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。
取而代之的是,我们推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。
浅渲染
在测试用例中,我们通常希望专注在一个孤立的单元中测试组件,避免对其子组件的行为进行间接的断言。
额外的,对于包含许多子组件的组件来说,整个渲染树可能会非常大。重复渲染所有的子组件可能会让我们的测试变慢。
Vue Test Utils 允许你通过 shallowMount 方法只挂载一个组件而不渲染其子组件 (即保留它们的存根):
import { shallowMount } from '@vue/test-utils'
const wrapper = shallowMount(Component)
wrapper.vm // 挂载的 Vue 实例
仿造注入
另一个注入 prop 的策略就是简单的仿造它们。你可以使用 mocks 选项:
import { mount } from '@vue/test-utils'
const $route = {
path: '/',
hash: '',
params: { id: '123' },
query: { q: 'hello' }
}
mount(Component, {
mocks: {
// 在挂载组件之前
// 添加仿造的 `$route` 对象到 Vue 实例中
$route
}
})
探测样式
当你的测试运行在 jsdom 中时,只能探测到内联样式。Jest就是这样。
事件
断言触发的事件
每个挂载的包裹器都会通过其背后的 Vue 实例自动记录所有被触发的事件。你可以用 wrapper.emitted()
方法取回这些事件记录。
wrapper.vm.$emit('foo')
wrapper.vm.$emit('foo', 123)
/*
`wrapper.emitted()` 返回以下对象:
{
foo: [[], [123]]
}
*/
然后你可以基于这些数据来设置断言:
// 断言事件已经被触发
expect(wrapper.emitted().foo).toBeTruthy()
// 断言事件的数量
expect(wrapper.emitted().foo.length).toBe(2)
// 断言事件的有效数据
expect(wrapper.emitted().foo[1]).toEqual([123])
你也可以调用 wrapper.emittedByOrder()
获取一个按触发先后排序的事件数组。
从子组件触发事件
你可以通过访问子组件实例来触发一个自定义事件
待测试的组件
触发!
测试代码
import { shallowMount } from '@vue/test-utils'
import ParentComponent from '@/components/ParentComponent'
import ChildComponent from '@/components/ChildComponent'
describe('ParentComponent', () => {
it("displays 'Emitted!' when custom event is emitted", () => {
const wrapper = shallowMount(ParentComponent)
wrapper.find(ChildComponent).vm.$emit('custom')
expect(wrapper.html()).toContain('Emitted!')
})
})
测试键盘、鼠标等其它 DOM 事件
触发事件
Wrapper
暴露了一个 trigger
方法。它可以用来触发 DOM 事件。
const wrapper = mount(MyButton)
wrapper.trigger('click')
你应该注意到了,find
方法也会返回一个 Wrapper
。假设 MyComponent
包含一个按钮,下面的代码会点击这个按钮。
const wrapper = mount(MyComponent)
wrapper.find('button').trigger('click')
选项
其 trigger
方法接受一个可选的 options
对象。这个 options
对象里的属性会被添加到事件中。
注意其目标不能被添加到 options
对象中。
const wrapper = mount(MyButton)
wrapper.trigger('click', { button: 0 })
鼠标点击示例
待测试的组件
测试
import YesNoComponent from '@/components/YesNoComponent'
import { mount } from '@vue/test-utils'
import sinon from 'sinon'
describe('Click event', () => {
it('Click on yes button calls our method with argument "yes"', () => {
const spy = sinon.spy()
const wrapper = mount(YesNoComponent, {
propsData: {
callMe: spy
}
})
wrapper.find('button.yes').trigger('click')
spy.should.have.been.calledWith('yes')
})
})
键盘示例
待测试的组件
这个组件允许使用不同的按键将数量递增/递减。
异步测试
import QuantityComponent from '@/components/QuantityComponent'
import { mount } from '@vue/test-utils'
describe('Key event tests', () => {
it('Quantity is zero by default', () => {
const wrapper = mount(QuantityComponent)
expect(wrapper.vm.quantity).toBe(0)
})
it('Up arrow key increments quantity by 1', () => {
const wrapper = mount(QuantityComponent)
wrapper.trigger('keydown.up')
expect(wrapper.vm.quantity).toBe(1)
})
it('Down arrow key decrements quantity by 1', () => {
const wrapper = mount(QuantityComponent)
wrapper.vm.quantity = 5
wrapper.trigger('keydown.down')
expect(wrapper.vm.quantity).toBe(4)
})
it('Escape sets quantity to 0', () => {
const wrapper = mount(QuantityComponent)
wrapper.vm.quantity = 5
wrapper.trigger('keydown.esc')
expect(wrapper.vm.quantity).toBe(0)
})
it('Magic character "a" sets quantity to 13', () => {
const wrapper = mount(QuantityComponent)
wrapper.trigger('keydown', {
key: 'a'
})
expect(wrapper.vm.quantity).toBe(13)
})
})
为了让测试变得简单,@vue/test-utils
同步应用 DOM 更新。不过当测试一个带有回调或 Promise 等异步行为的组件时,你需要留意一些技巧。
API 调用和 Vuex action 都是最常见的异步行为之一。下列例子展示了如何测试一个会调用到 API 的方法。这个例子使用 Jest 运行测试用例同时模拟了 HTTP 库 axios
。更多关于 Jest 的手动模拟的介绍可移步这里。
axios
的模拟实现大概是这个样子的:
export default {
get: () => Promise.resolve({ data: 'value' })
}
下面的组件在按钮被点击的时候会调用一个 API,然后将响应的值赋给 value
。
测试用例可以写成像这样:
import { shallowMount } from '@vue/test-utils'
import Foo from './Foo'
jest.mock('axios')
it('fetches async when a button is clicked', () => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
expect(wrapper.vm.value).toBe('value')
})
现在这则测试用例会失败,因为断言在 fetchResults
中的 Promise 完成之前就被调用了。大多数单元测试库都提供一个回调来使得运行期知道测试用例的完成时机。Jest 和 Mocha 都是用了 done
。我们可以和 $nextTick
或 setTimeout
结合使用 done
来确保任何 Promise 都会在断言之前完成。
it('fetches async when a button is clicked', done => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.value).toBe('value')
done()
})
})
setTimeout
允许测试通过的原因是 Promise 回调的 microtask 队列会在处理 setTimeout
的回调的任务队列之前先被处理。也就是说在 setTimeout
的回调运行的时候,任何 microtask 队列上的 Promise 回调都已经执行过了。另一方面 $nextTick
会安排一个 microtask,但是因为 microtask 队列的处理方式是先进先出,所以也会保证回调在作出断言时已经被执行。更多的解释请移步这里。
另一个解决方案是使用一个 async
函数配合 npm 包 flush-promises
。flush-promises
会清除所有等待完成的 Promise 具柄。你可以 await
该 flushPromiese
调用,以此清除等待中的 Promise 并改进你的测试用例的可读性。
更新后的测试看起来像这样:
import { shallowMount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import Foo from './Foo'
jest.mock('axios')
it('fetches async when a button is clicked', async () => {
const wrapper = shallowMount(Foo)
wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.vm.value).toBe('value')
})
相同的技巧可以被运用在同样默认返回一个 Promise 的 Vuex action 中。
注意
注意任何在其内部被抛出的错误可能都不会被测试运行器捕获,因为其内部使用了 Promise。关于这个问题有两个建议:要么你可以在测试的一开始将 Vue 的全局错误处理器设置为 done 回调,要么你可以在调用 nextTick 时不带参数让其作为一个 Promise 返回:
// 这不会被捕获
it('will time out', done => {
Vue.nextTick(() => {
expect(true).toBe(false)
done()
})
})
// 接下来的两项测试都会如预期工作
it('will catch the error using done', done => {
Vue.config.errorHandler = done
Vue.nextTick(() => {
expect(true).toBe(false)
done()
})
})
it('will catch the error using a promise', () => {
return Vue.nextTick().then(function() {
expect(true).toBe(false)
})
})