近几年国内各厂似乎有将单测覆盖率演变成代码质量硬性标准的势头,你当然可以认为这是内卷的另一种体现,因为千行 bug 率、单测覆盖率等是难得的或许可以「量化」代码质量的手段,但我相信推动者或多或少地有将单元测试作为测试领域银弹的思想,本文就分析分析这种思想所存在的问题。

从TDD谈起

TDD 指测试驱动开发,坦白而言,对于大部分业务部门来说这都是一个听起来很高端的词汇,毕竟看到自己项目中那匮乏的测试用例时常人都会产生这样的心理活动。很多人也常常在想,如果我们能实践 TDD ,也许我们的代码开发效率就能更高、代码的正确性保证就能更省心、更可靠了。

然而事与愿违,如果没有老板的首肯,TDD 在大部分业务部门中都是难以落地的。原因很简单,那就是 TDD 会肉眼可见地加长项目上线周期,但收益却只能在中后期才有所体现且难以量化。

如果要落地 TDD ,我们首先需要花费精力去设计和编写测试用例,而在根据用例完成常规开发后,我们还需要根据用例执行的情况对代码进行一定的重构。在这个过程中,第一个流程的工作内容与 QA 的职责是重叠的,也就是会造成 QA 和研发两方面的人力的浪费。而更麻烦的是重构阶段,很多产品的研发时间线是以 MVP 为起点的,公司和 PM 更希望一期工程能够尽快上线以便观察效果,至于代码方面的问题只要不影响核心功能的使用,可以放到后期再逐步优化,所以这样的排期冲突很难以技术合理性为理由去强行解决。

而如果我们把重构妥协到第一期上线之后,那也就意味着 TDD 并没有发挥真正的作用,也就是测试用例仅仅是功能验证手段,并没有驱动开发。其次一期时间紧不代表二期时间宽裕,几次妥协下来,项目代码和测试用例之间的关系已十分错综复杂,实际联系已不再紧密,TDD 很容易就演变为了 DDT ,我称其为开发测试驱动,研发开始根据代码恶补用例,而这样的举措通常收益甚小。

后补的单元测试为什么收益小

首先我们要明白,很多业务在代码层面是缺少测试用例的,最直接的体现就是单元测试少得可怜。当我们试图为这些历史代码补充用例时,我们的出发点常常是通过用例的设计使得某个环节的输入输出能固定满足几种场景的功能要求,这也就意味着这一过程和这些用例具有以下特点:

  1. 为了得到原有逻辑对应的输出,我们需要构造特定的输入来满足需求,这在业务代码的场景中往往会依赖持久层比如 DB 中的数据,即输入不能保证稳定。
  2. 这类用例只能用于当前的历史代码逻辑,当代码逻辑发生调整时用例要协同调整,但由于用例数量根基不牢,调整起来需要一一甄别。
  3. 后补的用例试图保证的是功能的正确性,对代码实现的合理性、架构的合理性都没有帮助。
  4. 补充时常常会假设当前逻辑稳定可靠,故很难发现当前功能中存在的固有问题。

可以看到,补充得来的单元测试没有自主性,必须依附于业务代码的实现逻辑,导致业务代码的修改会导致单元测试的修改,而这类单元测试只能在功能层面为未来的修改提供依据,即未来的修改能跑通之前的用例则代表该功能至少在已知的正确性层面上应该没有发生改变,但对于新逻辑调整所带来的新边界条件及输入输出组合却难以验证,同时这类单元测试本质上与直接通过接口调用并没有太大的差异。

另一方面,对持久层数据的依赖更使得我们所写的单元测试常常还没有直接对接口发起调用来得方便。而如果你想通过模拟数据源的方式固定一套稳定的输入输出,那就意味着你额外维护了一套测试环境,在未来的迭代中很难保证不会与主测试环境和线上的逻辑发生冲突,维护成本陡增。

我们该如何看待单元测试

测试是一门学问甚至科学,单元测试只是众多测试手段中的一种,他有自己适用的场景,如果不能完全以 TDD 的思想开发,那么在适当的场景使用单元测试,再结合使用增量测试、集成测试、回归测试、冒烟测试等其他测试方式来制定系统的测试计划,我们的代码才能尽可能地做到高质量、高合理性。

那么什么场景适合单元测试呢?我认为如果按照 DDD 战术模式的分层思想,则领域层的任何逻辑都适用于单元测试。这无关领域模型充血与否,原因主要在于领域逻辑在合理分层的设计下是与持久层没有直接耦合的,该层的单元测试易于编写和维护。同时它深藏于接口所在的展示层之下,却包含了最根本的业务逻辑,其他测试方式有可能无法完整测试领域层的逻辑,故为其添加单元测试有助于发现隐藏的问题,并能更好地维护代码的健壮性。同时研发只对底层核心业务逻辑进行测试也能避免与 QA 团队的工作内容发生重叠,进而避免了人力的浪费。此外好的领域设计会将领域逻辑在实现时进行更加细致的逻辑拆分,而这恰好符合了 TDD 的基础要求之一,因此对这类逻辑实行 TDD 也会更易于落地。

而对于其他层,他们常常包含多个领域模型的交互,逻辑的执行也依赖 DB 等持久层的数据或仅仅包含其他层的 DTO 转换逻辑,这些逻辑完全可以交由其他测试方式,通过制定科学的测试流程来保证质量。特别的,展示层是产品逻辑和多端交互最直接的体现,所以它是最适合从接口层面根据出入参进行用例设计和实现的,而不应由研发人员使用各种接口 Mock 工具编写复杂的测试代码创造所谓的单元测试。

关于单测覆盖率

回到单测覆盖率,现代业务项目的代码实现尤为复杂,对于 Java 类语言尤甚。笼统地要求提高整个项目的单测覆盖率就意味着研发人员需要花费更多的时间来关注足够简单或不重要的逻辑,而大部分公司的现状又使得单测多为事后补充,效果甚微。

所以我们大可以把用于编写冗余单元测试的时间花费到对于项目真正重要的重构优化上,同时在项目初期就尽量保证核心领域逻辑的单测覆盖率,这样才能在效率和质量之间找到合理的平衡点,为业务发展提供助燃剂。