什么是单元测试
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。
单元的大小或范围,并没有一个明确的标准,可以是一个函数、方法、类、功能模块或者子系统。
单元测试的意义
- 提升效率:可以通过 mock 数据,尽早发现问题,减少线上Bug
- 程序优化:可以充当一个设计工具,帮助于开发人员去思考代码结构的设计,编写出更有利于测试的代码
- 测试全面:可以覆盖到一些业务场景无法触及的测试场景,如异常处理
- 降低风险:重构或升级基础组件时,如果依赖于该组件的高阶组件单元测试都能通过,可以降低本次变更带来的风险
适用范围
本文只适用于在Vue2版本下接入前端单元测试
- vue: "^2.7.7"
- @vue/test-utils: "^1.3.5"
- jest: "^29.5.0"
选择单元测试接入方式
Vue Test Utils 是 Vue 组件单元测试的官方库,提供了非常完备的Vue组件测试支持,包括:
- 挂载 API : 创建一个内部挂载了测试组件的容器(Vue实例)
- 挂载选项: 提供了mock外部数据的能力,如全局属性、异步行为、组件插槽等
- 容器操作: 提供读取容器内容的能力,如容器属性、选择器、DOM事件处理、组件断言等
- 兼容性: 兼容几乎所有的 JS 测试运行器。Vue CLI 的 webpack 模板对 Karma 和 Jest 这两个测试运行器都支持,并且在 Vue Test Utils 的文档中有一些引导
选择测试运行器
测试运行器 (test runner) 就是运行测试的程序。vue推荐使用Jest 、mocha-webpack。对比如下:
-
Jest
配置简单: 默认安装用例执行环境JSDOM,无需额外配置- 功能全面: 内置断言,并提供测试覆盖率报告
- 支持处理Vue单文件: 提供vue-jest预处理器
- 支持TS: 支持TypeScrpit,方便测试用例编写
-
webpack + Mocha:包含了更顺畅的接口和侦听模式,方便开发者能够通过 webpack +
vue-loader得到完整的单文件组件支持。但是配置非常繁琐。
用 Jest 测试 Vue 组件
安装 Jest 和 Vue Test Utils
首先安装 Jest 和 Vue Test Utils:
$ npm install --save-dev jest @vue/test-utils
然后在 package.json 中定义一个单元测试的脚本
// package.json
{
"scripts": {
"test": "jest"
}
}
为 Jest 配置 Babel
最新版本的 Node 已经支持绝大多数的 ES2015 特性,但如果测试中使用 ES modules 语法和 stage-x 的特性。需要安装 babel-jest:
$ npm install --save-dev babel-jest
然后在 package.json 的 jest.transform 里添加一个入口,来告诉 Jest 用 babel-jest处理 js 测试文件:
// package.json
{
// ...
"jest": {
"moduleFileExtensions": [
"js",
"vue"
],
"transform": {
"^.+\.js$": "<rootDir>/node_modules/babel-jest",
".*\.(vue)$": "vue-jest"
}
}
}
在 Jest 中处理单文件组件
Jest 要处理 *.vue 文件,需安装和配置 vue-jest 预处理器
$ npm install --save-dev vue-jest
在 package.json 中配置 jest 选项
// package.json
{
// ...
"jest": {
"moduleFileExtensions": [
"js",
"vue" // 告诉 Jest 处理 `*.vue` 文件
],
"transform": {
".*\.(vue)$": "vue-jest" // 用 `vue-jest` 处理 `*.vue` 文件
}
}
}
配置Jest支持TypeScript
需要在 Jest 中设置编译 TypeScript。需安装 ts-jest:
$ npm install --save-dev ts-jest @types/jest
然后在 package.json 中设置使用 ts-jest 处理 TypeScript 测试文件:
// package.json
{
// ...
"jest": {
"moduleFileExtensions": [
"js",
"ts",
"tsx",
"vue"
],
"transform": {
"^.+\.js$": "<rootDir>/node_modules/babel-jest",
"^.+\.tsx?$": "ts-jest",
".*\.(vue)$": "vue-jest"
}
}
}
最后在tsconfig.json文件中声明jest
// tsconfig.json
{
"compilerOptions": {
//...
"types": ["jest"]
}
}
其他配置
处理webpack别名
如果在 webpack 中配置了别名解析,比如把 @ 设置为 /src 的别名,在jest中也需要用 moduleNameMapper 选项配置
{
// ...
"jest": {
// ...
// 支持源代码中相同的 `@` -> `src` 别名
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1",
"@types": "<rootDir>/src/types/index"
}
}
}
放置测试文件
默认情况下,Jest 将会递归的找到整个工程里所有 .spec.j(t)s 或 .test.j(t)s 扩展名的文件。如果不符合需求,也可以在 package.json 里的jest配置项中段落中配置testRegex字段。
// package.json
{
// ...
"jest": {
// ...
"testRegex": "(/__tests__/.*|(\.|/)(test|spec))\.(jsx?|tsx?)$"
}
}
测试覆盖率
通过 jest 配置项collectCoverage 和 collectCoverageFrom 来定义需要收集测试覆盖率信息的文件
// package.json
{
// ...
"jest": {
// ...
"collectCoverage": true,
"collectCoverageFrom": ["**/src/components/** .{js,ts,vue}" , "! /node_modules/**"]
}
}
测试覆盖率参数:
Stmts语句覆盖率Branch分支覆盖率Funcs函数覆盖率Lines行覆盖率
提升测试用例执行效率
在Jest测试用例执行过程中,有三个地方比较耗性能:
- 生成虚拟文件系统:在首次启动测试时遍历整个项目生成一个
Haste Map,用于热更新场景和监听文件改动 - 多线程:生成新线程会额外消耗资源
- 文件转译:Jest会在执行到测试文件时即时转译
针对上述问题的解决方法是:
- 无解
- 具体情况具体分析,非大型项目,单线程足够
- 使用更高效率的编译器如
esbuild-jest、@swc/jest
$ npm install --save-dev @swc/core @swc/jest
// package.json
{
// ...
"jest": {
"transform": {
"^.+\.js$": "<rootDir>/node_modules/babel-jest",
"^.+\.tsx?$": "ts-jest",
"^.+\.(t|j)sx?$": [
"@swc/jest",
{
"jsc": {
"target": "es2021"
}
}
],
".*\.(vue)$": "vue-jest"
},
"maxWorkers": 1,
}
}
在替换编译器后,首次执行测试用例效率有较大提升
- ts-jest
- @swc/jest
可能遇到的问题
到了这一步,基本的配置已经完成,但是可能会因为各个依赖包的版本不匹配导致jest执行失败,可能遇到的情况如下:
未安装测试运行器运行环境jest-environment-jsdom
在介绍测试运行器时有谈到早期的jest会默认安装JSDOM的运行环境,但是在28版本后,已取消默认安装,需要自己手动安装jest-environment-jsdom,执行jest运行环境。
$ npm install --save-dev jest-environment-jsdom
// package.json
{
// ...
"jest": {
// ...
"testEnvironment": "jsdom"
}
}
提示:Error: Cannot find module 'babel-core'
查找issue后发现,主要是因为babel-loader和babel-core版本不匹配导致的。
- babel-loader v8.x 应该对应 babel-core v7.x
- babel-loader v7.x 应该对应 babel-core v6.x
所以需降低babel-core版本:
$ npm install --save-dev babel-core@^7.0.0-bridge.0
提示:ReferenceError: Vue is not defined
查找issue后发现,其原因主要是 jest-environment-jsdom 和@vue/test-utils配置冲突导致的。引入 jest-environment-jsdom时,jest会自动设置特定的导入方式,再引入@vue/test-utils时设置了customExportConditions配置项,导致Jest 最后使用了CommonJS的导入方式,修改如下:
// package.json
{
// ...
"jest": {
// ...
"testEnvironmentOptions": {
"customExportConditions": ["node", "node-addons"]
},
}
}
提示:TypeError: Cannot destructure property 'createComponentInstance' of 'Vue.ssrUtils' as it is undefined
此问题是由于@vue/test-utils与vue版本不匹配导致的,按照官网的描述:
- @vue/test-utils v2.x 应该对应 vue v3.x
- @vue/test-utils v1.x 应该对应 vue v2.x
需对@vue/test-utils进行降级处理:
$ npm install --save @vue/test-utils@v1.3.5
完整依赖列表及配置
- jest
- jest-environment-jsdom
- @vue/test-utils @1.3.5
- vue-jest
babel-jest- babel-core @7.0.0-bridge.0
ts-jest- @types/jest
- @swc/core
- @swc/jest
// package.json
{
// ...
"jest": {
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"customExportConditions": [
"node",
"node-addons"
]
},
"moduleFileExtensions": [
"js",
"ts",
"tsx",
"vue"
],
"transform": {
"^.+\.(t|j)sx?$": [
"@swc/jest",
{
"jsc": {
"target": "es2021"
}
}
],
".*\.(vue)$": "vue-jest"
},
"transformIgnorePatterns": [
"<rootDir>/node_modules/(?!(element-ui/src/mixins/emitter))"
],
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1",
"^@types/(.*)$": "<rootDir>/src/types/index"
},
"maxWorkers": 1,
"collectCoverage": true,
"collectCoverageFrom": ["**/src/components/** .{js,ts,vue}" , "! /node_modules/**"]
}
}
单元测试用例编写指引(测试用例编写过程遇到的坑点)
在测试组件中使用了第三方组件或其他基础组件,如Element UI等
报错场景包括:
- 测试组件使用了全局定义的第三方组件
Vue.use(ElementUI, { locale, size: 'small' })
- 测试组件按需引入第三方组件
import { Input } from 'element-ui'
- 自定义基础组件
import CustomerInput from '@/components/CustomerInput.vue'
解决方式如下:
- 在挂载选项中注册对应的组件
const wrapper = mount(CustomerInputNumber, {
components: {
'el-input': Input,
'CustomerInput': CustomerInput
}
});
其中,注册组件可以是自定义的模拟组件,也可以在测试用例中按需引入的第三方组件
import CustomerInput from '@/components/CustomerInput.vue'
const mockComponent = {
template: "<div><slot></slot></div>",
props: {
color: String
}
};
const wrapper = mount(CustomerInputNumber, {
components:{
'el-input': mockComponent,
'CustomerInput': CustomerInput
}
});
- 使用临时Vue实例全局注册组件
import { createLocalVue } from '@vue/test-utils'
import { Input } from 'element-ui'
const localVue = createLocalVue()
localVue.use(Input)
const wrapper = mount(CustomerInputNumber, {
localVue
});
在测试组件中直接使用node_modules中的文件
例如,在测试组件中,直接使用了node_modules中的Emitter组件。但是在Jest转译时,会默认将node_modules忽略,所以当测试用例执行到该行时,会因为获取不到导出的Emitter组件而报错。
在报错信息中,Jest给出的处理建议,是将Emitter组件所在目录加入到转译文件中,需要对Jest做如下配置:
// package.json
{
// ...
"jest": {
// ...
"transformIgnorePatterns": [
"<rootDir>/node_modules/(?!(element-ui/src/mixins/emitter))"
],
}
}
在测试组件的Constructer中使用全局依赖
如果提示在测试组件中找不到挂载在全局实例上的方法,如 lodash.throttle() 而在实际的测试用例运行时,已经传入了loadsh的mocks注册,并且在测试组件的mounted生命周期中,Vue实例上可获取到mocks注册的loadsh属性。
// 测试用例
import lodash from 'lodash'
const wrapper = mount(TestComponent, {
mocks: {
"_": lodash
},
});
// 测试组件
export default class TestComponent extends Vue {
// private onScrollListener = this._.throttle(this.onScroll, 300) // 先注释报错行
private mounted () {
console.log(this._) // 可以正常输出
}
}
原因是,运行测试用例挂载(mount、shallowMount)测试组件时,如果测试组件使用了全局注册的属性或方法,只能在mounted生命周期后才能获取到由测试用例mocks注册的模拟属性或方法。而Constructor生命周期在mounted之前执行,此时去获取就会提示undefined。
解决方法是:在测试组件中按需引入lodash属性,不使用全局注册,这样就能在Constructor生命周期中获取到依赖:
// 测试组件
import _ from 'lodash'
export default class TestComponent extends Vue {
private onScrollListener = _.throttle(this.onScroll, 300)
}