单元测试
(unit testing),是指对软件中的最小可测试单元进行检查和验证。在提供了经过测试的单元的情况下,系统集成过程将会大大地简化。开发人员可以将精力集中在单元之间的交互作用和全局的功能实现上,而不是陷入充满很多Bug的单元之中不能自拔。
流行框架
1. jest
Jest
是一个由 facebook
开发的测试运行器,内置Jsdom、断言库和mock,vue-test-utils 官网评价
Jest 是功能最全的测试运行器。它所需的配置是最少的,默认安装了 JSDOM,内置断言且命令行的用户体验非常好。不过你需要一个能够将单文件组件导入到测试中的预处理器。我们已经创建了 vue-jest 预处理器来处理最常见的单文件组件特性,但仍不是 vue-loader 100% 的功能。
2. karma + mocha + chai + sinon
Karma
是一个启动浏览器运行测试并生成报告的测试运行器
Mocha
是一个在node.js
和浏览器上运行的功能丰富的JavaScript测试框架, 能良好的支持javascript异步的单元测试
Chai
是一个适用node.js
和浏览器端的 BDD(行为) / TDD 断言库
Sinon
是一个独立的 JavaScript 测试spy, stub, mock库,没有依赖任何单元测试框架工程
-
spy (监听) 生成一个间谍函数,记录下函数调用的参数,返回值,this的值,以及抛出的异常
-
stub (存根) 用简单的行为替换复杂的行为,例如父子组件调用,但是子组件没有完成,可以用一个stub替代,告诉父组件这里有一个子组件
-
mock (模仿)生成代码中使用的假数据对象,验证预期
vue test utils
vue test utils 是 Vue.js 官方的单元测试实用工具库, 支持主流js测试运行器,是一个基于包裹器
的 API,同步触发事件。因此 Vue.nextTick 不是必须的。
安装
vue.2.0老项目默认已经安装并配置好了 webpack、vue-loader 和 Babel,可以通过下面的方法添加测试模块
1. 使用 jest 测试单文件组件
安装依赖
# 安装 jest @vue/test-utils
npm install --save-dev jest @vue/test-utils
# 安装vue-jest, 在 Jest 中处理vue文件需要安装 vue-jest 预处理器:
npm install --save-dev vue-jest
# 安装 babel-jest
npm install --save-dev babel-jest
# 安装 jest 快照插件
npm install --save-dev jest-serializer-vue
在根目录新建 test/unit
目录,添加 jest
配置文件 jest.conf.js
const path = require('path')
module.exports = {
rootDir: path.resolve(__dirname, '../../'),
moduleFileExtensions: [
'js',
'json',
// 告诉 Jest 处理 `*.vue` 文件
'vue'
],
moduleNameMapper: {
// 用 `vue-jest` 处理 `*.vue` 文件
'^@/(.*)$': '<rootDir>/src/$1'
},
transform: {
'^.+\\.js$': '<rootDir>/node_modules/babel-jest', // 用 `babel-jest` 处理 `*.js` 文件
'.*\\.(vue)$': '<rootDir>/node_modules/vue-jest' // 用 `vue-jest` 处理 `*.vue` 文件
},
snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'], // 快照插件
setupFiles: ['<rootDir>/test/unit/setup'], // 在每次测试之前运行一些代码来配置或设置测试环境
verbose: true,
testURL: 'http://localhost/',
collectCoverage: true, // 打开代码覆盖率收集功能
coverageDirectory: '<rootDir>/test/unit/coverage', // 生成代码覆盖率报告文件夹目录
// 定义需要收集测试覆盖率信息的文件路径
collectCoverageFrom: [
'src/**/*.{js,vue}',
'!src/main.js',
'!src/router/index.js',
'!**/node_modules/**'
],
// 测试覆盖率报告模版
coverageReporters: ['html', 'text-summary']
}
配置 test/unit/setup.js
文件, 不是必须
import Vue from 'vue'
// 去除生产环境警告
Vue.config.productionTip = false
配置测试环境 .babelrc
{
...
"env": {
"test": {
"presets": [
["env", { "targets": { "node": "current" }}]
]
}
}
}
配置 package.json
测试脚本
// package.json
{
"scripts": {
"unit": "jest --config test/unit/jest.conf.js --coverage --updateSnapshot"
}
}
当被测试内容修改后,重新生成快照片会报错,加上 --updateSnapshot
可以消除
测试文件可以放在 test/unit/spec/
文件下,命名方式如 *.spec.js
或 *.test.js
,jest 会识别自动识别类似文件, 运行下面代码启动测试脚本
npm run unit
2. 使用 Mocha 和 webpack 测试单文件组件
mocha-webpack 是一个 webpack + Mocha 的包裹器,同时包含了更顺畅的接口和侦听模式。这些设置的好处在于我们能够通过 webpack + vue-loader 得到完整的单文件组件支持,但这本身是需要很多配置的
npm install --save-dev @vue/test-utils mocha mocha-webpack
配置 package.json
测试脚本
// package.json
{
"scripts": {
"test": "mocha-webpack --webpack-config webpack.config.js --require test/setup.js test/**/*.spec.js"
}
}
3. 使用 karam + mocha + chai + sinon
3.1 安装依赖
# 安装 @vue/test-utils
npm install --save-dev @vue/test-utils
# 安装 mocha 和 chai 和 sinon
npm install --save-dev mocha chai sinon sinon-chai
# 安装 karma 依赖
npm install --save-dev karma karma-mocha karma-sourcemap-loader karma-spec-reporter karma-webpack karma-sinon-chai
# 使用 karma-coverage 插件来设置 Karma 的代码覆盖率
npm install --save-dev karma-coverage cross-env
3.2 webpack 配置
// config/test.conf.js
'use strict'
const merge = require('webpack-merge')
const devEnv = require('./dev.env')
module.exports = merge(devEnv, {
NODE_ENV: '"testing"'
})
// config/test.conf.js
'use strict'
const utils = require('./utils')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const webpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders()
},
devtool: '#inline-source-map',
resolveLoader: {
alias: {
'scss-loader': 'sass-loader'
}
},
plugins: [
new webpack.DefinePlugin({
'process.env': require('../config/test.env')
})
]
})
delete webpackConfig.entry
module.exports = webpackConfig
3.3.1 使用真实浏览器作为运行环境,例如 chrome, 需要 安装 chrome 启动器
npm install --save-dev karma-chrome-launcher babel-plugin-istanbul
创建 test/unit/karma.conf.js
文件:
// karma.conf.js
var webpackConfig = require('../../build/webpack.test.conf')
module.exports = function karmaConfig (config) {
config.set({
browsers: ['Chrome'],
frameworks: ['mocha', 'sinon-chai', 'istanbul'],
reporters: ['spec', 'coverage'],
files: ['./index.js'],
preprocessors: {
'./index.js': ['webpack', 'sourcemap']
},
webpack: webpackConfig,
webpackMiddleware: {
noInfo: true
},
coverageReporter: {
dir: './coverage',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' }
]
}
})
}
更新 .babelrc
{
...
"env": {
"test": {
"presets": ["env", "stage-2"],
"plugins": ["transform-vue-jsx"]
}
}
}
3.3.2 使用无界面浏览器,例如 phantomjs
npm install --save-dev karma-phantomjs-launcher karma-phantomjs-shim phantomjs-prebuilt
创建 test/unit/karma.conf.js
文件:
// karma.conf.js
var webpackConfig = require('../../build/webpack.test.conf')
module.exports = function karmaConfig (config) {
config.set({
browsers: ['PhantomJS'], // 'Chrome'
frameworks: ['mocha', 'sinon-chai', 'phantomjs-shim'],
reporters: ['spec', 'coverage'],
files: ['./index.js'],
preprocessors: {
'./index.js': ['webpack', 'sourcemap']
},
webpack: webpackConfig,
webpackMiddleware: {
noInfo: true
},
coverageReporter: {
dir: './coverage',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' }
]
}
})
}
更新 .babelrc
{
...
"env": {
"test": {
// 环境变量 添加 istanbul 插件
"presets": ["env", "stage-2"],
"plugins": ["transform-vue-jsx", "istanbul"]
}
}
}
3.4 创建 test/unit/index.js
文件:
import Vue from 'vue'
// 关闭生成环境提示
Vue.config.productionTip = false
// 测试文件的目录
const testsContext = require.context('./specs', true, /\.spec$/)
testsContext.keys().forEach(testsContext)
// 测试覆盖率的文件范围,src文件下除了main.js
const srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/)
srcContext.keys().forEach(srcContext)
3.5 创建 test/unit/.eslintrc
文件, 不然会运行时 expect 会报 undefined
{
"env": {
"mocha": true
},
"globals": {
"expect": true,
"sinon": true
}
}
3.6 定义 package.json
测试脚本
// package.json
{
"scripts": {
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
}
}
2. 使用 vue-cli 3.0
安装 vue-cli 3.0
npm install -g @vue/cli
# or
yarn global add @vue/cli
创建一个项目
vue create my-project
# or
vue ui // 启动创建项目的ui界面,默认8000端口
# 兼容2.0用法
vue init webpack my-project
选择默认或者手动模式
Vue CLI v3.0.1
? Please pick a preset:
default (babel, eslint)
❯ Manually select features
按空格键选中需要安装的功能,按回车下一步
Vue CLI v3.0.1
? Please pick a preset: Manually select features
? Check the features needed for your project:
◉ Babel
◯ TypeScript
◯ Progressive Web App (PWA) Support
◉ Router
◉ Vuex
◉ CSS Pre-processors
◉ Linter / Formatter
❯◉ Unit Testing
◯ E2E Testing
选择测试工具,这里内置了两种方式
-
选择 jest
, 在jest.conf.js
添加代码覆盖率报告和快照设置,记得安装 jest-serializer-vue
npm包
module.exports = {
// ...
snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'], // 快照插件, 需要安装
collectCoverage: true,
coverageDirectory: '<rootDir>/tests/unit/coverage',
collectCoverageFrom: [
'**/*.{js,vue}',
'!**/node_modules/**'
],
coverageReporters: [
'html',
'text-summary'
]
}
-
选择 mocha + chai
, 在karma.conf.js
添加代码覆盖率报告
module.exports = {
...
reporters: ['spec', 'coverage'],
coverageReporter: {
dir: './coverage',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' }
]
}
}
使用
这里以jest 为例
测试普通js文件
创建 utils/index.js
function sum (a, b) {
return a + b
}
function minus (a, b) {
return a - b
}
module.exports = {
sum,
minus
}
创建 test/spec/sum.spec.js
import { sum, minus } from '@/utils/index.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
test('adds 2 - 1 to equal 1', () => {
expect(minus(2, 1)).toBe(1)
})
测试 vue 文件
创建 src/components/HelloWorld.vue
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<my-counter></my-counter>
</div>
</template>
<script>
import MyCounter from './MyCounter'
export default {
name: 'HelloWorld',
data () {
return {
msg: 'Welcome to Your Vue.js App'
}
},
components: {
MyCounter
}
}</script>
创建 src/components/Counter.vue
<template>
<div class="my-counter">
<p style="margin-top: 10px">计算: {{base}} + {{ count }} = {{total}}</p>
计算基数: <input class="my-input" id="input" type="text" v-model="inputValue">
<button class="my-button" @click="increment" ref="my-button" name="my-button">{{text}}</button>
<slot></slot>
<slot name="foo"></slot>
</div>
</template>
<script>
export default {
name: 'MyCounter',
props: {
text: {
type: String,
default: 'Increment'
}
},
data () {
return {
count: 0,
inputValue: 2,
base: 2
}
},
methods: {
increment () {
this.count++
}
},
computed: {
total () {
return parseInt(this.base) + this.count
}
},
watch: {
inputValue () {
if (this.inputValue) {
this.base = this.inputValue
}
}
}
}
</script>
<style>
.my-button {
width: 100px;
height: 40px;
border: none;
border-radius: 4px;
background: #e83e43;
color: #fff;
font-size: 18px;
outline: none;
}
.my-input {
width: 80px;
height: 30px;
font-size: 18px;
outline: none;
}
</style>
npm run dev
运行后,页面正常显示
测试DOM结构
通过mount、shallow、find、findAll方法都可以返回一个包裹器对象
,包裹器会暴露很多封装、遍历和查询其内部的Vue组件实例的便捷的方法。
find和findAll方法都可以都接受一个选择器作为参数,find方法返回匹配选择器的DOM节点或Vue组件的Wrapper,findAll方法返回所有
匹配选择器的DOM节点或Vue组件的Wrappers的WrapperArray。
很多方法的参数中都包含选择器。一个选择器可以是一个 CSS 选择器、一个 Vue 组件或是一个查找选项对象。
选择器
CSS 选择器
挂载处理任何有效的 CSS 选择器:
你也可以结合使用:
Vue 组件
Vue 组件也是有效的选择器
// 'test/unit/specs/helloWorld.spec.js'
import MyCounter from '@/components/MyCounter.vue'
const myCounter = wrapper.find(MyCounter)
expect(myCounter.is(MyCounter)).toBe(true)
})
查找选项对象:
Name:可以根据一个组件的name
选择元素
// test/unit/specs/helloWorld.spec.js
import MyCounter from '@/components/MyCounter.vue'
const myCounter = wrapper.find({ name: 'MyCounter' })
expect(myCounter.is(MyCounter)).toBe(true)
Ref:可以根据$ref
选择元素。wrapper.find({ ref: 'my-button' })
// test/unit/specs/myCounter.spec.js
wrapper.find({ ref: 'my-button' }).trigger('click')
创建 test/unit/specs/helloWorld.spec.js
// wrapper.vm 属性访问一个实例所有的方法和属性
import { shallowMount, mount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
describe('测试 HelloWorld 组件', () => {
test('是一个 Vue 实例', () => {
const wrapper = shallowMount(HelloWorld)
expect(wrapper.isVueInstance()).toBeTruthy()
})
test('mount 快照', () => {
const wrapper = mount(HelloWorld)
expect(wrapper.vm.$el).toMatchSnapshot()
})
test('shallowMount 快照', () => {
const wrapper = shallowMount(HelloWorld)
expect(wrapper.vm.$el).toMatchSnapshot()
})
})
创建 test/unit/specs/myCounter.spec.js
import { shallowMount } from '@vue/test-utils'
import MyCounter from '@/components/MyCounter.vue'
describe('MyCounter.vue', () => {
test('测试加一', () => {
const wrapper = shallowMount(MyCounter)
wrapper.find('button').trigger('click')
expect(wrapper.find('p').text()).toBe('1')
})
})
执行单元测试后,测试通过,然后Jest会在 test/__snapshots__/
文件夹下创建一个快照文件 helloWorld.spec.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`测试 HelloWorld 组件 mount 快照 1`] = `
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`测试 HelloWorld 组件 mount 快照 1`] = `
<div
class="hello"
>
<h1>
Welcome to Your Vue.js App
</h1>
<div
class="my-counter"
>
<p
style="margin-top: 10px;"
>
计算: 2 + 0 = 2
</p>
计算基数:
<input
class="my-input"
id="input"
type="text"
/>
<button
class="my-button"
name="my-button"
>
Increment
</button>
</div>
</div>
`;
exports[`测试 HelloWorld 组件 shallowMount 快照 1`] = `
<div
class="hello"
>
<h1>
Welcome to Your Vue.js App
</h1>
<mycounter-stub
text="Increment"
/>
</div>
`;
通过对比发现 shallowMmount 方法,它和mount一样,创建一个包含被挂载和渲染的Vue组件的Wrapper,不同的是子组件被存根的替代,不被渲染
测试样式
UI的样式测试为了测试我们的样式是否复合设计稿预期。同时通过样式测试我们可以感受当我们code变化带来的UI变化,以及是否符合预期。
// 'test/unit/specs/myCounter.spec.js'
// classes 返回 Wrapper DOM 节点的 class。
test('组件有my-button类', () => {
const wrapper = shallowMount(MyCounter)
const myButton = wrapper.find({ ref: 'my-button' })
expect(myButton.classes()).toContain('my-button')
})
测试Props
在挂载时向组件传值 propsData
或 使用 setProps()
方法
// 'test/unit/specs/myCounter.spec.js'
// props() 返回 Wrapper vm 的 props 对象
test('测试props', () => {
const wrapper = shallowMount(MyCounter, {
propsData: {
text: '递增'
}
})
// const wrapper = shallowMount(MyCounter)
// wrapper.setProps({text: '递增'})
expect(wrapper.props().text).toBe('递增')
})
测试计算属性
// 'test/unit/specs/myCounter.spec.js'
test('测试计算属性', () => {
const wrapper = shallowMount(MyCounter)
wrapper.vm.count = 3
expect(wrapper.vm.total).toBe(5)
})
测试监听器
vue-test-utils 同步触发事件,断言不用放到$nextTick中
// 'test/unit/specs/myCounter.spec.js'
test('测试计算属性', () => {
const wrapper = shallowMount(MyCounter)
wrapper.vm.inputValue = 5
expect(wrapper.vm.total).toBe(5)
})
mock依赖
伪造全局注入的时候有用
// 'test/unit/specs/helloWorld.spec.js'
test('测试mock', () => {
const $store = { authed: true }
const wrapper = shallowMount(HelloWorld, {
mocks: {
$store
}
})
expect(wrapper.vm.$store.authed).toBe(true)
})
测试插槽 slots
类型:{ [name: string]: Array<Component>|Component|string } 为组件提供一个 slot 内容的对象。该对象中的键名就是相应的 slot 名,键值可以是一个组件、一个组件数组、一个字符串模板或文本。
// 'test/unit/specs/MyCounter.spec.js'
test('测试slot', () => {
const wrapper = shallowMount(MyCounter, {
slots: {
default: '<p class="default">匿名slot</p>',
foo: '<p class="foo">slot name of foo</p>'
}
})
expect(wrapper.find('.default').html()).toBe('<p class="default">匿名slot</p>')
expect(wrapper.find('.foo').html()).toBe('<p class="foo">slot name of foo</p>')
})
销毁一个 Vue 组件实例
destroy()
销毁 WrapperArray 中的每个 Vue Wrapper 并且可以使用 beforeEach()、
describe('销毁', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(Component);
})
afterEach(() => {
wrapper.destroy()
})
test('test1', () => {
// 直接调用 wrapper
})
test('test2', () => {
// // 直接调用 wrapper
})