什么是测试
维基百科的定义:
在规定的条件下对程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。
也可以这样理解:测试的作用是为了提高代码质量和可维护性。
- 提高代码质量:测试就是找 BUG,找出 BUG,然后解决它。BUG 少了,代码质量自然就高了。
- 可维护性:对现有代码进行修改、新增功能从而造成的成本越低,可维护性就越高。
什么时候写测试
如果你的程序非常简单,可以不用写测试。例如下面的程序,功能简单,只有十几行代码:
function add(a, b) {return a + b}function sum(data = []) {let result = 0data.forEach(val => {result = add(result, val)})return result}console.log(sum([1,2,3,4,5,6,7,8,9,10])) // 55
如果你的程序有数百行代码,但封装得很好,完美的践行了模块化的理念。每个模块功能单一、代码少,也可以不用写测试。
如果你的程序有成千上万行代码,数十个模块,模块与模块之间的交互错综复杂。在这种情况下,就需要写测试了。试想一下,在你对一个非常复杂的项目进行修改后,如果没有测试会是什么情况?你需要将跟这次修改有关的每个功能都手动测一边,以防止有 BUG 出现。但如果你写了测试,只需执行一条命令就能知道结果,省时省力。
测试类型与框架
测试类型有很多种:单元测试、集成测试、白盒测试…
测试框架也有很多种:Jest、Jasmine、LambdaTest…
本章将只讲解单元测试和 E2E 测试(end-to-end test 端到端测试)。其中单元测试使用的测试框架为 Jest,E2E 使用的测试框架为 Cypress。
Jest
安装
npm i -D jest
打开 package.json 文件,在 scripts 下添加测试命令:
"scripts": {"test": "jest",}
然后在项目根目录下新建 test 目录,作为测试目录。
单元测试
什么是单元测试?维基百科中给出的定义为:
单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。
从前端角度来看,单元测试就是对一个函数、一个组件、一个类做的测试,它针对的粒度比较小。
单元测试应该怎么写呢?
- 根据正确性写测试,即正确的输入应该有正常的结果。
- 根据错误性写测试,即错误的输入应该是错误的结果。
对一个函数做测试
例如一个取绝对值的函数 abs(),输入 1,2,结果应该与输入相同;输入 -1,-2,结果应该与输入相反。如果输入非数字,例如 "abc",应该抛出一个类型错误。
// main.jsfunction abs(a) {if (typeof a != 'number') {throw new TypeError('参数必须为数值型')}if (a < 0) return -areturn a}// test.spec.jstest('abs', () => {expect(abs(1)).toBe(1)expect(abs(0)).toBe(0)expect(abs(-1)).toBe(1)expect(() => abs('abc')).toThrow(TypeError) // 类型错误})
现在我们需要测试一下 abs() 函数:在 src 目录新建一个 main.js 文件,在 test 目录新建一个 test.spec.js 文件。然后将上面的两个函数代码写入对应的文件,执行 npm run test,就可以看到测试效果了。

对一个类做测试
假设有这样一个类:
class Math {abs() {}sqrt() {}pow() {}...}
我们必须把这个类的所有方法都测一遍。
test('Math.abs', () => {// ...})test('Math.sqrt', () => {// ...})test('Math.pow', () => {// ...})
对一个组件做测试
组件测试比较难,因为很多组件都涉及了 DOM 操作。
例如一个上传图片组件,它有一个将图片转成 base64 码的方法,那要怎么测试呢?一般测试都是跑在 node 环境下的,而 node 环境没有 DOM 对象。
我们先来回顾一下上传图片的过程:
- 点击
<input type="file" />,选择图片上传。 - 触发
input的change事件,获取file对象。 - 用
FileReader将图片转换成 base64 码。
这个过程和下面的代码是一样的:
document.querySelector('input').onchange = function fileChangeHandler(e) {const file = e.target.files[0]const reader = new FileReader()reader.onload = (res) => {const fileResult = res.target.resultconsole.log(fileResult) // 输出 base64 码}reader.readAsDataURL(file)}
上面的代码只是模拟,真实情况下应该是这样使用:
document.querySelector('input').onchange = function fileChangeHandler(e) {const file = e.target.files[0]tobase64(file)}function tobase64(file) {return new Promise((resolve, reject) => {const reader = new FileReader()reader.onload = (res) => {const fileResult = res.target.resultresolve(fileResult) // 输出 base64 码}reader.readAsDataURL(file)})}
可以看到,上面的代码出现了 window 的事件对象 event、FileReader。也就是说,只要我们能够提供这两个对象,就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象:
// 重写 Filewindow.File = function () {}// 重写 FileReaderwindow.FileReader = function () {this.readAsDataURL = function () {this.onload&& this.onload({target: {result: fileData,},})}}
然后测试可以这样写:
// 提前写好文件内容const fileData = 'data:image/test'// 提供一个假的 file 对象给 tobase64() 函数function test() {const file = new File()const event = { target: { files: [file] } }file.type = 'image/png'file.name = 'test.png'file.size = 1024it('file content', (done) => {tobase64(file).then(base64 => {expect(base64).toEqual(fileData) // 'data:image/test'done()})})}// 执行测试test()
通过这种 hack 的方式,我们就实现了对涉及 DOM 操作的组件的测试。我的 vue-upload-imgs 库就是通过这种方式写的单元测试,有兴趣可以了解一下(测试文件放在 test 目录)。
测试覆盖率
什么是测试覆盖率?用一个公式来表示:代码覆盖率 = 已执行的代码数 / 代码总数。Jest 如果要开启测试覆盖率统计,只需要在 Jest 命令后面加上 --coverage 参数:
"scripts": {"test": "jest --coverage",}
现在我们用刚才的测试用例再试一遍,看看测试覆盖率。
// main.jsfunction abs(a) {if (typeof a != 'number') {throw new TypeError('参数必须为数值型')}if (a < 0) return -areturn a}// test.spec.jstest('abs', () => {expect(abs(1)).toBe(1)expect(abs(0)).toBe(0)expect(abs(-1)).toBe(1)expect(() => abs('abc')).toThrow(TypeError) // 类型错误})

上图表示每一项覆盖率都是 100%。
现在我们把测试类型错误的那一行代码注释掉,再试试。
// test.spec.jstest('abs', () => {expect(abs(1)).toBe(1)expect(abs(0)).toBe(0)expect(abs(-1)).toBe(1)// expect(() => abs('abc')).toThrow(TypeError)})

可以看到测试覆盖率下降了,为什么会这样呢?因为 abs() 函数中判断类型错误的那个分支的代码没有执行。
// 就是这一个分支语句if (typeof a != 'number') {throw new TypeError('参数必须为数值型')}
覆盖率统计项
从覆盖率的图片可以看到一共有 4 个统计项:
- Stmts(statements):语句覆盖率,程序中的每个语句是否都已执行。
- Branch:分支覆盖率,是否执行了每个分支。
- Funcs:函数覆盖率,是否执行了每个函数。
- Lines:行覆盖率,是否执行了每一行代码。
可能有人会有疑问,1 和 4 不是一样吗?其实不一样,因为一行代码可以包含好几个语句。
if (typeof a != 'number') {throw new TypeError('参数必须为数值型')}if (typeof a != 'number') throw new TypeError('参数必须为数值型')
例如上面两段代码,它们对应的测试覆盖率就不一样。现在把测试类型错误的那一行代码注释掉,再试试:
// expect(() => abs('abc')).toThrow(TypeError)
第一段代码对应的覆盖率:

第二段代码对应的覆盖率:

它们未执行的语句都是一样,但第一段代码 Lines 覆盖率更低,因为它有一行代码没执行。而第二段代码未执行的语句和判断语句是在同一行,所以 Lines 覆盖率为 100%。
TDD 测试驱动开发
TDD(Test-Driven Development) 就是根据需求提前把测试代码写好,然后根据测试代码实现功能。
TDD 的初衷是好的,但如果你的需求经常变(你懂的),那就不是一件好事了。很有可能你天天都在改测试代码,业务代码反而没怎么动。
所以 TDD 用不用还得取决于业务需求是否经常变更,以及你对需求是否有清晰的认识。
E2E 测试
端到端测试,主要是模拟用户对页面进行一系列操作并验证其是否符合预期。本章将使用 Cypress 讲解 E2E 测试。
Cypress 在进行 E2E 测试时,会打开 Chrome 浏览器,然后根据测试代码对页面进行操作,就像一个正常的用户在操作页面一样。
安装
npm i -D cypress
打开 package.json 文件,在 scripts 新增一条命令:
"cypress": "cypress open"
然后执行 npm run cypress 就可以打开 Cypress。首次打开会自动创建 Cypress 提供的默认测试脚本。


点击右边的 Run 19 integration specs 就会开始执行测试。

第一次测试
打开 cypress 目录,在 integration 目录下新建一个 e2e.spec.js 测试文件:
describe('The Home Page', () => {it('successfully loads', () => {cy.visit('http://localhost:8080')})})
运行它,如无意外应该会看到一个测试失败的提示。

因为测试文件要求访问 http://localhost:8080 服务器,但现在还没有。所以我们需要使用 express 创建一个服务器,新建 server.js 文件,输入以下代码:
// server.jsconst express = require('express')const app = express()const port = 8080app.get('/', (req, res) => {res.send('Hello World!')})app.listen(port, () => {console.log(`Example app listening at http://localhost:${port}`)})
执行 node server.js,重新运行测试,这次就可以看到正确的结果了。

PS: 如果你使用了 ESlint 来校验代码,则需要下载 eslint-plugin-cypress 插件,否则 Cypress 的全局命令会报错。下载插件后,打开 .eslintrc 文件,在 plugins 选项中加上 cypress:
"plugins": ["cypress"]
模仿用户登录
上一个测试实在是有点小儿科,这次我们来写一个稍微复杂一点的测试,模仿用户登录:
- 用户打开登录页
/login.html - 输入账号密码(都是
admin) - 登录成功后,跳转到
/index.html
首先需要重写服务器,修改一下 server.js 文件的代码:
// server.jsconst bodyParser = require('body-parser')const express = require('express')const app = express()const port = 8080app.use(express.static('public'))app.use(bodyParser.urlencoded({ extended: false }))app.use(bodyParser.json())app.post('/login', (req, res) => {const { account, password } = req.body// 由于没有注册功能,所以假定账号密码都为 adminif (account == 'admin' && password == 'admin') {res.send({msg: '登录成功',code: 0,})} else {res.send({msg: '登录失败,请输入正确的账号密码',code: 1,})}})app.listen(port, () => {console.log(`Example app listening at http://localhost:${port}`)})
由于没有注册功能,所以暂时在后端写死账号密码为 admin。然后新建两个 html 文件:login.html 和 index.html,放在 public 目录。
<!-- login.html --><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>login</title><style>div {text-align: center;}button {display: inline-block;line-height: 1;white-space: nowrap;cursor: pointer;text-align: center;box-sizing: border-box;outline: none;margin: 0;transition: 0.1s;font-weight: 500;padding: 12px 20px;font-size: 14px;border-radius: 4px;color: #fff;background-color: #409eff;border-color: #409eff;border: 0;}button:active {background: #3a8ee6;border-color: #3a8ee6;color: #fff;}input {display: block;margin: auto;margin-bottom: 10px;-webkit-appearance: none;background-color: #fff;background-image: none;border-radius: 4px;border: 1px solid #dcdfe6;box-sizing: border-box;color: #606266;font-size: inherit;height: 40px;line-height: 40px;outline: none;padding: 0 15px;transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);}</style></head><body><div><input type="text" placeholder="请输入账号" class="account"><input type="password" placeholder="请输入密码" class="password"><button>登录</button></div><script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.0/axios.min.js"></script><script>document.querySelector('button').onclick = () => {axios.post('/login', {account: document.querySelector('.account').value,password: document.querySelector('.password').value,}).then(res => {if (res.data.code == 0) {location.href = '/index.html'} else {alert(res.data.msg)}})}</script></body></html>
<!-- index.html --><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>index</title></head><body>Hello World!</body></html>
login.html 静态页

index.html 静态页

然后把测试文件内容改一下:
describe('The Home Page', () => {it('login', () => {cy.visit('http://localhost:8080/login.html')// 输入账号密码cy.get('.account').type('admin')cy.get('.password').type('admin')cy.get('button').click()// 重定向到 /indexcy.url().should('include', 'http://localhost:8080/index.html')// 断言 index.html 页面是包含 Hello World! 文本cy.get('body').should('contain', 'Hello World!')})})
现在重新运行服务器 node server.js,再执行 npm run cypress,点击右边的 Run... 开始测试。

测试结果正确。为了统一脚本的使用规范,最好将 node server.js 命令替换为 npm run start:
"scripts": {"test": "jest --coverage test/","lint": "eslint --ext .js test/ src/","start": "node server.js","cypress": "cypress open"}
小结
本章所有的测试用例都可以在我的 github 上找到,建议把项目克隆下来,亲自运行一遍。
