测一测你的代码:关于前端自动化测试

开篇:我们需要自动化测试吗

所谓自动化测试就是把人对软件的测试行为转化为由机器执行测试行为的一种实践,对于最常见的 GUI 自动化测试来讲,就是由自动化测试工具模拟之前需要人工在软件界面上的各种操作,并且自动验证其结果是否符合预期。

你是不是有点小激动?这似乎开启了用机器代替重复手工劳动的自动化时代,你可以从简单重复劳动中解放出来了。

自动化测试的本质是先写一段代码,然后去测试另一段代码,所以实现自动化测试用例本身属于开发工作。因此我们有必要去尝试一把

金坷垃,好处都有啥

下面例举了几个自动化测试的好处:

  • 自动化测试可以替代大量的手工机械重复性操作,测试工程师可以把更多的时间花在更全面的用例设计和新功能的测试上;
    • 自动化测试可以大幅提升回归测试的效率,非常适合敏捷开发过程;
    • 自动化测试可以高效实现某些手工测试无法完成或者代价巨大的测试类型,比如压力测试等;
    • 自动化测试还可以保证每次测试执行的操作以及验证的一致性和可重复性,避免人为的遗漏或疏忽。

代码测试的维度

代码测试的依据其实主要有两个方面

  • 测试合格率: 所有的测试样例(TestCase)在运行过程中得到的结果是否符合断言
  • 代码覆盖率: 在一个或多个case在执行测试的过程中,测试目标的代码是否都执行到了

测试合格率

这个其实比较好理解,测试合格率是最直观的一种测试维度,从某种角度来说,合格率越高能保证我们的产品在大部分情况下可以完成预期的操作,但是这并不保险,因为我们无从得知TestCase的数目或者范围是否合理。

例如这段代码

1
2
3
4
5
6
7
funtion option(a){
if(a > 10){
return a
}else {
return option(a)
}
}

在黑盒测试情况下,测试人员可能写了大量的TestCase:11,22,..., 但是很不幸,都是 > 10的测试样例,等到使用了这个方法的项目上线之后,这个方法意外输入了<=10的参数,凉凉,运行环境报错(嘿嘿,栈溢出警告)。 这里不难看出,单一看这个合格率这个维度确实很重要,但是很难保证完备性。接下来就来聊聊我们的覆盖率。

代码覆盖率

测试覆盖率是衡量测试完整性的一种手段:通过已执行代码的覆盖率,用于评测代码的可靠性和稳定性,可以及时发现没有被测试用例执行到的代码块,提前发现可能的逻辑错误。

代码覆盖率主要有四个指标

  • Statements: 语句覆盖率,所有语句的执行率;

  • Branches: 分支覆盖率,所有代码分支如 if、三目运算的执行率;

  • Functions: 函数覆盖率,所有函数的被调用率;

  • Lines: 行覆盖率,所有有效代码行的执行率,和语句类似,但是计算方式略有差别

例如下面这个例子

1
2
3
4
5
6
7
const a = 10;
const b = 20;
if (a > b) {
console.log(a);
} else {
console.log(b)
};

对于上面的代码,可以得到这样一份覆盖率测试结果:

image-20210828234411355

代码中有5个语句(statement),执行了4个;有2个分支(branch),执行了1个;有0个函数,调用了0个;有5行代码,执行4行。

可能有细心的大佬已经发现了,你说两个分支、0个函数我能信,可是这明明有7行代码,为啥这里只统计到了5行?这里其实有一个误区,行覆盖率和语句覆盖率中的行数并不是指代码文件中的行数,而是可执行语句的行数,例如倒数第三行中的} else {和最后一行的}都属于JS提供的语法格式,并不是可执行语句,因此不会被计入。

另外,Statements和Lines为什么是一样的,他们两计算的差异在哪里。这里也解答一下,其实这两种覆盖率确实很相似,Lines的统计维度仅仅在起初的代码文件中, 而Statements则会在JS文件进行一次预编译后的代码进行统计, 当然统计的仍然是有效代码,像函数声明、变量提升这些额外的代码会被忽略。因此大部分情况下 Statements和Lines的统计数据是一致的。

举个例子🌰

对于下面的代码

1
2
3
for (let index = 1; index < 10; index += 1) {
console.log(index);
}

生成的覆盖率报告是这样的:Lines总数是2, OK这没有问题,确实是两行有效代码没问题, 而Statements 总数却达到了3。

image-20210829020439021

正是由于预编译后,for(let index = 1...)这种写法会在循环体的作用域内又声明并赋值一个index,所以上面那个例子中,Statements中的数量变成了3。

1
2
3
4
let index;
for ( index = 1; index < 3; index += 1) {
console.log(index);
}

而上面这种写法由于预编译后循环体内不会生出新的赋值index的语句,因此statements为2。

image-20210829022622795

这里不难看出,代码覆盖率和测试合格率两者相结合能够很好的测试出代码是否健壮。不过代码覆盖率不同与测试合格率那么严格,测试合格率是严格要求所有的case都能100%的通过,诚然拥有100%覆盖率的测试是优秀的,但是我们实际项目是复杂的,并不容易产出测试样例和验证手段,例如UI动画、文件系统的操作等等,这些都是很难进行断言的部分,因此通常是设定一个合格门槛就可以了(完美要求100%覆盖率太过于严苛,甚至会使开发和测试的投入成本本末倒置,不必盲目追求)

番外 - 前端代码覆盖率的计算是如何实现的

这里顺带讲一下前端JS代码覆盖率是如何实现的「此部分篇幅并非后文必备铺垫,可选择直接跳转到下一节」

目前市面上几乎大部分的Js测试框架中例如Mocha、Ava等使用的覆盖率测试都是基于一款名为Istanbul的开源Js代码覆盖率计算工具。简单说来Istanbul实现的基本方法是注入(Instrumentation)注入就是在被测代码中自动插入用于覆盖率统计的探针(Probe)代码,并保证插入的探针代码不会给原代码带来任何影响。

详细来说的话,Istanbul会将我们的源码构造成抽象语法数(AST)后,将各个维度标记的代码加入到树节点中,最后输出一个注入了标记的源码,执行后即可得到对应的覆盖率数据。

image-20210830121444196

接下来可以看一下一段代码在经过注入后是什么样的

这是一段源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
function sum(a, b) {
return a + b;
}

function main(foo) {
if (foo > 3) {
return sum(foo, 3);
} else {
return foo;
}
}

main(5);

在经过注入探针后的代码大致如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var cov_1pwyfn0t92 = (function() {
// 此处省略较多的代码,这里面返回的是一个计数器对象
})();

function sum(a, b) {
cov_1pwyfn0t92.f[0]++;
cov_1pwyfn0t92.s[0]++;
return a + b;
}


function main(foo) {
cov_1pwyfn0t92.f[1]++;
cov_1pwyfn0t92.s[1]++;
if (foo > 3) {
cov_1pwyfn0t92.b[1][0]++;
cov_1pwyfn0t92.s[2]++;
return sum(foo, 3);
} else {
cov_1pwyfn0t92.b[1][1]++;
cov_1pwyfn0t92.s[3]++;
return foo;
}
}
cov_1pwyfn0t92.s[4]++;
main(5);

可以看到最开始的源代码几乎被转换成了另一个样子,但原来的代码逻辑是不会改变的,只是注入了一些对原代码执行没有影响的计数语句,很明显这些计数代码就对应了各个维度的计数器:

cov_1pwyfn0t92 文件唯一计数对象
cov_1pwyfn0t92.s Statement 计数器
cov_1pwyfn0t92.b Branch 计数器
cov_1pwyfn0t92.f Function 计数器

lines可以通过将statements进行计算得出(去除statements中含的预编译时产生的有效代码)

后面就是执行这个代码并统计和输出覆盖率报告即可

image-20210829030904271

前端测试的类型

单元测试

单元测试是什么

单元测试(Unit Test以下简称 - UT)指的是对软件中的最小可测试单元在与程序其他部分相隔离的情况下进行检查和验证的工作,这里的最小可测试单元通常是指函数或者类,而在Vue领域还额外包括了组件级别的单元测试。

就像使用砖头建造房子一样,我们需要保证每块砖头的重量、长宽高参数等数据数据是否符合规范进行测试,用于确保每块砖都是ok的,不会出现空心砖等情况,从而保证用这块砖建造出来的房子都不会因为砖头的质量问题而倒塌。

我需要使用单元测试吗

在实际开发中哪些情况下你可能需要写前端UT? 来做一组判断题

  1. 你写的是个util类,是会被其他类调用的那种?
  2. 你写的是一个公共component,是会被其他工程调用的那种?
  3. 你写的是一个开源项目

如果以上3个问题有一个肯定回答,你都应该考虑写UT了

单元测试要关注什么

对于单元测试来说,保证其幂等性非常重要,所谓幂等就是在相同输入的前提下,其输出结果不会随外界因素而改变。

所以,对于函数式编程语言来说,写单元测试则是非常容易的事情,因为在函数式范式中,我们的函数都是纯函数,在范式层面上就已经约束了开发者写出幂等的程序,那么,在javascript领域,我们想要写出质量更高,对测试友好的代码的话,则需要尽可能的写出各种纯函数,从而保证幂等性。

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互

对于前端而言,其实还包含UI界面的幂等,如何更加高效的保证界面幂等,我们是可以借助jest的快照能力实现html结构级别的幂等验证或者通过gemini的离线截图能力来实现像素级的幂等验证。

单元测试中使用到的自动化技术

单元测试阶段的“自动化”内涵不仅仅指测试用例执行的自动化,还包含以下几个方面:

  1. 部分测试输入数据的自动化生成
  2. 自动桩代码的生成
  3. 测试覆盖率的自动统计与分析

在前端项目中使用单元测试

俗话说的好,“听君一席话,如听一席话”,光说不练假把式,因此我们实际在项目中使用单元测试的姿势究竟是什么样的呢。

image-20210829162309824

下文中会使用Jest来进行实践,来体验一番在 Vue + TS 项目中进行不同类型的单元测试。(具体配置流程在此不再赘述,如果有同学感兴趣的话,后期会专门写一篇在Vue项目中配置自动化测试环境的文章,挂到文末)

普通函数的UT

实际开发中,我们会抽出很多公共方法到utils中,供其他组件或者工具类进行消费,因此函数或者类方法的UT是最常见的。来一个简单的🌰

util/sqrt.ts文件中编写了一个带有中文报错提示的开平方根函数:

1
2
3
4
5
6
export function sqrt(x: number): number {
if (x < 0) {
throw new Error('负值没有平方根');
}
return Math.exp(Math.log(x) / 2);
}

函数本身并不秀, 因为我只是加了一个看起来舒适的报错信息,现在在util/__test__/sqrt.spec.ts中编写单元测试的代码

1
2
3
4
5
6
7
8
9
10
11
12
import { sqrt } from '../sprt';

describe('sqrt util test', () => {
// 测试用例
it('4的平方根应该等于2', () => {
expect(sqrt(4)).toEqual(2);
});

it('参数为负值时应该报错', () => {
expect(() => { sqrt(-1); }).toThrow('负值没有平方根');
});
});

测试代码简单说明

  • describe是作用就是声明一个将几个相关测试组合在一起的块
  • it是test的别名,可以看作是一个case的测试代码
  • expect会生成一个预期对象, 提供了很多断言方法,例如toEqual、toThrow等等,开发者还可以像全局expect中添加自定义的断言方法,详细可查看[ JestAPI ]

上述方法是Jest运行时会绑定到全局环境的方法,无须单独引入

在终端中执行jest --coverage, 便能获得测试的运行结果

image-20210829174619107

不难看出,我们所有的测试用例都已经通过了,并且代码覆盖率达到了100%。没错,单元测试就是这么简单!

Vue组件的UT

组件级的单元测试仅仅使用Jest是不够的,这里还需要引入VueTestUtil——Vue.js 的官方单元测试实用程序库(以下简称vtu),用于在测试代码使用Vue组件。老规矩,上🌰

image-20210829195420033

先来一个简单提示组件。这个组件的HTML结构中含有一个标题和关闭按钮, 并且可以传入类型字段,对应3种不同的配色。除此之外还提供了自动关闭的逻辑,传入自动关闭倒计时后提示组件会自动关闭。模版代码components/alert.vue如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<template>
<div class="alert__body" :class="type" v-if="isShow">
<span class="alert__inner" v-text="title"></span>
<button class="alert__button" @click="close">X</button>
</div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';

// eslint-disable-next-line no-shadow
const enum AlertType { SUCCESS = 'success', INFO = 'info', ERROR = 'error' }

@Component({})
export default class Alert extends Vue {
@Prop({
type: String,
default: AlertType.INFO,
}) // 提示类型,代表了三种不同的色彩风格
type!: AlertType;

@Prop({
type: String,
default: '我是弹窗',
}) // 提示框中的文本内容
title!: string;

@Prop({
type: Number,
default: 3,
}) // 自动关闭倒计时 小于等于0时永不关闭
closeTimeout!: number;

isShow = true;

mounted(): void {
this.autoClose();
}

// 关闭弹窗逻辑
close(): void {
this.isShow = false;
}

// 自动关闭逻辑
autoClose(): void {
if (this.closeTimeout > 0) {
setTimeout(() => {
this.close();
}, this.closeTimeout * 1000);
}
}
}
</script>


<style lang="less" scoped>
.alert__body {
...
&.success {
background-color: green;
}
&.error {
background-color: red;
}
&.info {
background-color: gray;
}
}

image-20210829194340500

不难看出,我们主要测试的case 有以下几点

  • 传入的title是否正常渲染在Dom中
  • 传入自动倒计时是否在指定时间后删除了该组件
  • 点击关闭按钮是否正常从Dom树中删除组件
  • 传入type后,组件是否使用了相应的css class

接下来依次编写自动化测试代码, 在components/__test__/alert.spec.ts中编写代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { mount } from '@vue/test-utils';
import Alert from '../alert.vue';
const TEXT = '我是弹窗';

describe('Alert.vue', () => {
it('render test & class', () => {
const wrapper = mount(Alert, {
propsData: {
title: TEXT,
},
});
const title = wrapper.find('.alert__inner').text();
expect(title).toEqual(TEXT);
const defaultClass = wrapper.find('.alert__body').classes();
expect(defaultClass).toContain('info');
});
});

为了便于理解测试代码, 这里先说说明一下从vtu中导出的mount方法。

mount方法接受两个参数,第一个是组件,第二个参数是一个配置对象,这里的propsData就是为组件传递了props参数.而mount返回了一个wrapper对象 , 这个对象包含已安装的组件或 vnode 以及测试组件或 vnode 的方法。

其实目前这段测试代码应该是比较明朗了,主要做了以下事项

  1. 通过mount方法创建了Alert组件,并且传递了title属性
  2. 为了验证title是否正确渲染到了dom上,通过wrapper提供的find方法来查找Dom元素, find入参选择器字符串语法和querySelector是一致的。最后利用wrapper包装了一层text方法来获取innerText属性,并进行验证。
  3. 而验证css的class是否成功渲染到dom上, 使用的是classes()方法,这个方法可以获取到实际dom的所有class,并以数组的形式返回。最后通过jest提供的验证数组内是否包含某个元素的断言方法.toContain进行验证。

最终的运行结果:

image-20210829210942571

其实对组件进行测试也并没有那么难对吧, 我们组件还有一个自动关闭的逻辑,这个case应该如何验证,先说一下思路

  • 创建一个指定closeTimeout属性的Alert组件
  • 创建后验证该组件中的isShow属性是否为true
  • 若干秒后验证isShow属性是否为false

ok, 有了思路之后我们不难发现,验证组件的isShow属性好做,但是如果在指定秒数后验证呢?如果在测试代码中写setTimeout显然太耗费测试时间了。年轻人遇到问题不要慌, 来看验证这个case的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jest.useFakeTimers();
describe('Alert.vue', () => {
it('it should close after closeTimeout ', () => {
const closeTimeout = 3;
const wrapper = mount(Alert, {
propsData: {
closeTimeout,
},
});
const vm = wrapper.vm as any;
expect(vm.isShow).toBeTruthy();
jest.runAllTimers();
expect(vm.isShow).toBeFalsy();
});
});

很明显,在代码第一行jest.useFakeTimers();这条语句可以声明当前单元测试中使用的计时器会变为虚拟计时器,通过执行同步代码jest.runAllTimers();,声明当前计时器中的代码已执行完毕。

而验证closeTimeout<=0时不会自动隐藏组件的case和上述代码同理

1
2
3
4
5
6
7
8
9
10
11
it('it should not close when closeTimeout is set to <= 0 ', () => {
const wrapper = mount(Alert, {
propsData: {
closeTimeout: 0,
},
});
const vm = wrapper.vm as any;
expect(vm.isShow).toBeTruthy();
jest.runAllTimers();
expect(vm.isShow).toBeTruthy();
});

上运行结果

image-20210829220812437

此时可以看到,我们的组件代码已经100%覆盖了,但是别忘了,我们还有按钮的点击事件没有测试,这个就相当简单了,上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('it should be able to close the alert by clicking close button', async () => {
const wrapper = mount(Alert, {
propsData: {
closeTimeout: 0,
},
});

// 查询关闭按钮dom
const closeBtn = wrapper.find('.alert__button');
// 断言关闭按钮存在
expect(closeBtn.exists()).toBe(true);
// 触发关闭按钮的click事件
await closeBtn.trigger('click');
// isShow变为false
expect((wrapper.vm as any).isShow).toBeFalsy();
});

运行结果:

image-20210829221917084

到这里,Vue组件级的单元测试告一断落,很多断言方法,还有直接在测试代码中改变vue data中的属性值,事实上vtu都提供了,如果感兴趣可以在vtu官方文档里查询。

上述的组件测试主要是通过js-dom在Node环境中模拟浏览器环境的去运行的,运行环境比较单一,并且浏览器是有差异化的,这就造成无法测试到某些边界情况。目前也有在浏览器中运行的前端测试框架,例如cypress.js, 下文中的端对端测试将会进行介绍。

端到端测试(E2E测试)

端到端测试是什么

E2E,是“End to End”的缩写,可以翻译成“端到端”测试。它模仿用户,从某个入口开始,逐步执行操作,直到完成某项工作。

通常情况下,单元测试确实能够帮助我们发现大部分的问题,但是在复杂的前端交互或者可视化项目测试中,单纯的单元测试并不能满足真实测试需求,这时候 e2e 测试的优势就显得尤其显著。

优势主要包括:

  1. 模拟用户行为
  2. 模拟真实运行环境
  3. 截屏比对
  4. 操控运行时环境

E2E测试可以被归类为集成测试,在Vue项目中和单元测试的关系大致可以理解为:

image-20210830170828660

  • 组件内部的逻辑由单元测试管控
  • 多个组件之间的联动,并且能够从用户角度形成完整操作链的逻辑,由E2E测试管控

在前端项目中使用E2E测试

在介绍之前先说一下适用E2E测试的一种敏捷开发方式 TDD

TDD 即测试驱动开发(Test-Driven Development),简单来说就是先写好测试case(由于代码还没有开始写,所以测试一定是不通过的),然后迅速开发代码并通过测试(达到代码“可用”的目标),由于我们要追求代码的质量,因此需要将代码重构(追求“简洁”目标),重构后如果测试不通过则再修复代码是测试样例通过,依此循环。

image-20210830172016984

大概理解后,我们就用TDD的思路来开发一个小案例

由于需要使用E2E测试,因此将会使用另外一款测试框架cypress, 它不但提供了E2E测试的能力,还有组件测试的能力。语法结构和Jest也会有一些差异

img

模拟了一个评论的功能模块构成

  • 一个输入框
  • 一个文本展示区域
  • 一个按钮,按钮点击后会清空输入框,并在文本展示区域内显示

根据TDD规范,先写测试用例cypress/integration/create_a_message.cy.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe('create a message', () => {
it('display a message in list', () => {
// 访问本地项目运行网址
cy.visit('http://localhost:8080');
// 查询输入框,并往输入框中填写字符串 new Message
cy.get("[data-test='messageText']").type('new Message');
// 查询send按钮,并执行点击事件
cy.contains('send').click();
// 查询输入框,并断言value为空
cy.get('[data-test="messageText"]').should('have.value', '');
// 查询当前页面是否存在innerText为new Message的元素
cy.contains('new Message');
});
});

运行cypress得到失败的结果

image-20210830175107760

然后在不考虑其他因素的情况快速完成开发

编写App.vue,完成基本逻辑,并通过e2e测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
<div id="app">
<h2>Code Test Demo</h2>
<div>
<input type="text" data-test="messageText" v-model="message">
<button @click="send">send</button>
</div>
<ul>
<li v-for="(message, index) in messageList" :key="index">{{message}}</li>
</ul>
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';

@Component({})
export default class App extends Vue {
message = ''

send(): void {
this.messageList.push(this.message);
this.message = '';
}

messageList:string [] = [];
}
</script>

image-20210830175237985

由于不能把内容都耦合到页面里,所有要重构

拆分为

  • messageForm.vue 包括输入框和按钮,当点击按钮时清空输入框并提交输入数据
  • messageList.vue 用于显示从messageForm.vue提交的信息

创建好MessageForm.vue后,先写messageForm的测试case, 这里就属于组件级别的单元测试了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { mount } from '@cypress/vue';
import MessageForm from '../messageForm.vue';

describe('MessageForm.vue', () => {
it('should emit "send" event', () => {
// 创建组件
mount(MessageForm);
// 查询输入框dom节点,并输入message
cy.get('[data-test="messageText"]').type('message');
// 查询组件,并执行点击事件
cy.contains('send').click().then(() => {
//断言当前组件是否$emmit('send'), 并传递message字符串
expect(Cypress.vueWrapper.emitted('send')?.[0][0]).equal('message');
});
});
});

测试不通过

image-20210830180010910

开始写messageForm.vue里的内容,保证测试通过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<input type="text" data-test="messageText" v-model="message">
<button @click="send">send</button>
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';

@Component({})
export default class MessageForm extends Vue {
message = ''

send(): void {
const { message } = this;
this.message = '';
this.$emit('send', message);
}
}
</script>

测试结果通过

img

同理完成messageList.vue的重构。这里不在赘述。

最后将重构后的代码重新运行测试,查看是否通过用例。通过至此基于E2E和单元测试的TDD开发流程结束

img

总结

总的来说,自动化测试还是一把“双刃剑”,虽然它可以从一定程度上解放测试工程师的劳动力和开发者在重构时的心智成本,完成一些人工无法实现的测试,但并不适用于所有的测试场景,如果维护自动化测试的代价高过了节省的测试成本,那么在这样的项目中推进自动化测试就会得不偿失。

是否要引入自动化测试,属于决策问题。但是作为我们开发者来说,尝试去了解一些自动化测试领域的工具和知识,对我们的收益还是很大的。

0%