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

语言: CN / TW / HK

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

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

你是不是有点小激动?这似乎开启了用机器代替重复手工劳动的自动化时代,你可以从简单重复劳动中解放出来了。 自动化测试的本质是先写一段代码,然后去测试另一段代码,所以实现自动化测试用例本身属于开发工作。因此我们有必要去尝试一把

金坷垃,好处都有啥

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

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

代码测试的维度

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

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

测试合格率

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

例如这段代码

funtion option(a){  if(a > 10){    return a }else {    return option(a) } }

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

代码覆盖率

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

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

  • Statements: 语句覆盖率,所有语句的执行率;
  • Branches: 分支覆盖率,所有代码分支如 if、三目运算的执行率;
  • Functions: 函数覆盖率,所有函数的被调用率;
  • Lines: 行覆盖率,所有有效代码行的执行率,和语句类似,但是计算方式略有差别

例如下面这个例子

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的统计数据是一致的。

举个例子🌰

对于下面的代码

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。

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

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

这是一段源代码

function sum(a, b) {  return a + b; } ​ function main(foo) {  if (foo > 3) {    return sum(foo, 3); } else {    return foo; } } ​ main(5);

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

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文件中编写了一个带有中文报错提示的开平方根函数:

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

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

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如下

```

​ ​