1 前言
文章研究了四个问题:什么是自动化测试、为什么要自动化测试、什么项目适合自动化测试、自动化测试具体要怎么做。在寻找这四个问题答案的过程中,梳理了一套完整的前端自动化测试方案,包括:单元测试、接口测试、功能测试、基准测试。
2 什么是自动化测试
维基百科是这样定义的
在软件测试中,测试自动化(英语:Test automation)是一种测试方法,使用特定的软件,去控制测试流程,并比较实际的结果与预期结果之间的差异。通过将测试自动化,可以让正式的测试过程中的必要测试,可以反复进行;通过这种方法,也可以将难以手动进行的测试,交由软件来做。
测试自动化的最大优势就是可以快速而且反复的进行测试。
总结一下:自动化测试指软件测试的自动化,让软件代替人工测试,可以快速、反复进行。
关于自动化测试有一个金字塔理论,把测试从上到下分为UI(用户界面测试)/Service(服务测试) /Unit(单元测试 )。如图所示,越往金字塔底层,测试的效率越高,测试质量保障程度越高,测试的成本越低。怎么理解这句话呢?前端项目通常UI变化频繁,一旦发生变化,UI测试用例就无法执行且难以维护,所以UI自动化测试的成本高,收益小;相比UI测试,Service测试更加简单直接且变化不会很频繁;单元测试主要对公共函数、方法进行测试,测试用例复用度高且更能保证代码质量。

3 实施自动化测试有什么好处
测试最重要的目的是验证代码正确性,确保项目质量。举个例子,某一天我写了一个逻辑复杂的函数,这个函数被很多地方调用,过了一个月之后,我可能忘记这里面的具体逻辑了,出于某种原因需要为这个函数增加一些功能,修改这个函数的代码,那我要怎么做才能保证修改代码后不影响其他的调用者呢,或者说,我要怎么做,才能快速的知道哪些地方受影响,哪些地方不受影响呢?答案就是实施自动化测试,跑测试用例。
如果不进行自动化测试,我们会如何验证代码的正确性?通常FE使用的方法是手动测试:console、alert、打断点、点点点。但手动测试是一次性的,如果下次有人对代码功能做了修改,我们不得不再次重复手动测试的工作,并且很难保证测试的全覆盖。但如果编写测试用例进行自动化测试,第一次写完的测试用例是可以重复使用的,一次编写,多次运行。如果测试用例写的完善、语义化,开发人员还可以通过看测试用例快速了解项目需求。实施自动化测试可以驱动开发人员在代码的设计中做更好的抽象,写可测试的代码,以测试公用方法为例,要确保被测试的方法无副作用,既对外部变量没有依赖,也不会改变全局原本的状态。
总结一下,实施自动化测试有四个好处:
-
可以验证代码正确性,保证项目质量
-
测试用例可以复用,一次编写,多次运行
-
通过看测试用例可以快速了解需求
-
驱动开发,指导设计,保证写的代码可测试
4 什么样的项目适合自动化测试
自动化测试如此优秀,那是不是所有项目都适合进行自动化测试?答案是否定的,因为有成本。在实施自动化测试之前需要对软件开发过程进行分析,基于投入产出来判断是否适合实施自动化测试。实施自动化测试的项目通常需要同时满足以下条件:
- 需求变动不频繁
- 项目周期足够长
- 自动化测试脚本可重复使用
- 代码规范可测试
如果需求变动过于频繁,维护测试脚本的成本太高;如果项目周期比较短,没有足够的时间去支持自动化测试的过程;如果测试脚本重复使用率低,耗费的精力大于创造的价值,不值得;如果代码不规范,可测试性差,那自动化测试实施起来会比较困难。
5 自动化测试怎么做
5.1 原始的测试方法
举个例子,现在有一个方法sum
const sum = (a, b) => { return a + b }
如何证明sum方法的正确性?我们通常会使用如下代码进行测试
// test/util.test.js
const sum = (a, b) => { return a + b }
if(sum(1,1)===2){
console.log('sum(1,1)===2,测试结果符合预期,方法正确')
}else{
console.log('sum(1,1)===2,测试结果不符合预期,方法出错')
}
执行测试代码后控制台输出结果如下

测试结果正确。假设现在把sum方法改为+1
const sum = (a, b) => { return a + b + 1 }
执行测试代码后控制台输出结果如下

这个输出虽然显示了方法出错的提示,但是对结果正确与错误没有做明显的区分,测试结论不够直观,我们把测试代码修改一下
// test/util.test.js
const sum = (a, b) => { return a + b + 1 }
if (sum(1, 1) === 2) {
console.log(' sum(1,1)===2,测试结果符合预期,方法正确')
} else {
throw new Error('sum(1,1)===2,测试结果不符合预期,方法出错')
}
这段代码执行后,一旦方法执行的结果不符合预期就主动抛出错误

这样就能更直观的看出测试结论。我们进一步优化,使用nodejs提供的断言模块来书写测试用例
const sum = (a, b) => { return a + b + 1 }
const assert = require('assert')
assert.equal(sum(1, 1), 2)
执行测试代码后控制台结果如下

输出信息与刚才的效果类似:执行结果不符合预期就主动抛出错误。使用assert达到了相同的效果,但代码量减小了,并且更加语义化。
5.2 使用测试框架
上面的方法可以帮助我们完成代码测试,那有没有更好的方式呢?我们开发项目时通常会选择使用框架和库,使用框架的好处是约束我们代码的风格,保证代码的可维护性和扩展性,使用工具库可以提高开发效率。同理,在实施自动化测试时我们也会选择使用测试框架和库。目前市面上比较流行的前端测试框架有Mocha、QUnit、Jasmine、Jest等,如下做个简单介绍

框架可以为我们输出更加直观的测试报告,比如像下面这样,正确和错误的测试结果都给我们展示

还可以输出文档结构的测试报告,比如下面这样

5.3 测试方案技术选型
本文讨论的自动化测试方案技术选型如下:
- 测试框架:mocha
- 断言库:chai
- 测试报告:mochawesome
- 测试覆盖率:Istanbul
- 测试浏览器:chrome
- 浏览器驱动:selenium-webdriver/chrome
- 接口测试http请求断言:supertest
- react组件测试:enzyme
- 基准测试:benchmark
选择Mocha是因为它:
- 精简而灵活,扩展性强
- 社区成熟用的人多
- 各种测试用例在社区都能找到
下面我们通过一段测试用例来看一下Mocha有什么能力:

可以看到Mocha最核心的四项能力
- 测试用例分组
- 生命周期钩子
- 兼容不同风格断言
- 同步异步测试架构
代码中describe块称为“测试套件”,表示一组相关的测试,它是一个函数,第一个参数是测试套件的名称("测试 sum 方法"),第二个参数是实际执行的函数,分组让测试用例代码结构化,易于维护。it块称为"测试用例",表示一个单独的测试,是测试的最小单位。它也是一个函数,第一个参数是测试用例的名称("1 加 1 应该等于 2"),第二个参数是实际执行的函数。
选择chai作为断言库是因为它提供了两种风格的断言:BDD风格(行为驱动开发)和TDD风格(测试驱动开发),其中BDD风格更接近自然语言。使用它可以自由、灵活的与Mocha搭配,下图是chai官网展示的两种断言风格。

5.4 测试方案代码
下面开始梳理完整的自动化测试方案,整体目录结构如下:

5.4.1 单元测试
(1)对如下方法进行单元测试
// /src/client/common/js/testUtil.js
export const sum = (a, b) => {
return a + b
}
编写好测试用例
import { sum } from '../../src/client/common/js/testUtil.js'
const { expect } = require('chai')
describe('单元测试: sum (a, b)', function () {
it('1+1 应该等于 2', function () {
expect(sum(1, 1)).to.be.equal(2)
})
})
// skip可以指定跳过某个分组
describe.skip('单元测试:金额按千分位逗号分隔的方法 formatMoney (s, type)', function () {...})
然后使用mocha执行测试用例,输出结果如下

可以看到两个测试分组有一个测试通过,一个被我们主动跳过。使用mocha执行测试用例时,因为我们指定了测试报告格式--reporter参数为mochawesome,测试报告会被输出为如下的html格式

为了分析当前测试用例对源代码的覆盖情况,我们使用Istanbul生成测试覆盖率报告

- 语句覆盖率(statement coverage):是否每个语句都执行了
- 分支覆盖率(branch coverage):是否每个if代码块都执行了
- 函数覆盖率(function coverage):是否每个函数都调用了
- 行覆盖率(line coverage):是否每一行都执行了
分别对应上图的Statements、Branches、Functions、Lines,点击左侧链接可以查看源码测试详情,绿色部分表示已被测试覆盖

关于测试覆盖率,需要强调的是,我们不应该把测试覆盖率的高低作为检验项目质量的标准,只能作为参考。代码覆盖率真正的意义在于帮助开发者找到代码设计的问题,帮助我们发现为什么有的代码没有被测试覆盖到,是代码设计有问题,还是加入了无用代码,它可以指导我们在代码设计中做更好的抽象,写可测试的代码。
(2)React组件测试
现在有如下的React组件
// /src/client/components/Empty/index.jsx'
import React, { Component } from 'react'
import { Icon } from 'antd'
const Empty = (props) => {
const placeholder = props.placeholder
return (
<div>
<Icon type='meh-o' />
<span>{placeholder || '数据为空'}</span>
</div>
)
}
module.exports = Empty
编写测试用例对它进行测试
import React from 'react'
import { expect } from 'chai'
import Enzyme, { mount, render, shallow } from 'enzyme'
import Adapter from 'enzyme-adapter-react-15.4' // 根据React的版本安装适配器
import Empty from '../../src/client/components/Empty/index.jsx'
import { spy } from 'sinon' // 对原有的函数进行封装并进行监听
Enzyme.configure({ adapter: new Adapter() }) // 使用Enzyme 先适配React对应的版本
describe('测试React组件: <Empty />', () => {
it('不传入属性时,组件中span的文本为"数据为空"', () => {
const wrapper = render(<Empty />)
expect(wrapper.find('span').text()).to.equal('数据为空')
})
it('传入属性"我是占位文本"时,组件中span的文本为"我是占位文本"', () => {
const wrapper = render(<Empty placeholder='我是占位文本' />)
expect(wrapper.find('span').text()).to.equal('我是占位文本')
})
})
使用mocha执行测试用例会生成如下测试报告,测试通过

测试覆盖率报告如下


5.4.2 接口测试
编写测试用例,使用supertest实施接口测试
const request = require('supertest')
const { expect } = require('chai')
const BASE_URL = 'http://127.0.0.1:1990'
describe('接口测试:商户登录测试用例', function () {
it('登录接口 /api/user/login', function (done) {
request(BASE_URL)
.post('/api/user/login')
.set('Content-Type', 'application/json') // set header内容
.send({ // send body内容
user_code: 666666,
password: 666666
})
.expect(200) // 断言希望得到返回http状态码
.end(function (err, res) {
// console.info(res.body) // 返回结果
expect(res.body).to.be.an('object')
expect(res.body.data.user_name).to.equal('商户AAAAA')
done()
})
})
})
执行接口测试用例生成如下测试报告


5.4.3 e2e测试
编写e2e测试用例,使用selenium-webdriver驱动浏览器进行功能测试
const { expect } = require('chai')
const { Builder, By, Key, until } = require('selenium-webdriver')
const chromeDriver = require('selenium-webdriver/chrome')
const assert = require('assert')
describe('e2e测试:商户系统端到端测试用例', () => {
let driver
before(function () {
// 在本区块的所有测试用例之前执行
driver = new Builder()
.forBrowser('chrome')
// 设置无界面测试
// .setChromeOptions(new chromeDriver.Options().addArguments(['headless']))
.build()
})
describe.skip('登录相关传统用例-跳过', function () {...})
describe('登录商户系统', function () {
this.timeout(50000)
it('登录跳转', async () => {
await driver.get('http://dev.company.home.ke.com:1990/login') // 打开商户登录页面
await driver.findElement(By.xpath('//*[@id="root"]/div/div[2]/div/ul/li[1]/input')).sendKeys(666666) // 输入用户名
await driver.findElement(By.xpath('//*[@id="root"]/div/div[2]/div/ul/li[2]/input')).sendKeys(666666) // 输入密码
await driver.findElement(By.xpath('//*[@id="root"]/div/div[2]/div/div/button')).click() // 点击登录按钮
const currentTitle = await driver.getTitle()
await driver.sleep(2000)
expect(currentTitle).to.equal('商户管理系统')
})
})
after(() => {
// 在本区块的所有测试用例之后执行
driver.quit()
})
})
使用mocha执行e2e测试用例生成如下测试报告

下图是selenium-webdriver驱动chrome浏览器自动运行,进行功能测试


5.4.4 基准测试
假设当前需要测试正则表达式的test方法和字符串的indexOf方法的性能,我们通常会采用如下方法进行测试:让两个方法分别执行1000次,比较哪个耗时长。
// 判断某个字符串中是否存在特定字符,比较reg.test和str.indexOf性能
const testPerf = (count) => {
var now = new Date() - 1
var i = count
while (i--) {
/o/.test('Hello World!')
}
console.log(`test方法执行${count}次用时`, new Date() - 1 - now)
}
const indexOfPerf = (count) => {
var now = new Date() - 1
var i = count
while (i--) {
'Hello World!'.indexOf('o') > -1
}
console.log(`indexOf方法执行${count}次用时`, new Date() - 1 - now)
}
testPerf(1000)
indexOfPerf(1000)
测试结果如下,因为代码执行较快,两个方法执行1000次的时间都为零,无法准确判断代码执行效率

科学的统计方法是需要多次执行,对大量的执行结果进行采样,我们可以使用工具帮我们完成这件事,如下使用benchmark进行测试
// 判断某个字符串中是否存在特定字符,比较reg.test和str.indexOf性能
const Benchmark = require('benchmark')
const suite = new Benchmark.Suite()
// add test
suite.add('正则表达式test方法', function () {
/o/.test('Hello World!')
})
.add('字符串indexOf方法', function () {
'Hello World!'.indexOf('o') > -1
})
// add listeners
.on('cycle', function (event) {
console.log(String(event.target))
})
.on('complete', function () {
console.log('Fastest is ' + this.filter('fastest').map('name'))
})
// run async
.run({ 'async': true })
执行测试代码,结果如下,indexOf每秒执行的次数比test每秒执行的次数超出了一个数量级,所以indexOf性能更好

6 总结
梳理完单元测试、接口测试、功能测试、基准测试的具体实施方案后,结合自动化测试的特点我们可以得出以下结论:
前端要不要进行自动化测试,需要根据具体的项目特点进行判断,对于满足以下条件的代码可以进行自动化测试:
- 核心功能模块、函数
- 短期不会发生变化的UI组件
- 提供外部调用的接口
- 对方法性能进行基准测试
最后,要强调一点,我们的目标是保证代码健壮、可维护,提高开发效率,自动化测试只是一种手段。
7 参考资料
- 测试框架 Mocha:www.ruanyifeng.com/blog/2015/1…
- 代码覆盖率:www.ruanyifeng.com/blog/2015/0…
- selenium-webdriver:www.npmjs.com/package/sel…
- 基准测试:github.com/bestiejs/be…
- 前端工具:ashleynolan.co.uk/blog/fronte…