作者 | 张哲
EasyModeling 是我在2021年圣诞假期期间开发的一个 Java 注解处理器,采用 Apache-2.0 开源协议。它可以帮助 Java 单元测试的编写者快速构造用于测试的数据模型实例,简化 Java 项目在单元测试中准备测试数据的工作,在提高编写效率的同时,使单元测试更加整洁易读。经过一年的维护,EasyModeling 已经在几个 Thoughtworks 内部的项目上得到了应用,并迭代发布了几个版本。
单元测试中的数据准备的困难
在企业级应用软件开发项目中编写测试代码时,针对特定的测试场景,我们需要准备相应的测试数据,以验证被测组件在给定输入下的行为。在使用 Java 语言的项目中,这些准备测试数据的代码体现为创建各种“数据模型类”的实例。这里的数据模型类,可以包括聚合模型(Aggregation Model)、数据传递模型(DTO)、值对象(VO)以及存储模型(Persist Model)等等。无论是对服务组件的测试,还是对数据模型本身的测试,我们都无可避免地需要构建这些数据模型类的实例。
在项目的起初阶段,准备数据的工作是简单的,我们只需要调用数据模型类的构造方法,传入适当的参数来创建实例即可。单元测试代码的规模不会太大,也尚且清晰易读。
但是随着产品开发工作的展开,一方面,项目中使用的这些数据模型会变得越来越复杂;另一方面,测试场景也会变得越来越多。经验上,在经过几个版本迭代的企业级应用 Java 代码中,我们通常不难找出一些拥有十几个、甚至几十个成员变量的数据模型类,并且它们之间还存在着复杂的相互持有、嵌套、继承的关系。这些数据模型类往往都是项目中的核心组件,故而也成为单元测试需要重点关注的组件。相应地,在涉及这些数据模型的单元测试中,为准备测试数据而编写的初始化数据模型类的代码量也会越来越大、越来越复杂。
这些冗杂繁复的数据初始化代码会影响单元测试本身的代码质量,造成单元测试编写成本高、易读性差、易维护性低等问题。而单元测试的质量又与生产代码的质量息息相关。例如,单元测试的编写成本过高,会使开发者越来越倾向于仅在已有测试基础上做修改,而不是为每个场景创建单独的测试,造成单个测试的职责过多;甚至使开发者放弃单元测试,降低了团队对产品质量的信心。又比如,单元测试的易读性差,导致单元测试无法承担起“测试即文档(tests as documentation)”的职责。而单元测试的易维护性低,则导致了代码很难被重构,从而单元测试不仅没有为重构提供信心,反而变成重构的桎梏。
具体来说,这些初始化数据的代码会引起三个方面的问题:
- 对测试场景的描述不清晰
- 构建测试数据的代码重复
- 初始化数据模型代码的膨胀
我们可以从下面的例子中略窥端倪。你是否在你的项目中见过这样的单元测试?
图片
这是一段典型的使用JUnit测试框架的单元测试代码。在这段单元测试代码中,被测对象是 leaveCalculator 组件的 annualLeave 方法。我们首先创建一位员工,如(a)处;然后将创建好的员工对象传入 annualLeave 方法,为其计算出应得的年假数额,如(2)处;最后断言他应该享有20天年假,如(3)处。为了简化讨论,我们暂且假设此处 annualLeave 方法的业务规则是:员工应得的年假数额只与这位员工加入公司的时间(date of joining)相关,即在代码中 (1) 处初始化的日期。
我们来详细分析这段测试代码中存在的坏味道、以及其潜在的问题。
对测试场景的描述不清晰
如前文所述,我们假设这段单元测试代码的目的是验证“入职超过5年的员工应该享有20天年假”这个业务规则。那么显然,其中只有 (1), (2), (3) 这三处是与当前测试场景相关的,它们共同构成了对上述业务规则的描述。而在 (1) 处之前传入 Employee 类构造方法的那些参数都是与当前测试场景无关的。遗憾的是,这些与测试场景无关的代码却占据了这个代码片段中的绝大部分代码行。
在实际项目中,我们会见到很多这样的单元测试,它们往往需要用几十行的代码来准备复杂的测试数据,需要初始化数个数据模型类的对象,以支持对被测组件的调用,然而这些代码中真正在描述测试场景的,却只有其中区区几行、甚至一两行。这不仅增加了测试的篇幅,还会导致阅读者无法快速聚焦在有意义的初始化条件上。就像我们在这个例子中看到的,描述测试场景的代码行(1)处混杂在大量初始化测试数据的代码行之中,造成了单元测试对测试场景的描述不聚焦。这使单元测试的阅读者很难从这段测试代码中一目了然地理解测试的意图,更遑论以测试为文档来理解业务规则。而在测试失败时,也无法快速从测试场景的数据构造出发去定位问题。
一些有经验的单元测试编写者已经注意到了这个问题,他们会在关键的测试数据初始化行末添加一些注释以示强调。然而注释本身就预示着代码坏味道,并且在重构中也是非常不安全的,甚至反而误导读者。
构建测试数据的代码重复
如果将目光从单个测试放大到单元测试组(Test Suit),我们会发现在针对同一个被测组件的不同测试场景下,初始化数据模型的代码会大量重复。例如在针对员工年假数额计算(leaveCalculator 组件的 annualLeave 方法)的测试组中,假设按照业务规则,我们需要考虑以下的测试场景:
- 入职不足2年的员工,应该享有10天年假;
- 当年入职的员工,享有按照入职时间折算的年假数额;
- 入职超过2年,而不足5年的员工,应该享有15天年假;
- 入职超过5年的员工,应该享有20天年假;
- 入职超过7年的员工,应该享有25天年假;
- 入职时间在未来(尚未入职)的员工,不应该计算年假数额(抛出异常);
不难想象,我们会分别在这6个测试场景对应的测试方法中重复地编写几乎完全相同的代码来初始化Employee类的对象。
这样的单元测试模式在企业级应用开发的场景中比比皆是。开发者经常很容易在测试第二个场景时,顺手从第一个场景的单元测试中复制初始化数据模型的代码,略作修改来描述第二个测试场景,后面的测试场景也如法炮制。这样显然会造成测试代码中存在大量的模板代码(Boilerplate code),进一步降低了代码的易读性。
通常在开发项目的实践中会引入构建者模式(Builder Pattern)或者 Object Mother 组件来消除这些模板代码。本文非常欣赏这些解决方案,下文会在此基础上做进一步讨论。
初始化数据模型代码膨胀
另外需要注意的是,前文举例的代码中为节省篇幅已经做了很多简化。我们不仅用省略号折叠了(1)处之后可能传入构造方法的更多的初始化参数,还折叠了在(b)处初始化 List<Department> departments 参数时逐个构造 Department 类对象所需要的大量细节,甚至在初始化每个Department类对象时,又另外需要构造更多的相关实例。
当然在实践中,经常使用的策略是将大量无关的属性设置成 null 或者空集合,但是这有时候会在被测组件对数据类有效性检查中被拦截。特别是在某些演进了一段时间的代码库中,我们经常会遇到的困难是,由于在测试中构造数据时采用了过多的 null 和空集合,一个新添加的数据有效性检查步骤或者切面(AOP),会造成几百个单元测试的失败。逐一修复这些失败的单元测试的工作量无疑是巨大的,同时是充满风险的,因为此时对单元测试的修改完全是为了兼容一个新添加的切面,而脱离了单元测试本身的业务上下文。
在这种情况下,开发者会越来越多选择将相似的数据有效性检查步骤散布在具体的业务代码中,而非在构造方法中统一检查、或者通过切面集中实现。可见,单元测试的不良设计,会反过来增加生产代码的维护难度,拖累了生产代码的演进。
EasyModeling提供的能力
造成开发者写出类似单元测试的原因是广泛存在的。例如,Employee 类没有提供更灵活的构造方法,也没有 Builder 模式的构造器。从 Employee 类自身的职责的角度出发,它的确没有理由提供一个仅包含 LocalDate dateOfJoining 作为参数的构造方法。在很多业务场景下,数据模型类也完全有可能就是不允许通过 Builder 模式来构造的。我们当然不能为了编写测试代码的便利,而去修改生产实现代码。又例如,代码中可能存在对 Employee 类的数据合法性校验。这些校验可能是类似切面的形式存在的,导致我们无法方便地在单元测试中忽略它。
在实际项目中,开发者很容易从“消除重复”的角度,抽象出相应的工厂类来提供测试所需要的数据模型实例。Martin Fowler 也在他的博客的短文 Object Mother 中简要讨论了相关的思路。但是在测试中使用工厂组件虽然消除了很多重复代码,却没有提供针对不同的测试场景的灵活定制能力,因此一些项目又会同时采用 Builder 模式来提供定制能力。我自己在多个项目上引入 Object Mother 来提供测试数据实例后发现,这些工厂类本身又具有非常固定的代码模板,于是我开始考虑开发一个工具来自动生成这种工厂类。
受到 Builder 模式和 Object Mother 思想的启发,我开发了 EasyModeling 来尝试简化 Java 单元测试的编写,并提高测试的可读性和易维护性。EasyModeling 是一个 Java 注解处理器库,它主要提供三个方面的功能:
- EasyModeling在编译期根据指定的数据模型类的结构,生成对应的数据模型工厂类,以方便单元测试快速生成数据模型类的实例。通过向 EasyModeling 注册一个数据模型类,单元测试的编写者只需要调用 EasyModeling 所提供工厂类的静态方法,就可以立即得到这个数据模型类的实例。
- EasyModeling 还可以在单元测试的运行时,自动初始化它所生成的数据模型实例。在生成数据模型实例时,EasyModeling 默认的行为是给数据模型实例的字段填充随机值,让开发者不需要再耗费精力去填充对测试场景无意义的属性。同时,开发者仍然有机会向 EasyModeling 指定每个数据模型类的每个字段所需的初始化方式。
- 另外,EasyModeling 还在其生成的工厂类中提供了一个 Builder 模式的构建器。利用这个构建器,开发者可以定制、并仅定制与当前测试场景相关的字段,使单元测试简短、清晰、易读。
在编码层面,EasyModeling 的行为完全发生在测试包中,丝毫不会侵入项目的生产实现代码。同时,EasyModeling 只会照顾开发者向它注册的数据类型类,而不会在代码库中主动搜索。所以即使是维护已久的代码库,从任何时间点引入 EasyModeling 都不会造成额外的负担。
EasyModeling简化后的单元测试
在引入了 EasyModeling 后,本文中第一节中的单元测试例子可以得到显著地简化:
图片
除此之外,如前文提到,开发者需要在测试代码中向 EasyModeling 注册 Employee 类:
图片
首先我们看到,在引入 EasyModeling 后,单元测试的代码在篇幅上得到了非常明显地简化。在单元测试中 (4) 处,EmployeeModeler 类就是由 EasyModeling 在编译期生成的工厂类,通过引用 EmployeeModeler 类中的静态方法 builder(),我们可以得到 Employee 类的Builder 的实例。请注意,此处使用的 Builder 类不是由 Employee 类自己编写的,也不是通过如 Lombok 这样的工具来提供的,而是由 EasyModeling 在其生成的工厂类 EmployeeModeler 来提供的。这样的好处是,为了测试而准备的 Builder 完全没有侵入生产代码。
其次,在 (4) 处生成的 Builder 类的实例中,EasyModeling 已经为我们尽可能多地填充了所有的成员变量。因此,我们接下来只需要聚焦在当前测试场景所关心的成员变量上。例如在 (5) 处,我们将 dateOfJoining 字段的内容设置为指定的日期。在可读性方面,由于避免了冗长的初始化参数,所以使开发者在阅读单元测试时,能够快速理解测试场景,进而也比较容易修改或维护单元测试。
第三,EasyModeling 在填充数据模型实例的属性时,不仅能够填充一些 Java 应用中常用的数据类型,包括基本类型、数组、集合、时间日期等等,还能够进一步填充当前数据模型所引用的其他数据模型。例如 Employee 类中引用的 List<Department> departments 列表字段。
最后,为了让 EasyModeling 帮我们生成 Employee 类的工厂类,如以上代码中 (6) 处,开发者只需要在任意的一个类上通过 @Model 注解声明即可。EasyModeling在编译期为所有被 @Model 注解声明的数据模型类生成对应的工厂(Modeler)类。
除此之外,EasyModeling 还提供了其他一些好用的特性,限于篇幅,具体的用法请参考文档。
EasyModeling的不足和未来
但是由于我的业余精力和能力都非常有限,EasyModeling 目前还处于它成长的初期,存在几点显然的不足。
第一,没有维护良好的使用文档。目前我只维护了一份项目 Readme 文件,作为简要的使用文档,导致一些略高级的使用方法和一些从新版本开始支持的功能并没有体现在文档中。
第二,没有维护文档注释。遵循代码整洁的原则,在长期从事的企业应用开发中,我几乎不会写任何形式的注释。所以我也没有意识到,在维护一个更偏底层的开源工具库时,充分的文档注释是非常必要的。一方面,文档注释便于开发者用户查看阅读,也便于有兴趣的贡献者参与开发。另一方面,由于这种较为基层的工具中无可避免地要使用一些魔法,如果没有良好的注释,随着时间推移,可能连我自己也会忘记其中的细节。
由于 EasyModeling 是一个关注单元测试的工具,而不会入侵任何生产代码,因此,在 Java 项目中引入 EasyModeling 几乎不会对项目的可靠性、安全性造成任何风险。所以如果你对这个工具感兴趣,认为它有可能帮助你提高编写测试的效率,请不妨引入到你的项目中尝试使用。
未来,由于我自己在项目上会持续使用 EasyModeling 来构建测试数据,所以我基本可以保证持续维护这个工具。在近期,我将聚焦在完善使用文档,以及修复从用户反馈的一些缺陷。在EasyModeling 的功能特性方面,虽然我手上目前依然积压着一些我自己想要实现的功能,但是我更想从用户的反馈中收集更多有趣的好主意,再来推进下一阶段的功能演进。