Java Record如何高效创建带默认值的测试夹具?
问题
需要对接收Java Record类作为入参的方法做单元测试,该Record字段多,方法业务逻辑复杂。测试时要创建大量测试Record实例,每个实例仅在默认Record基础上修改少量字段。但Java Record不可变,没有setter,Java也不支持方法参数默认值,试过以下方案都有问题:
- Telescoping fixtures:样板代码多,同类型字段易出错;
- Mockito模拟getter:无法模拟final类;
- 复制粘贴:代码冗余难维护;
- Lombok Builder:高效但不够优雅。
示例代码:
record Customer( String name, int ageInYears, boolean isPremiumCustomer, boolean isRegisteredInApp, int totalNumberOfPurchases, int totalPurchaseValue, int numberOfPurchasesLastMonth, int purchaseValueLastMonth ) {} class DiscountCalculator { /** * Calculates discount for given customer in percent. */ static int calculateDiscountFor(Customer customer) { // 复杂业务逻辑实现 } }
测试策略示例:
class DiscountCalculatorTest { private static final Customer DEFAULT_CUSTOMER = new Customer("Jane Doe", 45, false, false, 10, 100, 1, 10); @Test void defaultCase() { // given var customer = DEFAULT_CUSTOMER; // when var result = DiscountCalculator.calculateDiscountFor(customer); // then assertThat(result).isEqualTo(0); } }
解决方案
1. 给Record添加withXxx风格的修改方法
利用Record的不可变性,手动为每个字段添加返回新实例的with方法,基于默认实例链式修改需要的字段,代码简洁且可读性强。
修改后的Customer Record:
record Customer( String name, int ageInYears, boolean isPremiumCustomer, boolean isRegisteredInApp, int totalNumberOfPurchases, int totalPurchaseValue, int numberOfPurchasesLastMonth, int purchaseValueLastMonth ) { public Customer withName(String name) { return new Customer(name, this.ageInYears, this.isPremiumCustomer, this.isRegisteredInApp, this.totalNumberOfPurchases, this.totalPurchaseValue, this.numberOfPurchasesLastMonth, this.purchaseValueLastMonth); } public Customer withAgeInYears(int ageInYears) { return new Customer(this.name, ageInYears, this.isPremiumCustomer, this.isRegisteredInApp, this.totalNumberOfPurchases, this.totalPurchaseValue, this.numberOfPurchasesLastMonth, this.purchaseValueLastMonth); } // 其他字段的with方法以此类推 }
测试中使用方式:
@Test void premiumCustomerGets10PercentDiscount() { // given var customer = DEFAULT_CUSTOMER.withIsPremiumCustomer(true); // when var result = DiscountCalculator.calculateDiscountFor(customer); // then assertThat(result).isEqualTo(10); } @Test void highVolumeCustomerGets5PercentDiscount() { // given var customer = DEFAULT_CUSTOMER.withTotalNumberOfPurchases(50) .withTotalPurchaseValue(1000); // when var result = DiscountCalculator.calculateDiscountFor(customer); // then assertThat(result).isEqualTo(5); }
这种方式完全符合Record的设计初衷,代码直观,无额外依赖,维护成本低。
2. 手动实现测试专用Builder
如果不想修改原Record类,可在测试类中实现仅用于测试的Builder,基于默认实例进行字段覆盖。
测试类中的Builder实现:
class DiscountCalculatorTest { private static final Customer DEFAULT_CUSTOMER = new Customer("Jane Doe", 45, false, false, 10, 100, 1, 10); private static class CustomerBuilder { private String name = DEFAULT_CUSTOMER.name(); private int ageInYears = DEFAULT_CUSTOMER.ageInYears(); private boolean isPremiumCustomer = DEFAULT_CUSTOMER.isPremiumCustomer(); private boolean isRegisteredInApp = DEFAULT_CUSTOMER.isRegisteredInApp(); private int totalNumberOfPurchases = DEFAULT_CUSTOMER.totalNumberOfPurchases(); private int totalPurchaseValue = DEFAULT_CUSTOMER.totalPurchaseValue(); private int numberOfPurchasesLastMonth = DEFAULT_CUSTOMER.numberOfPurchasesLastMonth(); private int purchaseValueLastMonth = DEFAULT_CUSTOMER.purchaseValueLastMonth(); private CustomerBuilder() {} public static CustomerBuilder aCustomer() { return new CustomerBuilder(); } public CustomerBuilder withName(String name) { this.name = name; return this; } public CustomerBuilder withIsPremiumCustomer(boolean isPremiumCustomer) { this.isPremiumCustomer = isPremiumCustomer; return this; } // 其他字段的setter方法以此类推 public Customer build() { return new Customer(name, ageInYears, isPremiumCustomer, isRegisteredInApp, totalNumberOfPurchases, totalPurchaseValue, numberOfPurchasesLastMonth, purchaseValueLastMonth); } } @Test void registeredAppUserGets3PercentDiscount() { // given var customer = CustomerBuilder.aCustomer() .withIsRegisteredInApp(true) .build(); // when var result = DiscountCalculator.calculateDiscountFor(customer); // then assertThat(result).isEqualTo(3); } }
这种方式不侵入原业务代码,Builder完全服务于测试场景,默认值继承自DEFAULT_CUSTOMER,修改字段时只需调用对应方法即可。
3. 使用Java 17+的record pattern结合解构
如果使用Java 17及以上,可利用record pattern解构默认实例的字段,再替换需要修改的部分,代码更简洁:
@Test void recentHighSpenderGets8PercentDiscount() { // given var (name, age, premium, registered, totalPurchases, totalValue, _, _) = DEFAULT_CUSTOMER; var customer = new Customer(name, age, premium, registered, totalPurchases, totalValue, 5, 200); // when var result = DiscountCalculator.calculateDiscountFor(customer); // then assertThat(result).isEqualTo(8); }
这种方式无需额外写方法,适合快速修改少量字段的场景,但如果修改的字段位置靠后,需要写出前面的所有字段占位符,更适合字段数量不多或修改位置靠前的情况。
内容的提问来源于stack exchange,提问作者Elias Schoof
相关产品推荐
相关产品推荐

