单元测试的作用毋庸置疑能带来很多好处,但是如何去写好单元测试.
单元测试好处
- 提供描述组件行为的文档
- 节省手动测试的时间
- 减少研发新特性时产生的 bug
- 改进设计
- 促进重构
TDD & BDD
TDD(Test Driven Development)测试驱动开发
TDD 的思想是根据需求先写测试用例,依照测试用例再去写功能代码。当增加或者修改某一项需求的时候,需要先修改测试用例,再依照测试用例去修改代码逻辑。
基本步骤:
- 编写测试用例
- 运行测试,测试用例无法通过测试
- 编写代码,使测试用例通过测试
- 优化代码,完成开发
- 重复以上步骤
BDD(Behavior Driven Development)行为驱动开发
与 TDD 相反,BDD 是根据需求先进行开发,等到该功能开发完毕后,再开始编写测试代码进行测试。
基本步骤:
- 编写代码
- 编写测试用例,测试无法通过
- 编写代码,使测试用例通过
- 优化代码,完成开发
- 重复以上步骤
Jest配置
了解到vue-cli构建的项目,在初始化时会询问是否使用单元测试,只需按步骤选择jest即可,会自动安装Vue Test Utils,它是 Vue.js 官方的单元测试实用工具库,为 jest
和 Vue
提供了一个桥梁,暴露出一些接口,让我们更加方便的通过 Jest
为 Vue
应用编写单元测试。
为项目增加单元测试
- 新建空白目录,打开终端输入 vue create ‘你的项目名’ 回车创建
vue create vue-jest
- 选择配置
- Manually select features 是自己手动选择
- 考虑到以后新项目写,就选择加上了ts, Unit Testing就是单元测试
- 其他配置如下图: 主要是单元测试这个选择jest(Pick a unit testing solution: Jest)
- 创建成功后执行以下命令就可以通过vscode查看项目目录
cd vue-jest
code .
配置jest
在生成的项目根目录下,会有一个 jest.config.json
文件,cli
自动生成所用的预设是@vue/cli-plugin-unit-jest
。我们可以在这里对 jest
进行个性化的配置。以下是一个配置文档的例子。配置文档具体参数说明 在项目根目录下找到jest.config.json
,修改如下,(有些配置已注释,按需打开)
module.exports = {
preset: "@vue/cli-plugin-unit-jest/presets/typescript-and-babel",
// 开启测试报告
collectCoverage: true,
// // 统计哪里的文件
collectCoverageFrom: ["**/src/components/**","**/src/views/common/**", "!**/node_modules/**"],
// // 告诉jest针对不同类型的文件如何转义
transform: {
"^.+\\.vue$": "vue-jest",
// '^.+\\.(vue)$': '<rootDir>/node_modules/vue-jest',
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\\.jsx?$': 'babel-jest',
'^.+\\.ts?$': 'ts-jest'
},
// 告诉jest需要解析的文件
moduleFileExtensions: [
'js',
'ts',
'jsx',
'json',
'vue'
],
// // 告诉jest去哪里找模块资源,同webpack中的modules
moduleDirectories: [
'src',
'node_modules'
],
// // 告诉jest在编辑的过程中可以忽略哪些文件,默认为node_modules下的所有文件
transformIgnorePatterns: [
'<rootDir>/node_modules/'
+ '(?!(vue-awesome|vant|resize-detector|froala-editor|echarts|html2canvas|jspdf))'
],
// // 别名,同webpack中的alias
// moduleNameMapper: {
// '^src(.*)$': '<rootDir>/src/$1',
// '^@/(.*)$': '<rootDir>/src/$1',
// '^block(.*)$': '<rootDir>/src/components/block/$1',
// '^toolkit(.*)$': '<rootDir>/src/components/toolkit/$1'
// },
// snapshotSerializers: [
// 'jest-serializer-vue'
// ],
// // 告诉jest去哪里找我们编写的测试文件
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
// '**/tests/unit/**/Test.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
// 在执行测试用例之前需要先执行的文件
// setupFiles: ['jest-canvas-mock']
};
调试单元测试
项目添加了单元测试,里面默认会有一条写好的单元测试,执行npm run test:unit
命令,终端就可以看到一条单元测试通过的记录。
%Stmts(statement coverage): 语句覆盖率,是否每个语句都执行了?
%Branch(branch coverage): 分支覆盖率,是否每个if代码块都执行了?
%Funcs(branch coverage): 函数覆盖率,是否每个函数都调用了?
%Lines(line coverage): 行覆盖率,是否每一行都执行了?
编写单元测试
Vue Test Utils
在编写测试用例之前,我们先来简单了解一下 Vue Test Utils 的 几个 API 以及 Wrapper。
mount: 创建一个包含被挂载和渲染的 Vue 组件的 Wrapper
shallowMount: 与mount作用相同,但是不渲染子组件
render: 将一个对象渲染成为一个字符串并返回一个 cheerio 包裹器
createLocalVue: 返回一个 Vue 的类,供你添加组件、混入和安装插件而不会污染全局的 Vue 类
wrapper: 一个 wrapper 是一个包括了一个挂载组件或 vnode,以及测试该组件或 vnode 的方法。
wrapper.vm: 可以访问一个实例所有的方法和属性
wrapper.get() : 返回DOM节点或者Vue组件
wrapper.setData() : 同Vue.set()
wrapper.trigger(): 异步触发事件
wrapper.find(): 返回DOM节点或者Vue组件
具体的 API 和 Warpper方法和实例 可以参考这里。
一、编写一个TodoApp
1. TodoApp
从一个带有单个 todo的简单组件开始:
在src下新建common文件夹,新建TodoApp.vue文件,编写以下内容
<template>
<div v-for="(todo,key) in todos" :key="key" data-test="todo"
:class="[todo.completed ? 'completed' : '']">
{{ todo.text }}
<input
type="checkbox"
v-model="todo.completed"
data-test="todo-checkbox"
/>
</div>
<form data-test="form" @submit.prevent="createTodo">
<input data-test="new-todo" v-model="state.newTodo" />
</form>
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
let state = reactive({
newTodo: ''
})
let todos = reactive([
{
text: 'Learn Vue.js 3',
completed: false
}
])
const createTodo = () => {
todos.push({
text: state.newTodo,
completed: false
})
}
</script>
2. 新建测试文件
在根目录下tests\unit下新建todo.spec.ts文件,首页测试页面默认第一个todo 是否已呈现
import { mount } from '@vue/test-utils'
import TodoApp from '@/views/common/TodoApp.vue'
test('renders a todo', () => {
// 1. 通过mount创建一个包含被挂载和渲染的 Vue 组件的 Wrapper
const wrapper = mount(TodoApp)
// 2.通过属性选择器获取该标签
const todo = wrapper.get('[data-test="todo"]')
// 3.通过text方法获取文本内容,最后断言是否等于Learn Vue.js 3
expect(todo.text()).toBe('Learn Vue.js 3')
})
3. 调试第一个测试用例
执行npm run test:unit
可以看出我们的测试用例和项目自带的都通过了测试。
4. 添加新的待办事项
一个模块单独写test比较分散,可以利用describe 函数
通过test函数可以创建一个个的测试用例,那当我们的测试用例越来越多的时候,就需要对测试用例进行分类整理,那这就是describe函数的作用。
describe('todoAll', () => {
it('renders a todo', () => {
const wrapper = mount(TodoApp)
const todo = wrapper.get('[data-test="todo"]')
expect(todo.text()).toBe('Learn Vue.js 3')
})
it('creates a todo', async () => {
const wrapper = mount(TodoApp)
// 1. 通过setValue设置input标签的value是New todo
await wrapper.get('[data-test="new-todo"]').setValue('New todo')
// 2. 通过trigger触发form表单提交事件
await wrapper.get('[data-test="form"]').trigger('submit')
// 3. 判断长度是否等2,等于2代表创建成功了
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)
})
})
- it和test的效果是一样的,两者语义差不多,只是it语义更好理解
- Jest 以同步方式执行测试,一旦调用最终函数就结束测试。然而,Vue 会异步更新 DOM。我们需要标记 test async,并调用await任何可能导致 DOM 更改的方法
执行npm run test:unit
,可以看到所有的单测都通过了.
5. 是否完成代办
可以通过判断div上是否有completed类,代表是否勾选
describe('todoAll', () => {
it('renders a todo', () => {
const wrapper = mount(TodoApp)
const todo = wrapper.get('[data-test="todo"]')
expect(todo.text()).toBe('Learn Vue.js 3')
})
it('creates a todo', async () => {
const wrapper = mount(TodoApp)
// 1. 通过setValue设置input标签的value是New todo
await wrapper.get('[data-test="new-todo"]').setValue('New todo')
// 2. 通过trigger触发form表单提交事件
await wrapper.get('[data-test="form"]').trigger('submit')
// 3. 判断长度是否等2,等于2代表创建成功了
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)
})
it('completes a todo', async () => {
const wrapper = mount(TodoApp)
await wrapper.get('[data-test="todo-checkbox"]').setValue(true)
// 通过classes方法获取标签上所有的类名,然后toContain判断数组里是否包含completed类名
expect(wrapper.get('[data-test="todo"]').classes()).toContain('completed')
})
})
执行npm run test:unit
,可以看到到复选框选中时,父元素div是有completed,所以通过单测
6. 动态参数
像上面的单测,里面的内容都是写死的,无法根据实际情况动态修改参数,为了方便测试,把参数抽离出来。调整为外部文件动态传参。以创建todo为例,理想状态是传入一个list参数,和一个期望值,根据list数组的长度去创建n个todo,判断创建todo的长度是否和期望值相等。
在src/views/common下面新建configJS
文件夹,然后在新建configData.ts
文件.
configData.ts
let createsTodo = {
list: ['a', 'b', 'c'], // 想要动态创建多少个
expected: 5 // 期望长度
}
export default {
createsTodo
}
todo.spec.ts
import { mount } from '@vue/test-utils'
import TodoApp from '@/views/common/TodoApp.vue'
import configData from "@/views/common/configJS/configData"; // 引入测试数据
describe('todoAll', () => {
it('creates a todo', async () => {
const wrapper = mount(TodoApp)
configData.createsTodo.list.forEach(async(el: any) => {
await wrapper.get('[data-test="new-todo"]').setValue(el)
await wrapper.get('[data-test="form"]').trigger('submit')
});
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(configData.createsTodo.expected)
})
})
执行npm run test:unit
会发现单测没通过,拿到的不是页面更新后的数据,还是初始数据1,初步判断是async和await失效了,
所以改用nextTick,代码改成如下:
todo.spec.ts
import { mount } from '@vue/test-utils'
import TodoApp from '@/views/common/TodoApp.vue'
import { nextTick } from 'vue'
import configData from "@/views/common/configJS/configData"; // 引入测试数据
describe('todoAll', () => {
it('creates a todo', async () => {
const wrapper = mount(TodoApp)
//发现在forEach使用async 和await失效,所以改用nextTick方法
configData.createsTodo.list.forEach((el: any) => {
wrapper.get('[data-test="new-todo"]').setValue(el)
wrapper.get('[data-test="form"]').trigger('submit')
});
await nextTick()
expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(configData.createsTodo.expected)
})
})
再次执行npm run test:unit
会发现单测通过了,可list长度是3,expected是4为什么会通过呢,原因是组件原本就有一条数据.
现在我们修改一下期望值,把他改成5,再次执行npm run test:unit
let createsTodo = {
list: ['a', 'b', 'c'],
expected: 5
}
发现单测没有通过,但是这是正常的。原因是期望值是错误的.
7. 多种场景
在数据文件里面写多种场景的数据,怎么才能都实现呢,想到的办法是每个单测场景都传入一个数组,根据数组的长度去循环创建n个单测(test or it).现在以是否完成代办单测为例。configData.ts
let completesTodo = [
{
bool: false, // 是否勾选
expected: false // 标签是否有勾选的类名
},
{
bool: true,
expected: true
},
]
export default {
completesTodo,
}
todo.spec.ts
import { mount } from '@vue/test-utils'
import TodoApp from '@/views/common/TodoApp.vue'
import configData from "@/views/common/configJS/configData";
describe('todoAll', () => {
configData.completesTodo.forEach(item => {
it('completes a todo', async() => {
const wrapper = mount(TodoApp)
await wrapper.get('[data-test="todo-checkbox"]').setValue(item.bool)
if (item.expected) {
expect(wrapper.get('[data-test="todo"]').classes()).toContain('completed')
} else {
expect(wrapper.get('[data-test="todo"]').classes()).not.toContain('completed')
}
})
})
})
执行npm run test:unit
可以发现根据数组completesTodo的长度循环创建的两条单测都通过了。现在多加一条数据,勾选但是期望值是false。看看是不是通不过单测。
configData.ts
let completesTodo = [
{
bool: false, // 是否勾选
expected: false // 标签是否有勾选的类名
},
{
bool: true,
expected: true
},
{
bool: true,
expected: false
},
]
export default {
completesTodo,
}
再次执行npm run test:unit
,发现有条单测不通过,符合预期效果.