本文首发于个人博客,欢迎踩点~html
今年一月份的时候我写了一个Vue+Koa的全栈应用,以及相应的配套教程,获得了不少的好评。同时我也在和读者交流的过程当中不断认识到不足和缺点,因而也对此进行了不断的更新和完善。本次带来的完善是加入和完整的先后端测试。相信对于不少学习前端的朋友来讲,测试
这个东西彷佛是个熟悉的陌生人。你听过,可是你未必作过。若是你对前端(以及nodejs端)测试很熟悉,那么本文的帮助可能不大,不过我很但愿能获得大家提出的宝贵意见!前端
和上一篇全栈开发实战:用Vue2+Koa1开发完整的先后端项目同样,本文从测试新手的角度出发(默认了解Koa并付诸实践,了解Vue并付诸实践,可是并没有测试经历),在已有的项目上从0开始构建咱们的全栈测试系统。能够了解到测试的意义,Jest测试框架的搭建,先后端测试的异同点,如何写测试用例,如何查看测试结果并提高咱们的测试覆盖率,100%测试覆盖率是不是必须,以及在搭建测试环境、以及测试自己过程当中遇到的各类疑难杂症。但愿能够做为入门前端以及Node端测试的文章吧。vue
有了以前的项目结构做为骨架,加入Jest测试框架就很简单了。node
.
├── LICENSE
├── README.md
├── .env // 环境变量配置文件
├── app.js // Koa入口文件
├── build // vue-cli 生成,用于webpack监听、构建
│ ├── build.js
│ ├── check-versions.js
│ ├── dev-client.js
│ ├── dev-server.js
│ ├── utils.js
│ ├── webpack.base.conf.js
│ ├── webpack.dev.conf.js
│ └── webpack.prod.conf.js
├── config // vue-cli 生成&本身加的一些配置文件
│ ├── default.conf
│ ├── dev.env.js
│ ├── index.js
│ └── prod.env.js
├── dist // Vue build 后的文件夹
│ ├── index.html // 入口文件
│ └── static // 静态资源
├── env.js // 环境变量切换相关 <-- 新
├── .env // 开发、上线时的环境变量 <-- 新
├── .env.test // 测试时的环境变量 <-- 新
├── index.html // vue-cli生成,用于容纳Vue组件的主html文件。单页应用就只有一个html
├── package.json // npm的依赖、项目信息文件、Jest的配置项 <-- 新
├── server // Koa后端,用于提供Api
│ ├── config // 配置文件夹
│ ├── controllers // controller-控制器
│ ├── models // model-模型
│ ├── routes // route-路由
│ └── schema // schema-数据库表结构
├── src // vue-cli 生成&本身添加的utils工具类
│ ├── App.vue // 主文件
│ ├── assets // 相关静态资源存放
│ ├── components // 单文件组件
│ ├── main.js // 引入Vue等资源、挂载Vue的入口js
│ └── utils // 工具文件夹-封装的可复用的方法、功能
├── test
│ ├── sever // 服务端测试 <-- 新
│ └── client // 客户端(前端)测试 <-- 新
└── yarn.lock // 用yarn自动生成的lock文件复制代码
能够看到新增的或者说更新的东西只有几个:mysql
.env
、.env.test
,是跟测试相关的环境变量主要环境:Vue2,Koa2,Nodejs v8.9.0linux
如下依赖的版本都是本文所写的时候的版本,或者更旧一些webpack
剩下依赖能够项目demo仓库。ios
对于测试来讲,我也是个新手。至于为何选择了Jest,而不是其余框架(例如mocha+chai、jasmine等),我以为有以下我本身的观点(固然你也能够不采用它):git
yarn add jest -D
#or
npm install jest --save-dev复制代码
很简单对吧。github
因为我项目的Koa后端用的是ES modules的写法而不是Nodejs的Commonjs的写法,因此是须要babel的插件来进行转译的。不然你运行测试用例的时候,将会出现以下问题:
● Test suite failed to run
/Users/molunerfinn/Desktop/work/web/vue-koa-demo/test/sever/todolist.test.js:1
({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){import _regeneratorRuntime from 'babel-runtime/regenerator';import _asyncToGenerator from 'babel-runtime/helpers/asyncToGenerator';var _this = this;import server from '../../app.js';
^^^^^^
SyntaxError: Unexpected token import
at ScriptTransformer._transformAndBuildScript (node_modules/jest-runtime/build/script_transformer.js:305:17)
at Generator.next (<anonymous>)
at new Promise (<anonymous>)复制代码
看了官方github的README发现应该是babel-jest
没装。
yarn add babel-jest -D
#or
npm install babel-jest --save-dev复制代码
可是奇怪的是,文档里说:Note: babel-jest is automatically installed when installing Jest and will automatically transform files if a babel configuration exists in your project. 也就是babel-jest在jest安装的时候便会自动安装了。这点须要求证。
然而发现运行测试用例的时候仍是出了上述问题,查阅了相关issue以后,我给出两种解决办法:
都是修改项目目录下的.babelrc
配置文件,增长env
属性,配置test
环境以下:
1. 增长presets
"env": {
"test": {
"presets": ["env", "stage-2"] // 采用babel-presents-env来转译
}
}复制代码
2. 或者增长plugins
"env": {
"test": {
"plugins": ["transform-es2015-modules-commonjs"] // 采用plugins来说ES modules转译成Commonjs modules
}
}复制代码
再次运行,编译经过。
一般咱们将测试文件(*.test.js或*.spec.js)放置在项目的test目录下。Jest将会自动运行这些测试用例。值得一提的是,一般咱们将基于
TDD
的测试文件命名为*.test.js
,把基于BDD
的测试文件命名为*.spec.js
。这两者的区别能够看这篇文章
咱们能够在package.json
的scripts
字段里加入test
的命令(若是本来存在则换一个名字,不要冲突)
"scripts": {
// ...其余命令
"test": "jest"
// ...其余命令
},复制代码
这样咱们就能够在终端直接运行npm test
来执行测试了。下面咱们先来从后端的Api测试开始写起。
重现一下以前的应用的操做流程,能够发现应用分为登陆前和登陆后两种状态。
能够根据操做流程或者后端api的结构来写测试。若是根据操做流程来写测试就能够分为登陆前和登陆后。若是根据后端api的结构的话,就能够根据routes或者controllers的结构、功能来写测试。
因为本例登陆前和登陆后的api基本上是分开的,因此我主要根据上述后者(routes或controllers)来写测试。
到此须要解释一下通常来讲(写)测试的步骤:
在test
文件夹下新建一个server
文件夹。而后建立一个user.spec.js
文件。
咱们能够经过
import server from '../../app.js'复制代码
的方式将咱们的Koa应用的主入口文件引入。可是此时遇到了一个问题。咱们如何对这个server发起http请求,并对其的返回结果作出判断呢?
在阅读了Async testing Koa with Jest以及A clear and concise introduction to testing Koa with Jest and Supertest这两篇文章以后,我决定使用supertest这个工具了。它是专门用来测试nodejs端HTTP server的测试工具。它内封了superagent这个著名的Ajax请求库。而且支持Promise,意味着咱们对于异步请求的结果也能经过async await
的方式很好的控制了。
安装:
yarn add supertest -D
#or
npm install supertest --save-dev复制代码
如今开始着手写咱们第一个测试用例。先写一个针对登陆功能的吧。当咱们输入了错误的用户名或者密码的时候将没法登陆,后端返回的参数里,success会是false。
// test/server/user.spec.js
import server from '../../app.js'
import request from 'supertest'
afterEach(() => {
server.close() // 当全部测试都跑完了以后,关闭server
})
// 若是输入用户名为Molunerfinn,密码为1234则没法登陆。正确应为molunerfinn和123。
test('Failed to login if typing Molunerfinn & 1234', async () => { // 注意用了async
const response = await request(server) // 注意这里用了await
.post('/auth/user') // post方法向'/auth/user'发送下面的数据
.send({
name: 'Molunerfinn',
password: '1234'
})
expect(response.body.success).toBe(false) // 指望回传的body的success值是false(表明登陆失败)
})复制代码
上述例子中,test()方法能接受3个参数,第一个是对测试的描述(string),第二个是回调函数(fn),第三个是延时参数(number)。本例不须要延时。而后expect()函数里放输出,再用各类match方法来将预期和输出作对比。
在终端执行npm test
,紧张地但愿能跑通也许是人生的第一个测试用例。结果我获得以下关键的报错信息:
● Post todolist failed if not give the params
TypeError: app.address is not a function
...
● Post todolist failed if not give the params
TypeError: _app2.default.close is not a function复制代码
这是怎么回事?说明咱们import进来的server看来并无close、address等方法。缘由在于咱们在app.js
里最后一句:
export default app复制代码
此处export出来的是一个对象。但咱们实际上须要一个function。
在谷歌的过程当中,找到两种解决办法:
1. 修改app.js
将
app.listen(8889, () => {
console.log(`Koa is listening in 8889`)
})
export default app复制代码
改成
export default app.listen(8889, () => {
console.log(`Koa is listening in 8889`)
})复制代码
便可。
2. 修改你的test文件:
在里要用到server
的地方都改成server.callback()
:
const response = await request(server.callback())
.post('/auth/user')
.send({
name: 'Molunerfinn',
password: '1234'
})复制代码
我采用的是第一种作法。
改完以后,顺利经过:
PASS test/sever/user.test.js
✓ Failed to login if typing Molunerfinn & 1234 (248ms)复制代码
然而此时发现一个问题,为什么测试结束了,jest还占用着终端进程呢?我想要的是测试完jest就自动退出了。查了一下文档,发现它的cli有个参数--forceExit
能解决这个问题,因而就把package.json
里的test
命令修改一下(后续咱们还将修改几回)加上这个参数:
"scripts": {
// ...其余命令
"test": "jest --forceExit"
// ...其余命令
},复制代码
再测试一遍,发现没问题。这样一来咱们就能够继续依葫芦画瓢,把auth/*
这个路由的功能都测试一遍:
// server/routes/auth.js
import auth from '../controllers/user.js'
import koaRouter from 'koa-router'
const router = koaRouter()
router.get('/user/:id', auth.getUserInfo) // 定义url的参数是id
router.post('/user', auth.postUserAuth)
export default router复制代码
测试用例以下:
import server from '../../app.js'
import request from 'supertest'
afterEach(() => {
server.close()
})
test('Failed to login if typing Molunerfinn & 1234', async () => {
const response = await request(server)
.post('/auth/user')
.send({
name: 'Molunerfinn',
password: '1234'
})
expect(response.body.success).toBe(false)
})
test('Successed to login if typing Molunerfinn & 123', async () => {
const response = await request(server)
.post('/auth/user')
.send({
name: 'Molunerfinn',
password: '123'
})
expect(response.body.success).toBe(true)
})
test('Failed to login if typing MARK & 123', async () => {
const response = await request(server)
.post('/auth/user')
.send({
name: 'MARK',
password: '123'
})
expect(response.body.info).toBe('用户不存在!')
})
test('Getting the user info is null if the url is /auth/user/10', async () => {
const response = await request(server)
.get('/auth/user/10')
expect(response.body).toEqual({})
})
test('Getting user info successfully if the url is /auth/user/2', async () => {
const response = await request(server)
.get('/auth/user/2')
expect(response.body.user_name).toBe('molunerfinn')
})复制代码
都很简洁易懂,看描述+预期你就能知道在测试什么了。不过须要注意一点的是,咱们用到了toBe()
和toEqual()
两个方法。乍一看好像没有区别。实际上有大区别。
简单来讲,toBe()
适合===
这个判断条件。好比1 === 1
,'hello' === 'hello'
。可是[1] === [1]
是错的。具体缘由很少说,js的基础。因此要判断好比数组或者对象相等的话须要用toEqual()
这个方法。
OK,接下去咱们开始测试api/*
这个路由。
在test
目录下建立一个叫作todolits.spec.js
的文件:
有了上一个测试的经验,测试这个其实也不会有多大的问题。首先咱们来测试一下当咱们没有携带上JSON WEB TOKEN的header的话,服务端是否是返回401错误:
import server from '../../app.js'
import request from 'supertest'
afterEach(() => {
server.close()
})
test('Getting todolist should return 401 if not set the JWT', async () => {
const response = await request(server)
.get('/api/todolist/2')
expect(response.status).toBe(401)
})复制代码
一切看似没问题,可是运行的时候却报错了:
console.error node_modules/jest-jasmine2/build/jasmine/Env.js:194
Unhandled error
console.error node_modules/jest-jasmine2/build/jasmine/Env.js:195
Error: listen EADDRINUSE :::8888
at Object._errnoException (util.js:1024:11)
at _exceptionWithHostPort (util.js:1046:20)
at Server.setupListenHandle [as _listen2] (net.js:1351:14)
at listenInCluster (net.js:1392:12)
at Server.listen (net.js:1476:7)
at Application.listen (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/koa/lib/application.js:64:26)
at Object.<anonymous> (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/app.js:60:5)
at Runtime._execModule (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/jest-runtime/build/index.js:520:13)
at Runtime.requireModule (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/jest-runtime/build/index.js:332:14)
at Runtime.requireModuleOrMock (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/jest-runtime/build/index.js:408:19)复制代码
看来是由于同时运行了两个Koa实例致使了监听端口的冲突。因此咱们须要让Jest按顺序执行。查阅官方文档,发现了runInBand这个参数正是咱们想要的。
因此修改package.json
里的test
命令以下:
"scripts": {
// ...其余命令
"test": "jest --forceExit --runInBand"
// ...其余命令
},复制代码
再次运行,成功经过!
接下来遇到一个问题。咱们的JWT的token本来是登陆成功后生成并派发给前端的。现在咱们测试api的时候并无通过登陆那一步。因此要测试的时候要用的token的话,我以为有两种办法:
koa-jwt
的验证。可是这种方法对项目有入侵性的影响,若是有的时候咱们须要从token获取信息的话就有问题了。我采用第二种办法。为了读者使用方便我是预先生成一个token而后用一个变量存起来的。(真正的开发环境下应对将测试的token放置在项目环境变量.env中)
接下来咱们测试一下数据库的四大操做:增删改查。不过咱们为了一次性将这四个接口都测试一遍能够按照这个顺序:增查改删。其实就是先增长一个todo,而后查找的时候将id记录下来。随后能够用这个id进行更新和删除。
import server from '../../app.js'
import request from 'supertest'
afterEach(() => {
server.close()
})
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibW9sdW5lcmZpbm4iLCJpZCI6MiwiaWF0IjoxNTA5ODAwNTg2fQ.JHHqSDNUgg9YAFGWtD0m3mYc9-XR3Gpw9gkZQXPSavM' // 预先生成的token
let todoId = null // 用来存放测试生成的todo的id
test('Getting todolist should return 401 if not set the JWT', async () => {
const response = await request(server)
.get('/api/todolist/2')
expect(response.status).toBe(401)
})
// 增
test('Created todolist successfully if set the JWT & correct user', async () => {
const response = await request(server)
.post('/api/todolist')
.send({
status: false,
content: '来自测试',
id: 2
})
.set('Authorization', 'Bearer ' + token) // header处加入token验证
expect(response.body.success).toBe(true)
})
// 查
test('Getting todolist successfully if set the JWT & correct user', async () => {
const response = await request(server)
.get('/api/todolist/2')
.set('Authorization', 'Bearer ' + token)
response.body.result.forEach((item, index) => {
if (item.content === '来自测试') todoId = item.id // 获取id
})
expect(response.body.success).toBe(true)
})
// 改
test('Updated todolist successfully if set the JWT & correct todoId', async () => {
const response = await request(server)
.put(`/api/todolist/2/${todoId}/0`) // 拿id去更新
.set('Authorization', 'Bearer ' + token)
expect(response.body.success).toBe(true)
})
// 删
test('Removed todolist successfully if set the JWT & correct todoId', async () => {
const response = await request(server)
.delete(`/api/todolist/2/${todoId}`)
.set('Authorization', 'Bearer ' + token)
expect(response.body.success).toBe(true)
})复制代码
对照着api的4大接口,咱们已经将它们都测试了一遍。那是否是咱们对于服务端的测试已经结束了呢?其实不是的。要想保证后端api的健壮性,咱们得将不少状况都考虑到。可是人为的去排查每一个条件、语句什么的必然过于繁琐和机械。因而咱们须要一个指标来帮咱们确保测试的全面性。这就是测试覆盖率了。
上面说过,Jest是自带了测试覆盖率功能的(其实就是基于istanbul这个工具来生成测试覆盖率的)。要如何开启呢?这里我还走了很多坑。
经过阅读官方的配置文档,我肯定了几个须要开启的参数:
因而咱们须要在package.json
里配置一个Jest字段(不是在scripts字段里配置,而是和scripts在同一级的字段),来配置Jest。
配置以下:
"jest": {
"verbose": true,
"coverageDirectory": "coverage",
"mapCoverage": true,
"collectCoverage": true,
"coverageReporters": [
"lcov", // 会生成lcov测试结果以及HTML格式的漂亮的测试覆盖率报告
"text" // 会在命令行界面输出简单的测试报告
]
}复制代码
而后咱们再进行一遍测试,能够看到在终端里已经输出了简易的测试报告总结:
从中咱们能看到一些字段是100%,而一些不是100%。最后一列Uncovered Lines
就是告诉咱们,测试里没有覆盖到的代码行。为了更直观地看到测试的结果报告,能够到项目的根目录下找到一个coverage
的目录,在lcov-report
目录里有个index.html
就是输出的html报告。打开来看看:
首页是个概览,跟命令行里输出的内容差很少。不过咱们能够往深了看,能够点击左侧的File提供的目录:
而后咱们能够看到没有被覆盖到代码行数(50)以及有一个函数没有被测试到:
一般咱们没有测试到的函数也伴随着代码行数没有被测试到。咱们能够看到在本例里,app的error
事件没有被触发过。想一想也是的,咱们的测试都是创建在合法的api请求的基础上的。因此天然不会触发error
事件。所以咱们须要写一个测试用例来测试这个.on('error')
的函数。
一般这样的测试用例并非特别好写。不过好在咱们能够尝试去触发server端的错误,对于本例来讲,若是向服务端建立一个todo的时候,没有附上相应的信息(id、status、content),就没法建立相应的todo,会触发错误。
// server/models/todolist.js
const createTodolist = async function (data) {
await Todolist.create({
user_id: data.id,
content: data.content,
status: data.status
})
return true
}复制代码
上面是server端建立todo的相关函数,下面是针对它的错误进行的测试:
// test/server/todolist.spec.js
// ...
test('Failed to create a todo if not give the params', async () => {
const response = await request(server)
.post('/api/todolist')
.set('Authorization', 'Bearer ' + token) // 不发送建立的参数
expect(response.status).toBe(500) // 服务端报500错误
})复制代码
再进行测试,发现以前对于app.js的相关测试都已是100%了。
不过controllers/todolist.js
里仍是有未测试到的行数34,以及咱们能够看到% Branch
这列的数字显示的是50而不是100。Branch
的意思就是分支测试。什么是分支测试呢?简单来讲就是你的条件语句测试。好比一个if...else
语句,若是测试用例只跑过if
的条件,而没有跑过else
的条件,那么Branch
的测试就不完整。让咱们来看看是什么条件没有测试到?
能够看到是个三元表达式并无测试完整。(三元表达式也算分支)咱们测试了0的状况,可是没有测试非零的状况,因此再写一个非零的状况:
test('Failed to update todolist if not update the status of todolist', async () => {
const response = await request(server)
.put(`/api/todolist/2/${todoId}/1`) // <- 这里最后一个参数改为了1
.set('Authorization', 'Bearer ' + token)
expect(response.body.success).toBe(false)
})复制代码
再次跑测试:
哈,成功作到了100%测试覆盖率!
虽然作到了100%测试覆盖率,可是有一个问题倒是不容忽视的。那就是咱们如今测试环境和开发环境下的服务端监听的端口是一致的。意味着你不能在开发环境下测试你的代码。好比你写完一个api以后立刻要写一个测试用例的时候,若是测试环境和开发环境的服务端监听的端口一致的话,测试的时候就会由于端口被占用而没法被监听到。
因此咱们须要指定一下测试环境下的端口,让它和开发乃至生产环境的端口不同。我一开始想法很简单,指定一下NODE_ENV=test
的时候用8888端口,开发环境下用8889端口。在app.js
里就是这样写:
// ...
let port = process.env.NODE_ENV === 'test' ? 8888 : 8889
// ...
export default app.listen(port, () => {
console.log(`Koa is listening in ${port}`)
})复制代码
接下去就遇到了两个问题:
Branch
测试是没法彻底经过的——由于始终是在test环境下,没法运行到port = 8889
那个条件跨平台env主要涉及到windows、linux和macOS。要在三个平台在测试的时候都跑着NODE_ENV=test
的话,咱们须要借助cross-env来帮助咱们。
yarn add cross-env -D
#or
npm install cross-env --save-dev复制代码
而后在package.json
里修改test
的命令以下:
"scripts": {
// ...其余命令
"test": "cross-env NODE_ENV=test jest --forceExit --runInBand"
// ...其余命令
},复制代码
这样就能在后端代码里,经过process.env.NODE_ENV
这个变量访问到test
这个值。这样就解决了第一个问题。
目前为止,咱们已经可以解决测试环境和开发环境的监听端口一致的问题了。不过却带来了测试覆盖率不全的问题。
为此我找到两种解决办法:
ignore
注释来忽略测试环境下的一些测试分支条件第一种方法很简单,在须要忽略的地方,输入/* istanbul ignore next */
或/* istanbul ignore <word>[non-word] [optional-docs] */
等语法忽略代码。不过考虑到这是涉及到测试环境和开发环境下的环境变量问题,若是不只仅是端口问题的话,那么就不如采用第二种方法来得更加优雅。(好比开发环境和测试环境的数据库用户和密码都不同的话,仍是须要写在对应的环境变量的)
此时咱们须要另一个很经常使用的库dotenv,它能默认读取.env
文件里的值,让咱们的项目能够经过不一样的.env
文件来应对不一样的环境要求。
步骤以下:
yarn add dotenv
#or
npm install dotenv --save复制代码
.env
和.env.test
两个文件,分别应用于开发环境和测试环境// .env
DB_USER=xxxx # 数据库用户
DB_PASSWORD=yyyy # 数据库密码
PORT=8889 # 监听端口复制代码
// .env.test
DB_USER=xxxx # 数据库用户
DB_PASSWORD=yyyy # 数据库密码
PORT=8888 # 监听端口复制代码
env.js
文件,用于不一样环境下采用不一样的环境变量。代码以下:import * as dotenv from 'dotenv'
let path = process.env.NODE_ENV === 'test' ? '.env.test' : '.env'
dotenv.config({path, silent: true})复制代码
import './env'复制代码
而后把本来那句port的话改为:
let port = process.env.PORT复制代码
再把数据库链接的用户密码也用环境变量来代替:
// server/config/db.js
import '../../env'
import Sequelize from 'sequelize'
const Todolist = new Sequelize(`mysql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@localhost/todolist`, {
define: {
timestamps: false // 取消Sequelzie自动给数据表加入时间戳(createdAt以及updatedAt)
}
})复制代码
不过须要注意的是,.env和.env.js文件都不该该归入git版本库,由于都是比较重要的内容。
这样就能实现不一样环境下用不一样的变量了。慢着!这样不是尚未解决问题吗?env.js
里的条件仍是没法被测试覆盖啊——你确定有这样的疑问。不用紧张,如今给出解决办法——给Jest指定收集测试覆盖率的范围:
修改package.json
里jest
字段以下:
"jest": {
"verbose": true,
"coverageDirectory": "coverage",
"mapCoverage": true,
"collectCoverage": true,
"coverageReporters": [
"lcov",
"text"
],
"collectCoverageFrom": [ // 指定Jest收集测试覆盖率的范围
"!env.js", // 排除env.js
"server/**/*.js",
"app.js"
]
}复制代码
作完这些工做以后,再跑一次测试,一次经过:
这样咱们就完成了后端的api测试。完成了100%测试覆盖率。下面咱们能够开始测试Vue的前端项目了。
Vue的前端测试我就要推荐来自官方的vue-test-utils了。固然前端测试大体分红了单元测试(Unit test)和端对端测试(e2e test),因为端对端的测试对于测试环境的要求比较严苛,并且测试起来比较繁琐,并且官方给出的测试框架是单元测试框架,所以本文对于Vue的前端测试也仅介绍配合官方工具的单元测试。
在Vue的前端测试中咱们可以了解到jest的mock、snapshot等特性和用法和vue-test-utils提供的mount、shallow、setData等一系列操做。
根据官网的介绍咱们须要安装以下:
yarn add vue-test-utils vue-jest jest-serializer-vue -D
#or
npm install vue-test-utils vue-jest jest-serializer-vue --save-dev复制代码
其中,vue-test-utils
是最关键的测试框架。提供了一系列对于Vue组件的测试操做。(下面会提到)。vue-jest
用于处理*.vue
的文件,jest-serializer-vue
用于快照测试提供快照序列化。
1. 修改.babelrc
在test
的env
里增长或修改presets
:
{
"presets": [
["env", { "modules": false }],
"stage-2"
],
"plugins": [
"transform-runtime"
],
"comments": false,
"env": {
"test": {
"plugins": ["transform-es2015-modules-commonjs"],
"presets": [
["env", { "targets": { "node": "current" }}] // 增长或修改
]
}
}
}复制代码
2. 修改package.json
里的jest配置:
"jest": {
"verbose": true,
"moduleFileExtensions": [
"js"
],
"transform": { // 增长transform转换
".*\\.(vue)$": "<rootDir>/node_modules/vue-jest",
"^.+\\.js$": "<rootDir>/node_modules/babel-jest"
},
"coverageDirectory": "coverage",
"mapCoverage": true,
"collectCoverage": true,
"coverageReporters": [
"lcov",
"text"
],
"moduleNameMapper": { // 处理webpack alias
"@/(.*)$": "<rootDir>/src/$1"
},
"snapshotSerializers": [ // 配置快照测试
"<rootDir>/node_modules/jest-serializer-vue"
],
"collectCoverageFrom": [
"!env.js",
"server/**/*.js",
"app.js"
]
}复制代码
关于vue-test-utils和Jest的配合测试,我推荐能够查看这个系列的文章,讲解很清晰。
接着,明确一下前端单元测试都须要测试些什么东西。引用vue-test-utils
的说法:
对于 UI 组件来讲,咱们不推荐一味追求行级覆盖率,由于它会致使咱们过度关注组件的内部实现细节,从而致使琐碎的测试。
取而代之的是,咱们推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件以后是否致使预期结果 (渲染结果或触发自定义事件)。
好比,对于每次点击按钮都会将计数加一的 Counter 组件来讲,其测试用例将会模拟点击并断言渲染结果会加 1。该测试并无关注 Counter 如何递增数值,而只关注其输入和输出。
该提议的好处在于,即使该组件的内部实现已经随时间发生了改变,只要你的组件的公共接口始终保持一致,测试就能够经过。
因此,相对于后端api测试看重测试覆盖率而言,前端的单元测试是没必要一味追求测试覆盖率的。(固然你要想达到100%测试覆盖率也是没问题的,只不过若是要达到这样的效果你须要撰写很是多繁琐的测试用例,占用太多时间,得不偿失。)替代地,咱们只须要回归测试的本源:给定输入,我只关心输出,不考虑内部如何实现。只要能覆盖到和用户相关的操做,能测试到页面的功能便可。
和以前相似,咱们在test/client
目录下书写咱们的测试用例。对于Vue的单元测试来讲,咱们就是针对*.vue
文件进行测试了。因为本例里的app.vue
无实际意义,因此就测试Login.vue
和Todolist.vue
便可。
运用vue-test-utils
如何来进行测试呢?简单来讲,咱们须要的作的就是用vue-test-utils
提供的mount
或者shallow
方法将组件在后端渲染出来,而后经过一些诸如setData
,propsData
、setMethods
等方法模拟用户的操做或者模拟咱们的测试条件,最后再用jest提供的expect
断言来对预期的结果进行判断。这里的预期就很丰富了。咱们能够经过判断事件是否触发、元素是否存在、数据是否正确、方法是否被调用等等来对咱们的组件进行比较全面的测试。下面的例子里也会比较完整地介绍它们。
建立一个login.spec.js
文件。
首先咱们来测试页面里是否有两个输入框和一个登陆按钮。根据官方文档,我首先注意到了shallow rendering,它的说明是,对于某个组件而言,只渲染这个组件自己,而不渲染它的子组件,让测试速度提升,也符合单元测试的理念。看着好像很不错的样子,拿过来用。
import { shallow } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'
let wrapper
beforeEach(() => {
wrapper = shallow(Login) // 每次测试前确保咱们的测试实例都是是干净完整的。返回一个wrapper对象
})
test('Should have two input & one button', () => {
const inputs = wrapper.findAll('.el-input') // 经过findAll来查找dom或者vue实例
const loginButton = wrapper.find('.el-button') // 经过find查找元素
expect(inputs.length).toBe(2) // 应该有两个输入框
expect(loginButton).toBeTruthy() // 应该有一个登陆按钮。 只要断言条件不为空或这false,toBeTruthy就能经过。
})复制代码
一切看起来很正常。运行测试。结果报错了。报错是input.length
并不等于2。经过debug断点查看,确实并无找到元素。
这是怎么回事?哦对,我想起来,形如el-input
、el-button
其实也至关因而子组件啊,因此shallow
并不能将它们渲染出来。在这种状况下,用shallow
来渲染就不合适了。因此仍是须要用mount
来渲染,它会将页面渲染成它应该有的样子。
import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'
let wrapper
beforeEach(() => {
wrapper = mount(Login) // 每次测试前确保咱们的测试实例都是是干净完整的。返回一个wrapper对象
})
test('Should have two input & one button', () => {
const inputs = wrapper.findAll('.el-input') // 经过findAll来查找dom或者vue实例
const loginButton = wrapper.find('.el-button') // 经过find查找元素
expect(inputs.length).toBe(2) // 应该有两个输入框
expect(loginButton).toBeTruthy() // 应该有一个登陆按钮。 只要断言条件不为空或这false,toBeTruthy就能经过。
})复制代码
测试,仍是报错!仍是没有找到它们。为何呢?再想一想。应该是咱们并无将element-ui
引入咱们的测试里。由于.el-input
其实是element-ui
的一个组件,若是没有引入它,vue天然没法将一个el-input
渲染成<div class="el-input"><input></div>
这样的形式。想通了就好说了,把它引进来。由于咱们的项目里在webpack
环境下是有一个main.js
做为入口文件的,在测试里可没有这个东西。因此Vue天然也不知道你测试里用到了什么依赖,须要咱们单独引入:
import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'
Vue.use(elementUI)
// ...复制代码
再次运行测试,经过!
接下来,使用Jest内置的一个特别棒的特性:快照(snapshot)。它可以将某个状态下的html结构以一个快照文件的形式存储下来,之后每次运行快照测试的时候若是发现跟以前的快照测试的结果不一致,测试就没法经过。
固然若是是之后页面确实须要发生改变,快照须要更新,那么只须要在执行jest的时候增长一个-u
的参数,就能实现快照的更新。
说完了原理来实践一下。对于登陆页,实际上咱们只须要确保html结构没问题那么全部必要的元素天然就存在。所以快照测试写起来特别方便:
test('Should have the expected html structure', () => {
expect(wrapper.element).toMatchSnapshot() // 调用toMatchSnapshot来比对快照
})复制代码
若是是第一次进行快照测试,那么它会在你的测试文件所在目录下新建一个__snapshots__
的目录存放快照文件。上面的测试就生成了一个login.spec.js.snap
的文件,以下:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Should have the expected html structure 1`] = `
<div class="el-row content" >
<div class="el-col el-col-24 el-col-xs-24 el-col-sm-6 el-col-sm-offset-9" >
<span class="title" >
欢迎登陆
</span>
<div class="el-row" >
<div class="el-input" >
<!---->
<!---->
<input autocomplete="off" class="el-input__inner" placeholder="帐号" type="text" />
<!---->
<!---->
</div>
<div class="el-input" >
<!---->
<!---->
<input autocomplete="off" class="el-input__inner" placeholder="密码" type="password" />
<!---->
<!---->
</div>
<button class="el-button el-button--primary" type="button" >
<!---->
<!---->
<span>
登陆
</span>
</button>
</div>
</div>
</div>
`;复制代码
能够看到它将整个html结构以快照的形式保存下来了。快照测试能确保咱们的前端页面结构的完整性和稳定性。
不少时候咱们须要测试在某些状况下,Vue中的一些methods可否被触发。好比本例里的,咱们点击登陆按钮应对要触发loginToDo
这个方法。因而就涉及到了methods
的测试,这个时候vue-test-utils
提供的setMethods
这个方法就颇有用了。咱们能够经过设置(覆盖)loginToDo
这个方法,来查看它是否被触发了。
注意,一旦setMethods了某个方法,那么在某个test()内部,这个方法本来的做用将彻底被你的新function覆盖。包括这个Vue实例里其余methods经过
this.xxx()
方式调用也同样。
test('loginToDo should be called after clicking the button', () => {
const stub = jest.fn() // 伪造一个jest的mock funciton
wrapper.setMethods({ loginToDo: stub }) // setMethods将loginToDo这个方法覆写
wrapper.find('.el-button').trigger('click') // 对button触发一个click事件
expect(stub).toBeCalled() // 查看loginToDo是否被调用
})复制代码
注意到这里咱们用到了jest.fn
这个方法,这个在下节会详细说明。此处你只须要明白这个是jest提供的,能够用来检测是否被调用的方法。
接下去就是对登陆这个功能的测试了。因为咱们以前把Koa的后端api进行了测试,因此咱们在前端测试中,能够默认后端的api接口都是返回正确的结果的。(这也是咱们先进行了Koa端测试的缘由,保证了后端api的健壮性回到前端测试的时候就能很轻松)
虽然道理是说得通的,可是咱们如何来默认、或者说“伪造”咱们的api请求,以及返回的数据呢?这个时候就须要用上Jest一个很是有用的功能mock
了。能够说mock
这个词对不少作前端的朋友来讲,不是很陌生。在没有后端,或者后端功能还未完成的时候,咱们能够经过api的mock来实现伪造请求和数据。
Jest的mock也是同理,不过它更厉害的一点是,它能伪造库。好比咱们接下去要用的HTTP请求库axios
。对于咱们的页面来讲,登陆只须要发送post请求,判断返回的success
是不是true
便可。咱们先来mock一下axios
以及它的post
请求。
jest.mock('axios', () => ({
post: jest.fn(() => Promise.resolve({
data: {
success: false,
info: '用户不存在!'
}
}))
}))复制代码
而后咱们能够把axios引入咱们的项目了:
import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'
import axios from 'axios'
Vue.use(elementUI)
Vue.prototype.$http = axios
jest.mock(....)复制代码
等会,你确定会提出疑问,jest.mock()
方法写在了import axios from 'axios'
下面,那么不就意味着axios
是从node_modules
里引入的吗?其实不是的,jest.mock()
会实现函数提高,也就是实际上上面的代码其实和下面的是同样的:
jest.mock(....)
import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'
import axios from 'axios' // 这里的axios是来自jest.mock()里的axios
Vue.use(elementUI)
Vue.prototype.$http = axios复制代码
看起来甚至有些var
的变量提高的味道。
不过这样的好处是很明显的,咱们能够在不破坏eslint
的规则的状况下采用第一种的写法而达到同样的目的。
而后你还会注意到咱们用到了jest.fn()
的方法,它是jest的mock方法里很重要的一部分。它自己是一个mock function
。经过它可以实现方法调用的追踪以及后面会说到的可以实现建立复杂行为的模拟功能。
继续咱们没写完的测试:
test('Failed to login if not typing the correct password', async () => {
wrapper.setData({
account: 'molunerfinn',
password: '1234'
}) // 模拟用户输入数据
const result = await wrapper.vm.loginToDo() // 模拟异步请求的效果
expect(result.data.success).toBe(false) // 指望返回的数据里success是false
expect(result.data.info).toBe('密码错误!')
})复制代码
咱们经过setData
来模拟用户在两个input框内输入了数据。而后经过wrapper.vm.loginToDo()
来显式调用loginTodo
的方法。因为咱们返回的是一个Promise
对象,因此能够用async await
将resolve里的数据拿出来。而后测试是否和预期相符。咱们此次是测试了输入错误的状况,测试经过,没有问题。那若是我接下去要再测试用户密码都经过的测试怎么办?咱们mock
的axios
的post
方法只有一个,难不成还能一个方法输出多种结果?下一节来详细说明这个问题。
回顾一下咱们的mock写法:
jest.mock('axios', () => ({
post: jest.fn(() => Promise.resolve({
data: {
success: false,
info: '用户不存在!'
}
}))
}))复制代码
能够看到,采用这种写法的话,post请求始终只能返回一种结果。如何作到既能mock
这个post
方法又能实现多种结果测试?接下去就要用到Jest另外一个杀手锏的方法:mockImplementationOnce。官方的示例以下:
const myMockFn = jest.fn(() => 'default')
.mockImplementationOnce(() => 'first call')
.mockImplementationOnce(() => 'second call');
console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn());
// > 'first call', 'second call', 'default', 'default'复制代码
4次调用同一个方法却能给出不一样的运行结果。这正是咱们想要的。
因而在咱们测试登陆成功这个方法的时候咱们须要改写一下咱们对axios
的mock方法:
jest.mock('axios', () => ({
post: jest.fn()
.mockImplementationOnce(() => Promise.resolve({
data: {
success: false,
info: '用户不存在!'
}
}))
.mockImplementationOnce(() => Promise.resolve({
data: {
success: true,
token: 'xxx' // 随意返回一个token
}
}))
}))复制代码
而后开始写咱们的测试:
test('Succeeded to login if typing the correct account & password', async () => {
wrapper.setData({
account: 'molunerfinn',
password: '123'
})
const result = await wrapper.vm.loginToDo()
expect(result.data.success).toBe(true)
})复制代码
就在我认为跟以前的测试没有什么两样的时候,报错传来了。先来看看当success
为true的时候,loginToDo
在作什么:
if (res.data.success) { // 若是成功
sessionStorage.setItem('demo-token', res.data.token) // 用sessionStorage把token存下来
this.$message({ // 登陆成功,显示提示语
type: 'success',
message: '登陆成功!'
})
this.$router.push('/todolist') // 进入todolist页面,登陆成功
}复制代码
很快我就看到了错误所在:咱们的测试环境里并无sessionStorage
这个本来应该在浏览器端的东西。以及咱们并无使用vue-router
,因此就没法执行this.$router.push()
这个方法。
首先安装一下mock-local-storage
这个库(也包括了sessionStorage)
yarn add mock-local-storage -D
#or
npm install mock-local-storage --save-dev复制代码
而后配置一下package.json
里的jest
参数:
"jest": {
// ...
"setupTestFrameworkScriptFile": "mock-local-storage"
}复制代码
对于后者,阅读过官方的建议,咱们不该该引入vue-router
,这样会破坏咱们的单元测试。相应的,咱们能够mock它。不过此次是用vue-test-utils
自带的mocks
特性了:
const $router = { // 声明一个$router对象
push: jest.fn()
}
beforeEach(() => {
wrapper = mount(Login, {
mocks: {
$router // 在beforeEach钩子里挂载进mount的mocks里。
}
})
})复制代码
经过这个方式,会把$router
这个对象挂载到实例的prototype
上,就能实如今组件内部经过this.$router.push()
的方式来调用了。
上述两个问题解决以后,咱们的测试也顺利经过了:
接下去开始测试Todolist.vue
这个组件了。
相似的咱们在test/client
目录下建立一个叫作todolist.spec.js
的文件。
先把上例中的一些环境先预置进来:
import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Todolist from '../../src/components/Todolist.vue'
import axios from 'axios'
Vue.use(elementUI)
jest.mock(...) // 后续补充
Vue.prototype.$http = axios
let wrapper
beforeEach(() => {
wrapper = mount(Todolist)
wrapper.setData({
name: 'Molunerfinn', // 预置数据
id: 2
})
})复制代码
先来个简单的,测试数据是否正确:
// test 1
test('Should get the right username & id', () => {
expect(wrapper.vm.name).toBe('Molunerfinn')
expect(wrapper.vm.id).toBe(2)
})复制代码
不过须要注意的是,todolist
这个页面在created
阶段就会触发getUserInfo
和getTodolist
这两个方法,而咱们的wrapper是至关于在mounted
阶段以后的。因此在咱们拿到wrapper的时候,created
、mounted
等生命周期的钩子其实已经运行了。本例里getUserInfo
是从sessionStorage
里取值,不涉及ajax请求。可是getTodolist
涉及请求,所以须要在jest.mock方法里为其配置一下,不然将会报错:
jest.mock('axios', () => ({
get: jest.fn()
// for test 1
.mockImplementationOnce(() => Promise.resolve({
status: 200,
data: {
result: []
}
}))
}))复制代码
上面说到的getTodolist
和getUserInfo
就是在测试中须要注意的隐式事件,它们并不受你测试的控制就在组件里触发了。
接下来开始进行键盘事件测试。其实跟鼠标事件相似,键盘事件的触发也是以事件名来命名的。不过对于一些常见的事件,vue-test-utils
里给出了一些别名好比:
enter, tab, delete, esc, space, up, down, left, right
。你在书写测试的时候能够直接这样:
const input = wrapper.find('.el-input')
input.trigger('keyup.enter')复制代码
固然若是你须要指定某个键也是能够的,只须要提供keyCode就行:
const input = wrapper.find('.el-input')
input.trigger('keyup', {
which: 13 // enter的keyCode为13
})复制代码
因而咱们把这个测试完善一下,这个测试是测试当我在输入框激活的状况下按下回车键可否触发addTodos
这个事件:
test('Should trigger addTodos when typing the enter key', () => {
const stub = jest.fn()
wrapper.setMethods({
addTodos: stub
})
const input = wrapper.find('.el-input')
input.trigger('keyup.enter')
expect(stub).toBeCalled()
})复制代码
没有问题,一次经过。
注意到咱们在实际开发时,在组件上调用原生事件是须要加.native
修饰符的:
<el-input placeholder="请输入待办事项" v-model="todos" @keyup.enter.native="addTodos"></el-input>复制代码
可是在vue-test-utils
里你是能够直接经过原生的keyup.enger
来触发的。
不少时候咱们要跟异步打交道。尤为是异步取值,异步赋值,页面异步更新。而对于使用Vue来作的实际开发来讲,异步的状况简直太多了。
还记得nextTick
么?不少时候,咱们要获取一个变动的数据结果,不能直接经过this.xxx
获取,相应的咱们须要在this.$nextTick()
里获取。在测试里咱们也会遇到不少须要异步获取的状况,可是咱们不须要nextTick
这个办法,相应的咱们能够经过async await
配合wrapper.update()
来实现组件更新。例以下面这个测试添加todo成功的例子:
test('Should add a todo if handle in the right way', async () => {
wrapper.setData({
todos: 'Test',
stauts: '0',
id: 1
})
await wrapper.vm.addTodos()
await wrapper.update()
expect(wrapper.vm.list).toEqual([
{
status: '0',
content: 'Test',
id: 1
}
])
})复制代码
在本例中,从进页面到添加一个todo并显示出来须要以下步骤:
能够看到总共有3个ajax请求。其中第一步不在咱们test()的范围内,二、三、4都是咱们能控制的。而addTodos和getTodolist这两个ajax请求带来的就是异步的操做。虽然咱们mock方法,可是本质上是返回了Promise对象。因此仍是须要用await
来等待。
注意你在jest.mock()里要加上相应的mockImplementationOnce的get和post请求。
因此第一步await wrapper.vm.addTodos()
就是等待addTodos()
的返回。
第二步await wrapper.update()
实际是在等待getTodolist
的返回。
缺一不可。两步等待以后咱们就能够经过断言数据list
的方式测试咱们是否拿到了返回的todo的信息。
接下去的就是对todo的一些增删改查的操做,采用的测试方法已经和前文所述相差无几,再也不赘述。至此全部的独立测试用例的说明就说完了。看看这测试经过的成就感:
不过在测试中我还有关于调试的一些经验想分享一下,配合调试能更好的判断咱们的测试的时候发生的不可预知的问题所在。
因为我本身是使用VSCode来作的开发和调试,因此一些用其余IDE或者编辑器的朋友们可能会有所失望。不过不要紧,能够考虑加入VSCode阵营嘛!
本文撰写的时候采用的nodejs版本为8.9.0
,VSCode版本为1.18.0
,因此全部的debug测试的配置仅保证适用于目前的环境。其余环境的可能须要自行测试一下,再也不多说。
关于jest的调试的配置以下:(注意配置路径为VScode关于本项目的.vscode/launch.json
)
{
// Use IntelliSense to learn about possible Node.js debug attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Jest",
"type": "node",
"request": "launch",
"program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js",
"stopOnEntry": false,
"args": [
"--runInBand",
"--forceExit"
],
"cwd": "${workspaceRoot}",
"preLaunchTask": null,
"runtimeExecutable": null,
"runtimeArgs": [
"--nolazy"
],
"env": {
"NODE_ENV": "test"
},
"console": "integratedTerminal",
"sourceMaps": true
}
]
}复制代码
配置完上面的配置以后,你能够在DEBUG
面板里(不要跟我说你不知道什么是DEBUG面板~)找到名为Debug Jest
的选项:
而后你能够在你的测试文件里打断点了:
而后运行debug模式,按那个绿色启动按钮,就能进入DEBUG模式,当运行到断点处就会停下:
因而你能够在左侧面板的Local
和Closure
里找到当前做用域下你所须要的变量值、变量类型等等。充分运用VSCode的debug模式,开发的时候查错和调试的效率都会大大加大。
本文用了很大的篇幅描述了如何搭建一个Jest测试环境,并在测试过程当中不断完善咱们的测试环境。讲述了Koa后端测试的方法和测试覆盖率的提升,讲述了Vue前端单元测试环境的搭建以及许多相应的测试实例,以及在测试过程当中不停地遇到问题并解决问题。可以看到此处的都不是通常有耐心的人,为大家鼓掌~也但愿大家经过这篇文章能过对本文在开头提出的几个重点在心中有所体会和感悟:
能够了解到测试的意义,Jest测试框架的搭建,先后端测试的异同点,如何写测试用例,如何查看测试结果并提高咱们的测试覆盖率,100%测试覆盖率是不是必须,以及在搭建测试环境、以及测试自己过程当中遇到的各类疑难杂症。
本文全部的测试用例以及总体项目实例你均可以在个人vue-koa-demo的github项目中找到源代码。若是你喜欢个人文章以及项目,欢迎点个star~若是你对个人文章和项目有任何建议或者意见,欢迎在文末评论或者在本项目的issues跟我探讨!
本文首发于个人博客,欢迎踩点~
Koa相关
How to use Jest to test Express middleware or a funciton which consumes a callback?
A clear and concise introduction to testing Koa with Jest and Supertest
Test port question
Coverage bug
Vue相关