从单元测试到集成测试 - GDCR 2019

本文是 2019 年全球编程静修日上海站系列文章的第二篇:

  1. 怎么在结对编程时快速达成共识?
  2. 单元测试和集成测试有什么区别?(本文)
  3. 怎么写出没有 if 语句的程序?

在上一篇总结中,我们回顾了本次编程静修的第一轮主题:「结对编程」。在这一轮活动中,大家遇到最多的问题是如何解决结对时思路上的冲突。通过分享、讨论,我们理解了思路冲突出现的原因和解决方案。

本次编程静修的第二轮主题是测试驱动开发(Test-Driven Development / TDD)。这一轮的难点在于如何选择合适的测试用例。大家在分享过程中都表示找不到合适的用例来驱动整个开发流程。遇到这样的问题又该怎么办呢?让我们看看这次的讨论又能擦出什么样的火花吧!

如何选择合适的测试颗粒度?

编写自动化测试用例是 TDD 中最重要的第一步。只有选择了合适颗粒度的测试用例,才能「驱动」我们的生产代码,往我们希望的方向发展。

在这一轮活动中,大多数同学都是直接将需求样例翻译成测试用例,最终导致测试的颗粒度太大。因为我们需要实现程序所需的所有逻辑,才能让一个样例测试通过。而在比较久的时间内,这个新增的测试都不会发挥太大的用处,也就难以「驱动」开发的流程,改善代码的设计。在分享回顾时,有的同学还指出了「测试用例较大,导致方法职责不清」、「测试用例失败后较难发现问题」等其他问题。可见,颗粒度太大的测试用例很难将我们从先写代码再写测试的旧模式中解放出来,自然也无法带来更多价值。

那么如何才能找到合适的颗粒度,避免测试用例太大的问题呢?

为了讨论这个问题,我们提到了「单元测试」和「集成测试」的概念:

单元测试
单元测试的目的是测试当前模块的正确性。因此,单元测试的覆盖范围仅限于一个单元(单元可以是类、模块、方法等)。而且单元测试对当前应用的其他模块不做引用,尽量只运行当前模块的代码,以减少其他模块的干扰。
集成测试

集成测试的目的是测试几个模块一起协同工作时的正确性。因此,集成测试的覆盖范围更高,可能包含若干个单元,甚至整个应用。解释完这些概念之后,再回过头看我们之前的问题,我们发现这些大颗粒度的测试都是集成测试,基本上覆盖了应用的所有逻辑,颗粒度自然会比较大。

而对应的解决方案也很明显: 创建更多更小的单元,写更多的单元测试来减小测试的颗粒度。

单元测试和集成测试的不同

当然,单元测试远远多于集成测试也会引发其他问题。某同学在讨论中就分享了自己的经验:「应用在单元测试全部通过后部署上线,却还是因为严重的 bug 无法启动,只能回滚」。而这个问题也正是单元测试的性质所导致的:单元测试只覆盖了每个单元,并不能保证不同单元一同协作时可以正常工作。也正因此,我们需要单元测试和集成测试一起守护我们的代码。

  单元测试 集成测试
依赖数量
运行速度
覆盖代码量
编写难易程度
和生产环境的接近程度

基于这个对比,比较科学的做法应该是:用比较少的集成测试,覆盖尽可能多的单元集成逻辑,保证关键路径不会出错;用比较多的单元测试,保证每个单元逻辑的正确性。这样一来,整个测试组件的运行速度大大加快,维护成本大大降低,测试效果也能得到保障。这也就是被业界称为「测试金字塔」的实践。

当然,每个应用的情况不同。可能有的应用需要更多单元测试,有的应用需要更多集成测试。这些都是需要我们具体问题具体分析的。

过度的单元测试可能会阻碍重构

在这一轮回顾的最后,我们还讨论了「在单元测试中,是否要测试私有方法。」

在一般的语言中,测试代码是无法调用私有方法的。但是在 Ruby, Java 等语言中,可以通过 send ,反射等「黑魔法」绕过这一限制,进而调用私有方法,测试其正确性。因此,是否要测试私有方法也就成为了我们在写测试时需要考虑的一个因素。

一般来说,测试私有方法是个危险的信号。如果我们需要通过「黑魔法」来绕过一些限制达到测试的目的,那可能说明这些限制并不合理,这些私有方法在一开始就应该被公开。而且,测试在一种层面上也充当了文档的作用,在测试代码里使用「黑魔法」也是起了个坏头,鼓励大家在别处也通过「黑魔法」,最终让私有方法失去其原本的意义。

最后,假设上述两个缺点都不存在(私有方法的定义合理,而且也不会鼓励「黑魔法」的滥用),测试私有方法也容易造成过度的单元测试。私有方法是一个单元(类/模块)内部仅有的几种抽象手段之一,也是重构时的首要目标之一。名字的变更,逻辑的变更,调用顺序的变更都是很常见且必须的。如果我们过度地测试了这些私有方法,那么在我们重构时,这些测试反而会称为我们的绊脚石。简单地更改方法名也会使这些测试失败。因此,维护私有方法的测试成本是很高的,到头来很可能得不偿失。

那么,如果有这种需要测试私有方法的场景时,我们应该怎么办呢?这时,我们有几个很好的备选方案:

  1. 有可能这个私有方法并没有隐藏的必要,那么我们可以将其变为公开方法,再进行测试;
  2. 有可能我们的单元职责太复杂了,需要增加一个新的单元(类/模块),将一些原有的私有方法作为这个新单元的公开方法,再对他们进行单元测试。

    这与我们「创建更多更小的单元,写更多的单元测试来减小测试的颗粒度」的思路是类似的。在这种情况下,公开方法的测试可以看作「集成测试」(集成了一些私有方法);而私有方法的测试可以看作「单元测试」(将一个或若干个私有方法看作一个单元)。既然私有方法已经需要单元测试了,那为何不让它名正言顺地上升到单元的高度呢?

自上而下的测试驱动开发

结合上述的三个讨论:「抽取更多单元」、「集成测试配合单元测试」、「避免测试私有方法」的思想,我们能发现一种高效的 TDD 方式:

  1. 先写集成测试,覆盖原始的需求,确保最通用、最基础的逻辑能够正常工作。
  2. 重构时抽取私有方法,将逻辑分解到更小的方法(输入,解析,处理,输出)中去。
  3. 抽取、整理私有方法,成为新的、更小的单元,并进行单元测试。
  4. 在每个单元中覆盖到各种边界条件的处理,若这个单元开始变得复杂,那么它的单元测试又向集成测试发展,因此我们又能回到 1 重复整个流程。

这样,我们自上而下地将一个大的需求分解到了一个个各司其职的小单元中去,让他们协同工作,完成同一个目标。每个单元的复杂度都在可接受的范围内(100 行代码以内),理解每个单元都变得非常容易。而且由于每个单元都不复杂,只要单元切分合理,我们完全可以在需要重构或修改需求时重写一部分单元,从根源上避免了在旧代码上修修补补的情况。最终,代码质量因此得到了提升。

以上就是我们对此次编程静修第二轮「测试驱动开发」的感悟总结。我们期待在下一轮主题「不使用 if 语句」的总结中与你再会!