简化Java单元测试数据( 二 )


一些有经验的单元测试编写者已经注意到了这个问题,他们会在关键的测试数据初始化行末添加一些注释以示强调 。然而注释本身就预示着代码坏味道,并且在重构中也是非常不安全的,甚至反而误导读者 。
构建测试数据的代码重复 
如果将目光从单个测试放大到单元测试组(Test Suit),我们会发现在针对同一个被测组件的不同测试场景下,初始化数据模型的代码会大量重复 。例如在针对员工年假数额计算(leaveCalculator 组件的 annualLeave 方法)的测试组中,假设按照业务规则,我们需要考虑以下的测试场景:

  1. 入职不足2年的员工,应该享有10天年假;
  2. 当年入职的员工,享有按照入职时间折算的年假数额;
  3. 入职超过2年,而不足5年的员工,应该享有15天年假;
  4. 入职超过5年的员工,应该享有20天年假;
  5. 入职超过7年的员工,应该享有25天年假;
  6. 入职时间在未来(尚未入职)的员工,不应该计算年假数额(抛出异常);
不难想象,我们会分别在这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 来提供测试数据实例后发现,这些工厂类本身又具有非常固定的代码模板,于是我开始考虑开发一个工具来自动生成这种工厂类 。


推荐阅读