Angular 深入浅出三十天:表单与测试 Day05 - 如何写出优秀的测试?

Day5

昨天介绍了开始撰写测试之前必须要知道的二三事之後,想必大家已经对如何开始撰写测试有了一些概念,但测试不是「有拜有保佑」,有写就好。所以我们除了要知道如何开始撰写测试之外,也要知道如何写出优秀的测试

什麽是优秀的测试?

我认为要优秀的测试会具备以下三个特质:

  • 值得信赖
  • 易於维护
  • 可读性高

值得信赖

虽说我们写测试的目的是为了证明我们的程序码没有问题,但不代表我们的测试程序码值得信赖。

换句话说,如果我们写出的测试有问题,怎麽证明我们的程序码没问题?因此,如何撰写出令人值得信赖的程序码就是一个很重要的课题。

易於维护

测试跟我们的程序码同样需要维护,而通常这会是很多人之所以「没办法」写测试的原因,每当需求有变动且时间紧迫、资源短缺的情况下,测试就会被抛弃。

但如果我们能够撰写出易於维护的测试,就算时间紧迫、资源短缺,也能够持续让测试保护我们的程序码。

可读性高

优秀的测试程序码,是可以当成说明书来看的。透过阅读测试程序码,我们可以很快地了解被测试的程序具备了哪些功能、要怎麽使用。而且如果测试有问题,我们也能够可以用最短的时间发现问题的根源。

甚至可以这麽说:一旦测试程序失去了可读性,也不用想它能够多易於维护与多值得信赖了。

因此,要如何让我们的测试具备上述三个特质呢?

撰写值得信赖的测试

我认为要撰写出值得信赖的测试要从以下几个方向着手:

  • 避免在测试中里写逻辑
  • 每次只测试一个关注点
  • Code review

避免在测试中里写逻辑

我们写测试是用来验证我们程序中的逻辑是否正确,一旦我们在写测试的时候也有逻辑,那是不是还要写其他的程序来验证我们的测试?在测试里,我们不关心过程,只要结果,所以我们不需要在测试里面写逻辑,任何的 switchif-elsefor/while looptry-catch 甚至是四则运算都不应该出现在测试里,直接把结果写上去即可。

每次只测试一个关注点

很多时候在我们的程序里同时做很多事情,这些事情就是我们要测试、验证的关注点

以我们前面撰写过的程序码来举例:

accountValueChange(accountControl: FormControl): void {
  this.account = accountControl.value;
  this.validationCheck(accountControl.errors, 'account');
}

这个函式做了两件事情:

  1. accountControl 的值指定给 account
  2. accountControlerrors 来判断要将什麽样子的错误讯息指定给 accountErrorMessage

程序码请参考第二天的文章:Template Driven Forms 实作 - 以登入为例

如果我们将这两件事情的验证都写在同一个测试案例里,当测试执行时,一旦第一件事情有错,就不会再验证第二件事情。

如此一来,我们怎麽知道第二件事情到底是对还是错?

所以当我们在测试这个函式时,就至少要用两个测试案例来验证上述做的两件事情,以保证我们的测试案例有确实测试到每一件事情。

Code review

有的时候我们自己一个人闷着头写,很容易沉浸在自己的世界、无法发现自己的错误,这时候我们就需要别人来帮忙我们用更客观一点的角度来发现我们的不足。

其实帮你 Code review 的人不用一定是比你厉害的人,古语有云:「三人必有我师焉」,每个人都是独特的,很多时候你没发现的错误、你没想到的问题、你没有过的想法,都可以在这时候互相交流,就算帮你 Code review 的人比你差,这也是一个教他的好时机。

撰写易於维护的测试

要撰写出易於维护的测试也一样可以从以下几个方向着手:

  • 只测试公开的方法
  • 测试也需要重构
  • 测试隔离
  • 比较物件

只测试公开的方法

一般来说,我们会将方法宣告为 private 或是 protected 时,一定是基於很多设计上或安全上的考量,所以我们也只会测试公开的方法。而且宣告为 private 或是 protected 的方法一定不会单独存在,它们一定会被某个公开方法呼叫(如果没有就表示这个方法根本就没人在使用,可以删掉了),所以当我们测试公开方法时,一定会测到那个被呼叫到的 private 或是 protected 的方法。

这时一定会有人问说:「那我真的很想要测试那个宣告为 private 或是 protected 的方法的话要怎麽办?」。

如果真的很想要测试那个宣告为 private 或是 protected 的方法,我们可以:

  1. 直接将该方法改为公开方法
  2. 将该方法抽到新的类别里
  3. 把方法改成静态方法

我个人比较偏好第二种跟第三种,因为这样可以让抽出来的这些方法可以被共用,在後续维护上也比较弹性。

测试也需要重构

正如本文一开始所说的,程序码需要维护,测试也需要维护;同样地,程序码需要重构,测试也需要。

不过测试的重构跟一般程序码重构的重点稍稍有点不一样,虽然大体上一样是要减少重复的程序码,但前面小节有提到「不要在测试里写逻辑」,以及後续会提到「动作与验证要分开」以提升可读性,所以在重构时要特别注意。

测试隔离

想想看,你的测试有没有以下的情况:

  1. 需要以某种顺序执行
  2. 会在测试案例里呼叫其他的测试案例
  3. 没有重设共用的属性、资料或者是状态

如果你的测试有以上任何一种情况,都表示你没有做好测试隔离

测试隔离这名字听起来很专业,其实讲白话一点就是让每个测试案例都是独立的,不跟其他的测试案例有依赖、或是顺序上的关系。每一个测试案例都要能单独运作,每一个测试案例都要从初始化开始,一直到验证完、清除或是还原状态为止,如此才不会影响到其他的测试案例。

撰写可读性高的测试

那到底要怎麽样撰写可读性高的测试呢?其实大致上就跟我们开发的时候所要求的差不多,毕竟开发者写的程序码并不是给电脑看的,而是给人看的。

所以除了 Clean Code 一书里提到的部分之外,对测试来说还需要注意以下两点:

  • 测试案例与测试集合的命名
  • 把验证和操作分开

测试案例与测试集合的命名

好的测试案例与测试集合的命名,可以让我们在读测试程序码或是测试结果时达到事半功倍的效果。举例来说,如果我们要测试登入系统的帐号栏位,一个不好的测试案例与测试集合的命名可能会是这样子的:

describe('LoginComponent', () => {

  it('Test account input - positive', () => {
    // ...
  });

  it('Test account input - negative', () => {
    // ...
  });

});

虽然可以知道这两个测试是一个是验证正向的情境,另一个是验证负向的情境,但实际上还要去细看测试案例里面程序码在写什麽才会知道当下这个测试案例验证的是什麽样的情境,可读性较差。

而好的测试案例与测试集合的命名可能会是这样子的:

describe('LoginComponent', () => {

  describe('accountValueChange', () => {

    it('should set value into property "account"', () => {
      // ...
    });

    it('should assign the error message "此栏位必填" to property "accountErrorMessage" when the value is the empty string', () => {
      // ...
    });

    it('should assign the error message "格式有误,请重新输入" to property "accountErrorMessage" when the value is not the correct pattern', () => {
      // ...
    });

    it('should assign the empty string to property "accountErrorMessage" when the value is the correct pattern', () => {
      // ...
    });
  });

});

有没有觉得这样比较好读呢?

语言当然不一定要用英文啦,用中文也行,看团队、主管或者是公司的规范。

把验证和操作分开

为了可读性,让别人可以很好阅读且很快速地理解我们所写的内容,所以我们不会为了节省程序码的空间,而把程序码都挤在一起,导致看的人还要去动脑思考,降低效率。

例如我们要验证登入系统的帐号栏位在值改变时,有没有将 input 栏位的值指派给 Component 的属性 account ,所以我们有程序码可能会这样子写:

it('should assign the value to property "account"', () => {
  const accountControl = new FormControl('[email protected]');
  component.accountValueChange(accountControl);
  expect(component.account).toBe(accountControl.value);
});

乍看之下其实没什麽太大的问题,也不是很难的程序码,但如果这样写会更好一点:

it('should assign the value to property "account"', () => {
  const account = '[email protected]';
  const accountControl = new FormControl(account);
  component.accountValueChange(accountControl);
  expect(component.account).toBe(account);
});

又或者是这样:

it('should assign the value to property "account"', () => {
  const accountControl = new FormControl('[email protected]');
  component.accountValueChange(accountControl);
  const account = accountControl.value;
  expect(component.account).toBe(account);
});

简单来说就是一步一步来,将动作跟验证分开,减少一些阅读时的负担,会让整个程序码更好阅读。

此外,在撰写测试时,有个 3A 原则的方式非常推荐大家使用。

3A 原则

这是在测试的世界里,非常着名的方法。可以说是只要照着这个方法写,满简单就能写出不错的测试。

而这个 3A 分别指的是:

  • Arrange - 准备物件或者是进行必要的前置作业。
  • Act - 实际执行、操作物件。
  • Assert - 进行结果验证

以上面的程序码为例, 3A 是这样分的:

it('should assign the value to property "account"', () => {
  // Arrange
  const account = '[email protected]';
  const accountControl = new FormControl(account);
  // Act
  component.accountValueChange(accountControl);
  // Assert
  expect(component.account).toBe(account);
});

这样看起来是不是更好读了呢?

虽然已经说了那麽多,但当程序已经实作好之後再来补测试其实是还满辛苦的,因此有一种开发方式叫做测试驱动开发

测试驱动开发

测试驱动开发,也就是所谓的 TDD (Test-driven development)

这个方式有一个流程,如下图所示:

心法

  1. 一开始要先写测试不实作程序码,这时测试会是红灯的状态
  2. 只实作足以让测试通过的程序码,这时测试就会通过变成绿灯
  3. 当反覆这样子做了几次之後,实作的程序码变多了可能会需要重构
  4. 重构完之後,如果测试变成了红灯,我们就再调整实作使其变成绿灯
  5. 重复循环这个过程

这样子的作法有满多好处的,像是:

  • 测试跟开发同步进行,有多少测试就写多少程序码
  • 由於测试先行,所以写出来的程序码都很好被测试
  • 由於有测试保护,在不断重构的过程中并不会出现改 A 坏 B 的情况
  • 由於会不断地重构,所以写出来的程序码会很好维护

虽然听起来很简单、好处很多,但在这流程中还是要注意以下三点:

  • 绝不跳过重构
  • 尽快变绿
  • 出错後放慢脚步

此外,我建议大家在写按照这个方式开发时,注意以下几件事情:

  • 编写测试时就仅仅关注测试,不想去如何实现
  • 先以调用方的角度来调用这块代码,并且从调用方的角度说出所期望的结果
  • 在编写某个功能的代码之前先编写测试代码,然後只编写使测试通过的功能代码
  • 所有的实现都是测试「逼」出来的,所有的实现代码都是为了让测试通过而编写的

本日小结

今天的重点主要是分享何谓优秀的测试如何撰写出优秀的测试这两点上,後面所分享测试驱动开发是提供一种更好写测试的开发方法给大家参考。

虽然我已经将如何写测试、如何写出好的测试都分享给大家了,但罗马不是一天造成的,没有人一开始就能写得出很好的测试。唯有不断地练习与学习,才能越写越轻松、越写越快乐。

总之,坐而言不如起而行,撰写测试对於专业的软件工程师来说绝对是一件利大於弊的事情,因此,从今天就开始写测试吧!

此外,非常推荐大家阅读书籍:「单元测试的艺术」,里面对於「什麽是优秀的测试」与「如何撰写优秀的测试」的部份会讲得更加详细与完整。

对於我今天所分享的部份,如果我有讲错或是大家有任何想要补充的部分,都非常欢迎留言在下面或讯息我让我知道噢!


<<:  CSS微动画 - Loading来了!七彩霓虹灯

>>:  [Day 05] tinyML与卷积神经网路(CNN)

30天程序语言研究

今天是30天程序语言研究的第八天,研究的语言一样是python,今天主要学习的是for回圈和二维阵列...

DAY1 学习动机与选择原因

这是一个在疫情期间无法去学校上课且刚放暑假的我, 为了自己的目标和理想所以努力的学习, 在完成了前一...

学习Python纪录Day6 - String type和Container type的运算子

String type和Container type的运算子 连接运算子 重复运算子 成员运算子 关...

[18] [烧瓶里的部落格] 08. 撰写测试

写单元测试可以检查程序是否按预期执行,Flask 可以模拟发送请求并回传资料 应当尽可能多进行测试,...

Proxmox VE 安装容器:Ubuntu 20.04

在前面我们用了很多的篇幅讲述 Proxmox VE 的客体虚拟机管理与使用,不过 Proxmox ...