共计 8655 个字符,预计需要花费 22 分钟才能阅读完成。
什么是测试
维基百科的定义:
在规定的条件下对程序进行操作,以发现程序谬误,掂量软件品质,并对其是否能满足设计要求进行评估的过程。
也能够这样了解:测试的作用是为了进步代码品质和可维护性。
- 进步代码品质:测试就是找 BUG,找出 BUG,而后解决它。BUG 少了,代码品质天然就高了。
- 可维护性:对现有代码进行批改、新增性能从而造成的老本越低,可维护性就越高。
什么时候写测试
如果你的程序非常简单,能够不必写测试。例如上面的程序,性能简略,只有十几行代码:
function add(a, b) {return a + b}
function sum(data = []) {
let result = 0
data.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.js
function abs(a) {if (typeof a != 'number') {throw new TypeError('参数必须为数值型')
}
if (a < 0) return -a
return a
}
// test.spec.js
test('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.result
console.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.result
resolve(fileResult) // 输入 base64 码
}
reader.readAsDataURL(file)
})
}
能够看到,下面的代码呈现了 window 的事件对象 event
、FileReader
。也就是说,只有咱们可能提供这两个对象,就能够在任何环境下运行它。所以咱们能够在测试环境下加上这两个对象:
// 重写 File
window.File = function () {}
// 重写 FileReader
window.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 = 1024
it('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.js
function abs(a) {if (typeof a != 'number') {throw new TypeError('参数必须为数值型')
}
if (a < 0) return -a
return a
}
// test.spec.js
test('abs', () => {expect(abs(1)).toBe(1)
expect(abs(0)).toBe(0)
expect(abs(-1)).toBe(1)
expect(() => abs('abc')).toThrow(TypeError) // 类型谬误
})
上图示意每一项覆盖率都是 100%。
当初咱们把测试类型谬误的那一行代码正文掉,再试试。
// test.spec.js
test('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.js
const express = require('express')
const app = express()
const port = 8080
app.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.js
const bodyParser = require('body-parser')
const express = require('express')
const app = express()
const port = 8080
app.use(express.static('public'))
app.use(bodyParser.urlencoded({ extended: false}))
app.use(bodyParser.json())
app.post('/login', (req, res) => {const { account, password} = req.body
// 因为没有注册性能,所以假设账号密码都为 admin
if (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()
// 重定向到 /index
cy.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 上找到,倡议把我的项目克隆下来,亲自运行一遍。
参考资料
- 单元测试到底是什么?应该怎么做?- coolhappy 的答复
- Jest
- Cypress
- 代码覆盖率
带你入门前端工程 全文目录:
- 技术选型:如何进行技术选型?
- 对立标准:如何制订标准并利用工具保障标准被严格执行?
- 前端组件化:什么是模块化、组件化?
- 测试:如何写单元测试和 E2E(端到端)测试?
- 构建工具:构建工具有哪些?都有哪些性能和劣势?
- 自动化部署:如何利用 Jenkins、Github Actions 自动化部署我的项目?
- 前端监控:解说前端监控原理及如何利用 sentry 对我的项目履行监控。
- 性能优化(一):如何检测网站性能?有哪些实用的性能优化规定?
- 性能优化(二):如何检测网站性能?有哪些实用的性能优化规定?
- 重构:为什么做重构?重构有哪些手法?
- 微服务:微服务是什么?如何搭建微服务项目?
- Severless:Severless 是什么?如何应用 Severless?