最近我读完了Maurício Aniche编写的《高效测试-开发人员指南》,我真的很喜欢它。我从事软件开发很长时间了,自认为已经为我实现的功能编写了非常好的测试。尽管如此,我还是觉得这本书很有价值。特别是关于如何根据需求规范、输入、输出和实现结构系统地设计测试用例的章节。
本书还涵盖了与开发人员编写自动化测试相关的许多其他常见主题,例如:测试驱动开发、Mock、可测试性设计和基于属性的测试。作者很好地描述相关内容。我特别喜欢代码示例,这些代码比最基本的案例要完整,但又精简到可以轻松记住。
作者是代尔夫特理工大学软件工程助理教授。他是一名有经验的开发人员。这本书显然源于一门软件测试课程的讲义。学术背景表明有很多相关研究的参考资料(其中的《完整的代码》我也很喜欢)。
最有趣的章节
我最感兴趣的是关于如何系统地提出测试用例的两个章节。关于这个事情我之前一直以相对临时的方式执行,所以有一个清晰而详尽的描述是很有帮助的。
基于需求规范的测试
作者将如何设计测试用例分解为七个步骤。从了解程序应该做什么开始,之后确定输入和输出的类型和范围。一些输入是等价的:它们的值不同,但在程序中产生相同的调用链路。例如一个表示姓名的字符串,则“张三”和“李四”的处理方式几乎完全相同。因此它们是等价的,所以两个输入都属于输入的相同分区(或类别)。另一方面,空字符串可能会导致不同的调用链路(因为可能不允许使用空姓名),所以这将是另一个分区。
这个想法是找到所有不同的分区,以测试程序的完整行为。这样做的一种系统方法是单独检查每个输入以找到它的所有可能分区。例如,对于可能为 null、空、长度为 1 的字符串、长度 > 1 的字符串。接下来,再结合其他输入检查每个输入。不同输入之间经常存在依赖关系。比如有两个字符串输入,如果同时为null怎么办?如果两者同时具有值 怎么办?等等。最后,查看所有可能的输出。例如,如果输出是一个字符串数组,请考虑数组整体情况:null、空数组、单项、多项。之后再考虑数组中的每个元素:空、单个字符、多个字符。理想情况下,找到所有输入分区应该生成所有输出分区。但是,通过不同的输出分区的反向检查可以防止输入案例的遗漏。
下一步是找到分区之间的边界,因为“边界更容易出现BUG”。如果存在 x < 10 的条件,则存在一个边界,其中 9 以下的值属于一个分区,而 10 及更高的值属于另一个分区。只要有边界,就应该有两个测试。一种测试边界上的值(on point),一种测试不属于同一分区的最接近值(off point)。如果有相等而不是小于,则两边都有边界,这意味着应该有三个测试。另请注意,边界不必位于变量值之间。如果一个空列表的处理方式与其中包含元素的列表不同,那也是一个边界。
接下来是测试用例生成。快速生成所有可能的分区组合会导致组合爆炸。因此,有必要优化一些测试。查看代码实现可以帮助我们将部分用例优化。例如,在函数入口检查异常情况很常见。如果及时的处理了输入的空值,则无需将该空输入与其他输入组合。当该输入为空时,一个测试用例就足够了。一旦确定了需要测试的不同用例,编写实际测试主要是一项“体力活”。
最后一步是“用创造力和经验来增强测试套件”。我喜欢这个!基本上,看看您是否可以想出现有测试用例的有趣变体。例如,如果使用字符串,可能会添加其中有空格的用例。
在书中,有一个很好的例子是关于subStringBetween()函数的,可以了解更多的细节。
结构测试和代码覆盖率
我们还没有完成,只是因为我们已经提出了我们可以从基于规范的测试中想到的所有测试用例。接下来就是查看源代码的代码覆盖率,看看是否有没有执行到的部分。这可以帮助我们发现我们遗漏的案例。也可能有一些实现细节不会出现在功能规范中,但仍需要进行测试。
这里的目标不是达到 100% 的测试覆盖率。相反,它是一种工具,可以突出显示代码的哪些部分未被执行,以便分析原因。也许它是不能或不应该测试的东西,或者不值得投入测试成本。如果是这样,则不需要额外的测试。但有时会遗漏,代码覆盖率可以帮助我们找到这些用例。
有很多不同的覆盖标准。行覆盖意味着给定行已从测试用例中至少运行一次。分支覆盖率是针对条件判断语句(if,for,while),需要每个分支必须至少执行一次。当存在多个条件的组合时,例如if (a && b),条件 + 分支覆盖意味着每个单独的条件 ( a , b ) 至少一次为真和假,并且整个分支至少一次真和假。最后,链路覆盖表示程序所有可能的路径都已经执行完毕。这会变得很棘手,因为路径的数量随着条件的数量呈指数增长。此外,如果有循环,可能每次都迭代数百次。因此,以路径覆盖为目标是不切实际的。
当有取决于多个条件(即复杂的 if 语句)的决策时,无需测试所有可能的条件组合就可以获得良好的错误检测。修改后的条件/决策覆盖率 (MC/DC)会执行每个条件,使其独立于所有其他条件影响整个决策的结果。换句话说,每个参数的每个可能条件必须至少影响结果一次。作者通过示例很好地展示了这是如何完成的。
因此,鉴于您可以检查代码覆盖率,您必须决定在覆盖率要求要达到多严格,并为此创建测试用例。边界值的概念在这里很有用。对于一个循环来说,至少在执行0次、1次、多次时进行测试是合理的。
似乎只进行结构测试就足够了,而不必为基于规范的测试而烦恼,因为结构测试可确保覆盖所有代码。然而,事实并非如此。与简单地检查覆盖率相比,分析需求可以产生更多的测试用例。例如,如果将结果添加到列表中,则添加一个元素的测试用例将覆盖所有代码。但是,根据需求规范,测试它是否在多个元素的情况也正常可能很重要。因此,结构测试应该作为基于规范的测试的补充。
本章以变异测试部分结束。这里的想法是系统地更改程序中的许多小细节,然后运行所有测试用例,并确保更改被失败的测试检测到。更改由变异测试工具自动执行,例如:将 <= 更改为 <,递减而不是递增变量,将加号更改为减号,或将布尔变量替换为true。虽然变异测试可以确保测试覆盖代码的每个部分,但通常需要很长时间才能运行。突变测试框架的一个例子是Mutmut,它是由我的一位前同事Anders编写的。
其他章节
基于属性的测试。基于属性的测试,根据代码中的属性,框架生成随机测试数据并检查该属性是否始终有效。如果发现失败案例,它会自动减少到尽可能小的例子。我读过的例子一直都很小。在本章中有几个示例,其中一些示例更为丰富。他们很好地理解了如何去做,并展示了挑战。我对基于属性的测试的问题一直是找到获得明确受益的案例,并且这些测试的受益超过了实现它们的成本。我以前使用随机生成的测试效果很好,但在完整的系统上(比如在手机之间生成随机呼叫),还没有尝试过基于属性的测试。
双重测试和Mock。本章首先定义插桩、存根、监测和Mock。然后,它给出了具有依赖性和副作用的典型业务代码示例,以及使用Mock框架如何帮助测试。关于Mock的利弊也有很好的讨论。
可测试性设计。本章的关键思想是将基础设施代码与业务代码分开。这说起来容易做起来难,但许多其他人也表达了这个想法,例如在 《整洁架构》中。描述如何做到这一点的一种方法是使用六边形架构(或端口和适配器),这在本章中有很好的描述。我以前听说过,但从未读过。实现这一点的关键技术是使用依赖注入。您还需要您的代码是可观察的。作者展示了如何通过更改生产代码来提高可观察性以使测试更容易的示例。我赞成这个!
测试驱动开发。本章使用将罗马数字转换为整数的示例来说明 TDD 的工作原理。在介绍了它是什么之后,作者指出,尽管他经常使用 TDD,但他并不是一直都在使用它。并非所有情况都能从TDD中获得受益。这和我的经验一致,我在《TDD不适用的情况》中写过一些内容。还讨论了关于 TDD 有效性的研究结果。实证研究并未发现 TDD 的明显好处。一些研究表明,观察到的 TDD 的好处可能不是因为首先编写测试,而是由于改进焦点和流程的细粒度、稳定步骤的过程(先测试或不先测试)。
还有关于大型测试(使用数据库)、契约设计和测试代码质量的章节。
缺失
探索性测试。本书的重点是开发代码时的自动化测试。但是,我也希望有一章是关于探索性测试的。它被提到了几次,仅此而已。在完成代码开发和自动测试后,我总是会进行一些探索性测试。专注于完整的应用程序,而不是它的一部分,提供了一个不同的视角,帮助我找到了我的自动测试没有发现的错误。我在《手工测试是否有价值?》,以及在斯德哥尔摩的 Jfokus conference谈到了它。幸运的是,有一本关于这个主题的好书叫做《深入研究!》我推荐它作为本书的补充。
命名测试。为测试想出一个一致且清晰的命名方案是非常困难的。对此的一些想法也很值得拜读。
随感
其他一些小观察:
作者在“开发者模式”下工作,然后在测试时切换到“测试者模式”。我也这样做。
代码示例是用 Java 编写的,并且有大量的“简介”可以进一步解释代码。这使得代码更容易理解。
作者和我一样,反对每次测试只使用一个断言。通常使用多个也是有意义的。
每章都以练习结尾(所有练习都在后面附有答案),这会让您更多地投入到学习中。
“我不害怕故意在代码中引入错误,运行测试,然后看到它们变红(然后恢复错误) ”——《我发现很有效》。
结论
我的理念是,作为开发人员,我的责任不仅仅是开发功能。在我说我完成之前,我还必须说服自己它按预期工作。这本书是关于如何编写好的测试让我相信我的代码有效的重要资料。
有效的软件测试很好地组合在一起。我特别喜欢其中有许多精心挑选的例子,这些例子突出了概念而不是简化。整本书非常务实,我们从作者作为教师、研究者和实践者的经历中获益匪浅。