前端如何进行单元测试?
Mocha
Mocha是JavaScript的测试框架, 浏览器和Node端均可以使用。但是Mocha本身并不提供断言的功能, 需要借助例如: Chai, 这样的断言库完成测试的功能。
Mocha API 速览
Mocha的API快速浏览, 更多细节请参考文档
⚠️ 注意:
- Mocha不推荐使用箭头函数作为Callback的函数
- 不要用例中什么都不做, 推荐使用skip跳过不需要的测试
Mocha简单的示例
describe('unit', function () { it('example', function () { return true }) })
Mocha测试异步代码
Mocha支持Promise, Async, callback的形式
// callback describe('异步测试Callback', function () { it('Done用例', function (done) { setTimeout(() => { done() }, 1000) }) }) // promise describe('异步测试Promise', function () { it('Promise用例', function () { return new Promise((resolve, reject) => { resolve(true) }) }) }) // async describe('异步测试Async', function () { it('Async用例', async function () { return await Promise.resolve() }) })
钩子
- before, 全部的测试用例之前执行
- after, 全部的测试用例结束后执行
- beforeEach, 每一个测试用例前执行
- afterEach, 每一个测试用例后执行
// before, beforeEach, 1, afterEach, beforeEach, 2, afterEach, after describe('MochaHook', function () { before(function () { console.log('before') }) after(function () { console.log('after') }) beforeEach(function () { console.log('beforeEach') }) afterEach(function () { console.log('afterEach') }) it('example1', function () { console.log(1) }) it('example2', function () { console.log(2) }) })
异步钩子
Mocha Hook可以是异步的函数, 支持done,promise, async
全局钩子(root hook)
如果beforeEach在任何descride之外添加, 那么这个beforeEach将被视为root hook。beforeEach将会在任何文件, 任何的测试用例前执行。
beforeEach(function () { console.log('root beforeEach') }) describe('unit1', function () { //... })
DELAYED ROOT SUITE
如果需要在任何测试用例前执行异步操作也可以使用(DELAYED ROOT SUITE)。使用"mocha --delay"执行测试脚本。"mocha --delay"会添加一个特殊的函数run()到全局的上下文。当异步操作完成后, 执行run函数可以开始执行测试用例
function deplay() { return new Promise((resolve, reject) => { setTimeout(function () { resolve() }, 1000) }) } deplay().then(function () { // 异步操作完成后, 开始执行测试 run() }) describe('unit', function () { it('example', function () { return true }) })
skip
describe, 或者it之后添加skip。可以让Mocha忽略测试单元或者测试用例。使用skip, 测试会标记为待处理。
重试测试
设置测试失败后, 测试重试的次数
describe('retries', function () { it('retries', function () { // 设置测试的重试次数 this.retries(3) const number = Math.random() if (number > 0.5) throw new Error() else return true }) })
动态生成测试
对于一些接口的测试, 可以使用动态生产测试用例, 配置请求参数的数组, 循环数组动态生成测试用例
describe('动态生成测试用例', function () { let result = [] for(let i = 0; i < 10; i ++) { result.push(Math.random()) } result.forEach((r, i) => { // 动态生成测试用例 it(`测试用例${i + 1}`, function () { return r < 1 }) }) })
slow
如果测试用例, 运行时间超过了slow设置的时间, 会被标红。
describe('unit', function () { it('example', function (done) { this.slow(100) setTimeout(() => { done() }, 200) }) })
timeout
设置测试用例的最大超时时间, 如果执行时间超过了最大超时时间,测试结果将为错误
describe('unit', function () { it('example', function (done) { this.timeout(100) setTimeout(() => { done() }, 200) }) })
Chai
Chai是Node和浏览器的BDD/TDD断言库。下面将介绍, BDD风格的API, expect。should兼容性较差。
Chai API 速览
Chai的API快速浏览, 更多细节请参考文档
not
对断言结果取反
it('not', function () { const foo = 1 expect(foo).to.equal(1) expect(foo).to.not.equal(2) })
deep
进行断言比较的时候, 将进行深比较
it('deep', function () { const foo = [1, 2, 3] const bar = [1, 2, 3] expect(foo).to.deep.equal(bar) expect(foo).to.not.equal(bar) })
nested
启用点和括号表示法
it('nested', function () { const foo = { a: [ { a: 1 } ] } expect(foo.a[0].a).to.equals(1) })
own
断言时将会忽略对象prototype上的属性
it('own', function () { Object.prototype.own = 1 const a = {} expect(a).to.not.have.own.property('own') expect(a).to.have.property('own') })
ordered
ordered.members比较数组的顺序是否一致, 使用include.ordered.members可以进行部分比较, 配合deep则可以进行深比较
it('order', function () { const foo = [1, 2, 3] const bar = [1, 2, 3] const faz = [1, 2] const baz = [{a: 1}, {b: 2}] const fzo = [{a: 1}, {b: 2}] expect(baz).to.have.deep.ordered.members(fzo) expect(foo).to.have.ordered.members(bar) expect(foo).to.have.include.ordered.members(faz) })
any
要求对象至少包含一个给定的属性
it('any', function () { const foo = {a: 1, b: 2} expect(foo).to.have.any.keys('a', 'b', 'c') })
all
要求对象包含全部给定的属性
it('all', function () { const foo = {a: 1, b: 2} expect(foo).to.not.have.all.keys('a', 'c') })
a, an
用来断言数据类型。推荐在进行更多的断言操作前, 首先进行类型的判断
it('a', function () { expect('123').to.a('string') expect(false).to.a('boolean') expect({a: 1}).to.a('object') expect('123').to.an('string') expect(false).to.an('boolean') expect({a: 1}).to.an('object') })
include
include断言是否包含, include可以字符串, 数组以及对象(key: value的形式)。同时可以配合deep进行深度比较。
it('include', function () { expect('love fangfang').to.an('string').to.have.include('fangfang') expect(['foo', 'bar']).to.an('array').to.have.include('foo', 'bar') expect({a: 1, b: 2, c: 3}).to.an('object').to.have.include({a: 1}) expect({a: {b: 1}}).to.an('object').to.deep.have.include({a: {b: 1}}) expect({a: {b: [1, 2]}}).to.an('object').to.nested.deep.have.include({'a.b[0]': 1}) })
ok, true, false, null, undefined, NaN
- ok断言, 类似"=="
- true断言, 类似"==="
- flase断言, 与false进行"==="比较
- null断言, 与null进行"==="比较
- undefined断言, 与undefined进行"==="比较
- NaN断言, 与NaN进行"==="比较
empty
断言数组, 字符串长度为空。或者对象的可枚举的属性数组长度为0
equal
进行"==="比较的断言
eql
可以不使用deep进行严格类型的比较
above, least, below, most
- above大于断言
- least大于等于断言
- below小于断言
- most小于等于断言
within
范围断言
it('within', function () { expect(2).to.within(1, 4) })
property
断言目标是否包含指定的属性
it('property', function () { expect({a: 1}).to.have.property('a') // property可以同时对key, value断言 expect({a: { b: 1 }}).to.deep.have.property('a', {b: 1}) expect({a: 1}).to.have.property('a').to.an('number') })
lengthOf
断言数组, 字符串的长度
it('lengthOf', function () { expect([1, 2, 3]).to.lengthOf(3) expect('test').to.lengthOf(4) })
keys
断言目标是否具有指定的key
it('keys', function () { expect({a: 1}).to.have.any.keys('a', 'b') expect({a: 1, b: 2}).to.have.any.keys({a: 1}) })
respondTo
断言目标是否具有指定的方法
it('respondTo', function () { class Foo { getName () { return 'fangfang' } } expect(new Foo()).to.have.respondsTo('getName') expect({a: 1, b: function () {}}).to.have.respondsTo('b') })
satisfy
断言函数的返回值为true, expect的参数是函数的参数
it('satisfy', function () { function bar (n) { return n > 0 } expect(1).to.satisfy(bar) })
oneOf
断言目标是否为数组的成员
it('oneOf', function () { expect(1).to.oneOf([2, 3, 1]) })
change
断言函数执行完成后。函数的返回值发生变化
it('change', function () { let a = 0 function add () { a += 3 } function getA () { return a } // 断言getA的返回值发生变化 expect(add).to.change(getA) // 断言变化的大小 expect(add).to.change(getA).by(3) })
Karma
什么是Karma?
Karma是一个测试工具,能让你的代码在浏览器环境下测试。代码可能是设计在浏览器端执行的,在node环境下测试可能有些bug暴露不出来;另外,浏览器有兼容问题,karma提供了手段让你的代码自动在多个浏览器(chrome,firefox,ie等)环境下运行。如果你的代码只会运行在node端,那么你不需要用karma。
安装, 运行Karma
// 安装karma npm install karma --save-dev
// 在package.json文件中添加测试命令 scripts: { test:unit: "karma start" }
通过Karma测试项目, 需要在项目中添加配置karam.conf.js的文件。推荐使用karam init命令生成初始化的配置文件。下面是, karam init 命令的配置项。生成配置文件之后, 就可以通过"npm run test:unit"命令进行单元测试了。
1. Which testing framework do you want to use(使用的测试框架)? mocha 2. Do you want to use Require.js(是否使用Require)? no 3. Do you want to capture any browsers automatically(需要测试的浏览器)? Chrome, IE, 4. What is the location of your source and test files(测试文件的位置)? test/*.test.js 5. Should any of the files included by the previous patterns be excluded(需要排除的文件) ? node_modules 6. Do you want Karma to watch all the files and run the tests on change(什么时候开始测试) ? change
添加断言库
// 安装 npm install --save-dev chai karma-chai
配置karma.conf.js与webpack
如果测试发送在浏览器环境, Karma会将测试文件, 模拟运行在浏览器环境中。所以推荐使用webpack, babel, 对测试文件进行编译操作。Karma中提供了处理文件中间件的配置。ps: 之前由于浏览器环境不支持require, 而我在test文件中使用了require, 并且我没有将测试文件进行编译, 耽误了我半天的时间:(
karma.conf.js配置的更多的细节,可以查看karma文档
// 安装babel npm install --save-dev karma-webpack webpack babel-core babel-loader babel-preset-env
// 对文件添加webpack的配置, 对配置文件使用babel进行处理 module.exports = function(config) { config.set({ basePath: '', frameworks: ['mocha', 'chai'], files: [ 'test/*.test.js' ], exclude: [ 'node_modules' ], preprocessors: { 'test/*.test.js': ['webpack'] }, webpack: { // webpack4中新增的mode模式 mode: "development", module: { rules: [ { test: /\.js?$/, loader: "babel-loader", options: { presets: ["env"] }, }, ] } }, reporters: ['progress'], port: 9876, colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], singleRun: false, concurrency: Infinity }) }
编写测试文件
// 通过babel, 浏览器可以正常的解析测试文件中的require const modeA = require('../lib/a') const expect = require('chai').expect describe('test', function () { it('example', function () { expect(modeA.a).to.equals(1) }) })
Vue与Karma集成
与处理浏览器中的require同理, 如果我们需要对.vue文件进行测试, 则需要通过vue-loader的对.vue文件进行处理。
我们首先通过vue-cli初始化我们的项目, 这里我使用的是vue-cli2.x的版本, 3.x的版本vue-cli对webpack的配置作出了抽象, 没有将webpack的配置暴露出来, 我们会很难理解配置。如果需要使用vue-cli3.x集成karma, 则需要另外的操作。
// 安装karma以及karam的相关插件 npm install karma mocha karma-mocha chai karma-chai karma-webpack --save-dev // 配置karma.conf.js // webpack的配置直接使用webpack暴露的配置 const webpackConfig = require('./build/webpack.test.conf') module.exports = function(config) { config.set({ basePath: '', frameworks: ['mocha'], files: [ 'test/*.test.js' ], exclude: [ ], // 测试文件添加中间件处理 preprocessors: { 'test/*.test.js': ['webpack'] }, webpack: webpackConfig, reporters: ['progress'], port: 9876, colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], singleRun: false, concurrency: Infinity }) }
⚠️ 注意: 这里依然存在一个问题Can't find variable: webpackJsonp, 我们可以将webpackConfig文件中的CommonsChunkPlugin插件注释后, karma将会正常的工作。
编写测试
import { expect } from 'chai' import { mount } from '@vue/test-utils' import HelloWorld from '../src/components/HelloWorld.vue' describe('HelloWorld.vue', function () { const wrapper = mount(Counter) it('Welcome to Your Vue.js App', function () { console.log('Welcome to Your Vue.js App') expect(wrapper.vm.msg).to.equals('Welcome to Your Vue.js App') }) })
Vue-cli3与Karma集成
对于vue-cli3我尝试自己添加karma.conf.js的配置, 虽然可以运行,但是存在问题。issue中, 官方建议我在vue-cli3版本中使用vue-cli的karma的插件解决。
对于vue-cli3,可以使用vue-cli-plugin-unit-karma插件, 集成vue-cli3与karma
VueTestUtils
对于VueTestUtils, 我这里并不想做过多的介绍。因为它拥有详尽和完善的中文文档, 在这里我只会做大致的概述。文档地址, 值得注意的一点文档中部分内容已经过时, 以及不适用与vue-cli3
什么是VueTestUtils?
VueTestUtils是Vue.js官方的单元测试实用工具库, 提供很多便捷的接口, 比如挂载组件, 设置Props, 发送emit事件等操作。我们首先使用vue-cli3创建项目, 并添加vue-cli-plugin-unit-karma的插件。而vue-cli-plugin-unit-karma插件已经集成了VueTestUtils工具, 无需重复的安装。
VueRouter
VueRouter, 是Vue的全局插件, 而我们测试的都是单文件组件, 我们该如何测试VueRouter的呢?, VueTestUtils为我们提供了localVue的API, 可以让我们在测试单文件组件的时候, 使用VueRouter。(更多内容请参考文档)
import { mount, createLocalVue } from '@vue/test-utils' import Test from '../../src/views/Test.vue' import VueRouter from 'vue-router' it('localVue Router', function () { const localVue = createLocalVue() localVue.use(VueRouter) const router = new VueRouter() // 挂载组件的同时, 同时挂载VueRouter const wrapper = mount(Test, { localVue, router }) // 我们可以在组件的实例中访问$router以及$route console.log(wrapper.vm.$route) })
对于$router以及$route, 我们也可以通过mocks进行伪造, 并注入到组件的实例中
it('mocks', function () { // 伪造的$route的对象 const $route = { path: '/', hash: '', params: { id: '123' }, query: { q: 'hello' } } const wrapper = mount(Test, { mocks: { $route } }) // 在组件的实例中访问伪造的$route对象 console.log(wrapper.vm.$route) })
Vuex
对于Vuex的测试, 我们需要明确一点我们不关心这个action或者mutation做了什么或者这个store是什么样子的, 我们只需要测试action将会在适当的时机触发。对于getters我们也不关心它返回的是什么, 我们只需要测试这些getters是否被正确的渲染。更多细节请查看文档。
describe('Vuex', function () { // 应用全局的插件Vuex const localVue = createLocalVue() localVue.use(Vuex) let actions let store let getters let isAction = false // 在每个测试用例执行前, 伪造action以及getters // 每个测试用例执行前, 都会重置这些数据 beforeEach(function () { // 是否执行了action isAction = false actions = { actionClick: function () { isAction = true }, actionInput: function () { isAction = true } } getters = { name: () => '方方' } // 生成伪造的store store = new Vuex.Store({ state: {}, actions, getters }) }) // 测试是否触发了actions it('如果text等于actionClick, 触发了actionClick action', function () { const wrapper = mount(TestVuex, { store, localVue }) wrapper.vm.text = 'actionClick' // 如果成功触发actionClick action, isAction将为true expect(isAction).to.true }) it('如果text等于actionInput,触发actionInput action', function () { const wrapper = mount(TestVuex, { store, localVue }) wrapper.vm.text = 'actionInput' // 如果成功触发actionInput action, isAction将为true expect(isAction).to.true }) // 对于getters, 同理action // 我们只关注是否正确渲染了getters,并不关心渲染了什么 it('测试getters', function () { const wrapper = mount(TestVuex, { store, localVue }) // 测试组件中使用了getters的dom, 是否被正确的渲染 expect(wrapper.find('p').text()).to.equals(getters.name()) }) })