编写本书的初衷是帮助读者了解设计软件时应考虑的取舍以及分享经验和教训。谈到设计选型取舍时,我们有一个假设前提,即你所编写的代码已经足够健壮。高质量的代码是软件“大厦”的基础,打牢此基础后你才需要考虑架构演进的方向。
为了帮助你了解本书各章通用的内容组织形式,我们先以两个大家都熟悉的取舍为例,分别是单元测试和集成测试,它们可能是比较立竿见影的软件质量保障实践,你在编程时很可能已经用到了它们。最终的目标是单元测试和集成测试可以覆盖所有的代码路径。然而,这很难在实践中达成。因为项目周期是有限的,你没有那么多的时间来完成编码并进行充分的测试。因此,投入多少资源与时间到单元测试与集成测试上就变成了我们需要权衡的问题。
编写测试时,你需要决定测试哪部分代码。譬如,你需要对一个简单的组件SystemComponent进行单元测试,它只提供了一个声明为public类型的接口,其他所有方法的声明都是private类型的,客户端无法直接访问。该场景的代码片段如代码清单1.1所示。
public class SystemComponent {
public int publicApiMethod() {
return privateApiMethod();
}
private int privateApiMethod() {
return complexCalculations();
}
private int complexCalculations(){
// 复杂的代码逻辑
return 0;
}
}
这时你需要判断,要不要为complexCalculations()添加单元测试,是否继续保持该方法的私有成员属性。这类单元测试属于黑盒测试,只能覆盖public类型的API。通常,单元测试做到这种程度就已经足够了。然而,极端的情况下,譬如私有方法的逻辑特别复杂时,为其添加单元测试也是物有所值的。为了做到这一点,你得放开complexCalculations()的访问权限。代码清单1.2展示了对应的修改。
@VisibleForTesting
public int complexCalculations() {
// 复杂的代码逻辑
return 0;
}
修改方法的可见性,将其由private类型变为public类型之后,你可以为这部分之前访问级别为private的API编写单元测试。由于公有方法对所有API客户端都可见,你不得不面对客户端可以直接调用该方法的窘境。你可能会说,上述代码清单中不是还有@VisibleForTesting注解吗?实际情况是,这个注解只能起到“提示信息”的作用,无法强制限定调用方不使用你的API中的公有方法。如果调用方没有留意这个注解,他们可能会忽略这一点。
本节提到的两种单元测试方法并无高低优劣之分。后一种方法提供了更高的灵活性,然而,随之而来的是维护代价的增加。你可以在这二者间取一个折中方案。譬如,将代码的包标记为private类型。这样一来,由于测试代码与产品代码在同一个包内,你可以直接在测试代码中调用上述方法,而不再需要将方法修改成public类型的。
计划测试任务时,你需要思考对你的系统而言,应该按什么比例分配单元测试与集成测试。通常情况下,选定一个方向会限制向另一个方向发展的可能性。而且,这种限制也许从该开发项目开始时便产生了。
大多数情况下,我们开发功能的时间都是“捉襟见肘”的,需要慎重考虑是否要投入更多的时间在单元测试或者集成测试上。现实场景中,我们应该充分结合单元测试和集成测试的优势,最大限度地发挥其效能,这也是为什么我们需要思考该按怎样的比例分配单元测试和集成测试。
这两种测试方法都是双刃剑,各有其利弊,你在编码时不得不做利弊权衡。单元测试的优点是速度更快,反馈时间更短,因此调试流程通常也更短。图1.1展示了这两种测试的优缺点。
图1.1 单元测试、集成测试及其执行与反馈时间(速度)
图1.1所示为一个金字塔,这是因为通常情况下,软件系统中的单元测试比集成测试多得多。单元测试可以为开发者提供几乎即时的反馈,从而帮助提升开发效率。单元测试的执行速度更快,可以帮助减少代码调试的时间。如果单元测试100%覆盖了你的代码库,当一个新的缺陷被引入时,很可能某个单元测试能发现这个缺陷。你可以在单元测试覆盖的方法级别上精确定位该缺陷。
另外,如果系统缺少集成测试,你将无法判断组件之间的连接是否正常以及它们之间的集成是否成功。你的算法虽然经过充分的测试,但没有对更大场景进行覆盖。最终你的系统可以在较低的层级正确完成所有任务,但由于系统中的组件配合没有经过测试,无法在更高层级上确保系统的正确性。在实际项目中,你的代码应该同时包含单元测试与集成测试。
需要注意的是,图1.1仅关注了测试的执行与反馈时间。但实际生产系统中还会有其他层级的测试,如我们可能会做完整验证业务场景的端到端测试。在更复杂的体系结构中,我们可能需要启动 N 个相互连接的服务以提供对应的业务功能。由于搭建测试基础架构所需的开销较大,这类测试的反馈时间可能比较长。然而,它们能从更高的层级保障系统端到端流程的正确性。如果要用这些测试与单元测试或者集成测试做比较,我们需要从不同的维度进行分析。如图1.2所示,它们从整体角度而言对系统验证的效果如何?
单元测试仅在单一组件中隔离运行,无法提供系统中其他组件的信息,也无法验证单一组件如何与其他组件进行交互。集成测试的重要性此时就凸显了,它可以同时测试多个组件,验证组件之间的交互效果。不过,集成测试通常不会跨多个服务或者微服务验证某个业务功能。最后我们要介绍的是端到端测试,这类测试可以对系统进行完整的验证,由于我们需要串联起整个系统,该系统可能包含若干个微服务、多个数据库、多个消息队列等,测试涉及的组件数目是极其庞大的。
图1.2 单元测试vs集成测试vs端到端测试
我们还要考虑创建测试所需的时间。创建单元测试比较容易,只需花费比较少的时间就能创建大量的单元测试用例。创建集成测试往往需要更多的时间。最消耗资源的是端到端测试,创建端到端测试的基础设施需要大量的投入。
在实际项目中,我们的资金和时间往往是有限的,虽然我们秉持尽最大可能提升软件质量的原则,但也要考虑方方面面的限制。通过测试覆盖代码变更可以帮助我们发布更高质量的软件,减少缺陷数量,从长远来看,还可以提升软件的可维护性。在资源有限的情况下,我们不得不思考,该选择做什么类型的测试,以及做到什么程度。我们需要在单元测试、集成测试、端到端测试之间寻找平衡点,可以多维度分析特定类型测试的优势与劣势,帮助我们做出更合理的判断。
有一点特别重要,也需要特别强调,那就是添加测试会延长开发的时间。我们做的测试越全面,花费的时间就越多。如果不为这些测试任务分配时间,只是死板地限定项目交付日期,很难开展有效的端到端测试工作。因此,计划为产品添加新功能时,我们也需要考虑添加对应的测试任务,而不要奢望以事后弥补的方式解决问题。