聊聊优秀单元测试中的可靠性

1,224 阅读9分钟

单元测试在软件开发中的重要性不言而喻,但是在真实项目中,无论你怎么组织测试,无论添加了多少类型的测试,单元测试、集成测试,如果测试不能被信任、容易维护以及让其它人容易阅读,它们就不能带来任何价值。所以衡量一个测试是否是优秀的单元测试,有三个比较重要的支柱:

  • 可靠性 开发人员希望项目中运行的测试可靠,这样无论是在维护遗留项目还是新项目,能够大胆地修改或者重构代码,只要测试是可靠的,就能保证不破坏之前的功能。可靠的测试没有缺陷,而且测试正确的代码。
  • 可维护性 无法维护的测试是噩梦,它们会拖延项目进度,或者当工期紧张的时候测试就会被丢在一旁,没人想为新的功能增加测试。如果修改测试花费时间太多,开发人员会停止测试的维护和修复工作。
  • 可读性 测试不仅是写给自己看的,其它的人也需要阅读,可读性好的测试还能在测出问题的时候容易找到代码中的问题。失去阅读性,其它两个支柱:可靠性和可维护性也会很快倒塌。如果测试无法理解,测试的维护工作就会变得困难,也很难得到人们的信任。

下面从可靠性的角度分析一下,怎么样编写优秀的单元测试。

编写可靠的测试

可靠的测试有几个特征。当测试通过的时候,你能完全信任在该场景下,代码功能是完全没有问题的。简而言之,一个可靠的测试能让你觉得对项目代码完全掌控,就算出现什么问题,也能从容应对。下面是一些指导原则:

  • 决定何时删除或修改测试;
  • 测试中需要避免逻辑;
  • 每个测试只有一个关注点;
  • 将单元测试和集成测试分开;
  • 推动代码审查。

遵循了以上原则,你的测试会变得更加可靠,也能让测试在代码中持续发现真正的缺陷。

决定何时删除和修改测试

一旦测试写好了并且通过了,在正常情况下你不应该修改或者删除这些测试,这些测试是你的代码的保护网,它们能告诉你修改的代码是否已破坏当前的功能。话虽如此,但是有些情况下你可能还是要修改或者删除测试,所以你需要知道什么情况去这样做是合理的,下面是一些可能原因。

  • 项目缺陷 被测试的项目代码有缺陷,如果你修改了项目代码,导致已有的一个测试失败,就出现了一个缺陷,这时候你就必须修改对应的测试,让测试在现有的实现下通过。
  • 测试缺陷 如果测试本身就有缺陷,这时候你就必须得修复测试。众所周知,测试的缺陷有时很难发现,因为测试本应该正确的,你就搞不清到底是测试里的缺陷还是代码中的缺陷。TDD 可以对测试进行测试,这也是为什么很多人钟爱 TDD 的原因。
  • 语义或者API 变更 如果测试的代码语义发生变化,但是功能没有变化。比如下面这个例子:
test('Semantics Change', () => {
    const logan: LogAnalyzer = new LogAnalyzer()
  expect(logan.isValid('abc')).toBeFalsy()
})

LogAnalyzer 类的语义发生变化,在使用任何其它 API 之前必须调用 init 方法进行初始化。这时候就需要修改测试:

test('Semantics Change', () => {
    const logan: LogAnalyzer = new LogAnalyzer()
  logan.init()
  expect(logan.isValid('abc')).toBeFalsy()
})

这样由代码语义变化导致测试失败的问题,这也是大多数开发人员在编写和维护单元测试所面临的最糟糕的体验。当然如果你有很多测试针对的是 LogAnalyze 类,你可以使用一个工厂方法重构测试:

function createLogAnalyzer () {
    const logan: LogAnalyzer = new LogAnalyzer()
  logan.init()
  return logan
}
test('Semantics Change', () => {
    const logan: LogAnalyzer = createLogAnalyzer()
  expect(logan.isValid('abc')).toBeFalsy()
})

这样,如果 LogAnalyzer 语义发生变化,我们只需要修改工厂方法。

  • 冲突或者无效的测试 如果产品代码增加了一个新功能,和另一个测试有直接冲突,就发生了测试冲突的问题。这种情况下,测试没有发现缺陷,却发现了冲突的产品需求。这种情况下,就需要跟产品经理反馈这个问题。
  • 重命名或者重构测试 不可读的测试代码带来的麻烦比解决的问题更多,因为它会妨碍你理解测试、发现代码中的缺陷。如果你发现测试名含义不清或者令人误解,或者测试的可读性有待提高,就应该修改测试代码。
  • 删除重复测试 在开发团队中,可能会出现不同的开发者编写了多个测试,测试同一个功能的情况。当然重复的测试也有好处,测试越多越能发现问题,可以阅读测试时看到同一测试的不同实现方式或者语义。当然重复测试也有很多缺点: 1)维护一个功能的多个测试比较困难;2)测试质量参差不齐,需要全部审查才能保证正确性;3)一个问题可能导致多个测试失败;4)相似的测试必须使用不同的名字,否则测试会分散在各个类中。 所以,有时候删除重复的测试是必要的。

避免测试中的逻辑

随着测试中的逻辑代码增多,出现测试缺陷的几率几乎是指数倍地增加。测试应该尽可能简单,不要在测试中添加逻辑,包括其他的生成随机数、创建线程、读写文件操作,使得测试变成了小型的测试引擎。如果你的测试中包括了下面任何一个语句,你的测试就包含了不应该有的逻辑:

  • switch、if或者else语句;
  • forEach、for或者while循环; 甚至 try...catch 语句都不应该出现在测试中,它们可能会导致很多问题。
  • 测试难以理解或者阅读;
  • 测试难以重现;
  • 测试容易包含缺陷变得难以调试;
  • 难以命名测试,因为它执行了很多任务。

只测试一个关注点

一个测试关注点是一个工作单元的最终结果:一个返回值、系统状态的一个改变或者第三方对象的一个调用。如果你的单元测试中对多个对象进行了断言或者既测试了一个对象的返回值,又测试了系统其它状态的改变,那么你的测试就可能测试了多个关注点。测试多个关注点的问题在于,你怎么去命名这个测试比较合适,或者当第一个对象的断言失败了如何处理。

命名测试看似简单,但当你因为测试了多个关注点而必须取一个通用的名字,那就使得看你测试的人不得不去阅读测试的源码才能知道你的测试所测的功能,如果测试只有一个关注点事情就简单多了。

大多数测试框架在第一个断言失败后就不会执行后面的断言,这样你可能就没法及时发现其它可能存在的缺陷。有一个判断的方法是:如果第一个断言失败了,你还会关心后面的断言结果吗?如果关心,那么就应该分开多个测试。

把单元测试和集成测试分开

给单元测试创建一个单独的绿色安全区很重要,如果团队成员不能信任你的测试能够很容易地、稳定地执行,他们就不会运行这些测试。当你重构测试,使得测试能容易运行,结果稳定,测试区就会变得稳定可靠。在测试中创建一个绿色安全区,使得团队成员对你的测试更加有信心。而创建一个绿色安全区很简单,那就是隔离单元测试和集成测试,使得单元测试的代码中只保留那些结果稳定、容易运行、可重复执行的测试。

用代码审查确保代码覆盖率

如果你的测试代码覆盖率达到100%不能说明什么,如果没有做代码审查,也许一些测试连断言都没有,只是为了奔着覆盖率的目标,完全没有管测试的质量。如果100%的代码覆盖率加上代码审查能说明什么?说明优秀的测试为你的项目做了一张安全网,可以避免愚蠢的错误,同时大家在代码审查中分享知识,每个人都从中获益。

为了确保你的测试覆盖率,你需要经常通过一些工具查看你的代码覆盖率。在 Jest 中就很容易做到,只要在命令中加上 --coverage 参数就可以打印出覆盖率报告了。当然你也可以在代码中去掉一些代码,或者修改某个分支条件,看测试是否还能通过,如果测试依赖通过,说明你还需要补充或者完善你的测试。

总结

可靠性的测试才能让你的团队成员信任你的测试,才能保证测试持续发现代码中的问题。如果测试不可靠,没有什么人想运行测试,那么还不如不写测试,占用了开发时间,还没有带来任何收益。本文从5个原则介绍了如何编写可靠的测试,希望能给大家写出优秀的单元测试一点指导。