Java records: 如何使用默认值创建测试装置?

huangapple go评论59阅读模式
英文:

Java records: How to create test fixtures with defaults?

问题

以下是要翻译的部分:

我想对一个以记录类作为输入的方法进行单元测试。记录类相当大,该方法实现了复杂的业务逻辑。为了测试这个逻辑,我需要创建许多测试记录。每个测试记录都是基于默认记录的,并更改一个或多个字段。

我该如何高效地定义所有这些测试记录?

一个示例的记录类和方法:

record Customer(
  String name,
  int ageInYears,
  boolean isPremiumCustomer,
  boolean isRegisteredInApp,
  int totalNumberOfPurchases,
  int totalPurchaseValue,
  int numberOfPurchasesLastMonth,
  int purchaseValueLastMonth
) {}

---

class DiscountCalculator {

  /**
   * 计算给定客户的折扣百分比。
   */
  static int calculateDiscountFor(Customer customer) {
    // ...
  }
}

我的测试策略是从一个“标准”客户开始:

class DiscountCalculatorTest {

  DEFAULT_CUSTOMER = new Customer("Jane Doe", 45, false, false, 10, 100, 1, 10);

  @Test
  void defaultCase() {
    // given
    var customer = DEFAULT_CUSTOMER;

    // when
    var result = calculateDiscountFor(customer);

    // default customer receives no discount
    assertThat(result).isEqualTo(0);
  }
}

现在,我想通过更改默认客户的一些值来检查逻辑,如下所示:

class DiscountCalculatorTest {

  ...

  @Test
  void discountForSeniors() {
    // given
    var customer = DEFAULT_CUSTOMER;
    // 这在Java记录中不起作用
    customer.setAgeInYears(67);
    
    // when
    var result = calculateDiscountFor(customer);

    // then
    // 老年人享受5%的折扣
    assertThat(result).isEqualTo(5);
  }

  @Test
  void discountForPowerUsers() {
    // given
    var customer = DEFAULT_CUSTOMER;
    // 再次,这也不起作用
    customer.setTotalPurchaseValue(10000);

    // when
    var result = calculateDiscountFor(customer);

    // then
    // 电子商务用户享受10%的折扣
    assertThat(result).isEqualTo(10);
  }

  // 还有更多测试...
}

当然,setter方法不起作用,因为Java记录是不可变的。我想核心问题是Java不支持方法参数的默认值。那么,如何高效地创建所有这些测试装置,其中每个字段都有一个默认值,除了一个(或两个/三个)?

一些我尝试过的方法:

  1. 望远镜式装置:
class CustomerFixtures {
  Customer defaultCustomer() {
    return defaultCustomer(45);
  }

  Customer defaultCustomer(int ageInYears) {
    return new Customer("Jane Doe", ageInYears, false, false, 10, 100, 1, 10);
  }
}

这导致了大量的样板代码,并且在示例中多个字段具有相同类型(例如ageInYearstotalPurchaseValue)时会出错。

  1. 模拟getter方法的返回值,比如Mockito.when(customer.getAgeInYears()).thenReturn(67)。这不起作用,因为Mockito不能模拟最终类。

  2. 复制粘贴疯狂:

class DiscountCalculatorTest {

  ...

  @Test
  void discountForSeniors() {
    // given
    var customer = new Customer("Jane Doe", 67, false, false, 10, 100, 1, 10);
    
    ...
  }

  @Test
  void discountForPowerUsers() {
    // given
    var customer = new Customer("Jane Doe", 45, false, false, 10, 10000, 1, 10);

    ...
  }

  ...
}

不要在家尝试这个。(绝对不要在工作中尝试)

  1. 基于 Lombok Builder 的奇怪解决方法:
class CustomerFixtures {

  @Builder
  private static Customer buildCustomer(String name, int ageInYears, boolean isPremiumCustomer,
    boolean isRegisteredInApp, int totalNumberOfPurchases, int totalPurchaseValue,
    int numberOfPurchasesLastMonth, int purchaseValueLastMonth) {
      return new Customer(name, ageInYears, isPremiumCustomer, isRegisteredInApp,
        totalNumberOfPurchases, totalPurchaseValue, numberOfPurchasesLastMonth,
        purchaseValueLastMonth);
  }

  public static CustomerFixtures.Builder customerBuilder() {
    return CustomerFixtures.builder()
      .name("Jane Doe")
      .ageInYears(45)
      // ...
      .purchaseValueLastMonth(10);
}

使用这个,单元测试可以使用var customer = customerBuilder().ageInYears(67).build();来设置他们的测试记录。这看起来高效,但感觉有些巧妙。

英文:

I want to unit test a method which takes a record class as input. The record class is fairly large and the method implements complex business logic. To test the logic, I need to create a lot of test records. Each test record is based on a default record and changes one (or a few) fields.

How can I efficiently define all those test records?

An example record class and method:

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) {
    // ...
  }
}

My testing strategy is to start with a "standard" customer:

class DiscountCalculatorTest {

  DEFAULT_CUSTOMER = new Customer("Jane Doe", 45, false, false, 10, 100, 1, 10);

  @Test
  void defaultCase() {
    // given
    var customer = DEFAULT_CUSTOMER;

    // when
    var result = calculateDiscountFor(customer);

    // default customer receives no discount
    assertThat(result).isEqualTo(0);
  }
}

Now I would like to check the logic by changing some values of the default customer, like so:

class DicountCalculatorTest {

  ...

  @Test
  void discountForSeniors() {
    // given
    var customer = DEFAULT_CUSTOMER;
    // this won't work for Java record
    customer.setAgeInYears(67);
    
    // when
    var result = calculateDiscountFor(customer);

    // then
    // seniors receive 5% discount
    assertThat(result).isEqualTo(5);
  }

  @Test
  void discountForPowerUsers() {
    // given
    var customer = DEFAULT_CUSTOMER;
    // again, this won't work
    customer.setTotalPurchaseValue(10000);

    // when
    var result = calculateDiscountFor(customer);

    // then
    // power users receive 10% discount
    assertThat(result).isEqualTo(10);
  }

  // a dozen more tests...
}

Of course, the setter method does not work because Java records are immutable. I guess the core problem is that Java does not support default values for method arguments. So how can I efficiently create all those test fixtures where each field has a default value except one (or two/three)?


Some approaches I have tried:

  1. Telescoping fixtures:
class CustomerFixtures {
  Customer defaultCustomer() {
    return defaultCustomer(45);
  }

  Customer defaultCustomer(int ageInYears) {
    return new Customer("Jane Doe", ageInYears, false, false, 10, 100, 1, 10);
  }
}

This leads to huge amounts of boilerplate and breaks if multiple fields have the same type (like ageInYears and totalPurchaseValue in the example).

  1. Mocking the return value of getter methods, like Mockito.when(customer.getAgeInYears()).thenReturn(67). This does not work because Mockito cannot mock final classes.

  2. Copy-paste madness:

class DicountCalculatorTest {

  ...

  @Test
  void discountForSeniors() {
    // given
    var customer = new Customer("Jane Doe", 67, false, false, 10, 100, 1, 10);
    
    ...
  }

  @Test
  void discountForPowerUsers() {
    // given
    var customer = new Customer("Jane Doe", 45, false, false, 10, 10000, 1, 10);

    ...
  }

  ...
}

Don't try this at home. (And definitely not at work).

  1. A weird workaround based on Lombok Builder:
class CustomerFixtures {

  @Builder
  private static Customer buildCustomer(String name, int ageInYears, boolean isPremiumCustomer,
    boolean isRegisteredInApp, int totalNumberOfPurchases, int totalPurchaseValue,
    int numberOfPurchasesLastMonth, int purchaseValueLastMonth) {
      return new Customer(name, ageInYears, isPremiumCustomer, isRegisteredInApp,
        totalNumberOfPurchases, totalPurchaseValue, numberOfPurchasesLastMonth,
        purchaseValueLastMonth);
  }

  public static CustomerFixtures.Builder customerBuilder() {
    return CustomerFixtures.builder()
      .name("Jane Doe")
      .ageInYears(45)
      // ...
      .purchaseValueLastMonth(10);
}

With this, unit tests can set up their test record with var customer = customerBuilder().ageInYears(67).build();. This seems efficient but feels hacky.

答案1

得分: 3

今天,Lombok的构建器(或类似的方法,比如手动编写、让你的IDE生成并手动维护,或者使用类似Lombok的方式,在接口或文本文件中定义结构,然后让注解处理器为你生成public record Customer源文件)是唯一可行的选择。然而,你似乎忽略了Lombok构建器支持的一个非常有用的方面:toBuilder()。你可以这样写:

@Builder(toBuilder = true)
public record Customer(String name, int ageInYears, ...) {}

class TestCustomer {
  private static final Customer DEFAULT_CUSTOMER =
    new Customer("Jane Doe", 45, false, false, 10, 100, 1, 10);

  public void testSenior() {
    int discount = calculateDiscountFor(DEFAULT_CUSTOMER.toBuilder()
      .ageInYears(67)
      .build());

    assertThat(discount).isEqualTo(10);
  }
}

在将来的某个时候,Java将引入with。你将能够这样写:

public class TestCustomer {
  private static final Customer DEFAULT_CUSTOMER =
    new Customer("Jane Doe", 45, false, false, 10, 100, 1, 10);

  public void testSenior() {
    int discount = calculateDiscountFor(DEFAULT_CUSTOMER with {
      ageInYears = 67;
    });

    assertThat(discount).isEqualTo(10);
  }
}

请注意:1. 如果你不熟悉Java语言更新如何在OpenJDK邮件列表上讨论的话,语法通常是不相关的 - with会成为一个关键字吗?它会看起来像这样吗?谁知道。谁在乎。这尚未讨论过,也不会在其他相关事项都已经充分讨论并建立明确的计划之后才会讨论。因此,不要关注它的外观。关注的是你可以将一个代码块传递给一个对象,该对象[A]将该对象解构为本地变量并在该块的作用域内自动声明这些局部变量,以及[B]在块的末尾通过传递每个解构的局部变量来构建一个新实例。

英文:

Today, lombok builder (or something like it - such as writing it by hand, letting your IDE generating it and then maintaining it by hand, or using one of the lombok-esques where you define the structure in an interface or text file and let the Annotation Processor generate the public record Customer source file for you) is the only feasible answer. However, you appear to have a missed a rather useful aspect of lombok's builder support: toBuilder(). You can write this:

@Builder(toBuilder = true)
public record Customer(String name, int ageInYears, ...) {}

class TestCustomer {
  private static final Customer DEFAULT_CUSTOMER =
    new Customer("Jane Doe", 45, false, false, 10, 100, 1, 10);

  public void testSenior() {
    int discount = calculateDiscountFor(DEFAULT_CUSTOMER.toBuilder()
      .ageInYears(67)
      .build());

    assertThat(discount).isEqualTo(10);
  }

}

In some future, java is introducing with. You would be able to write<sup>1</sup>:

public class TestCustomer {
  private static final Customer DEFAULT_CUSTOMER =
    new Customer(&quot;Jane Doe&quot;, 45, false, false, 10, 100, 1, 10);

  public void testSenior() {
    int discount = calculateDiscountFor(DEFAULT_CUSTOMER with {
      ageInYears = 67;
    });

    assertThat(discount).isEqualTo(10);
  }
}

<sup>1</sup> In case you are not familiar with how java language updates are debated on the OpenJDK mailing lists: Syntax is generally irrelevant - is with going to become a keyword? Is that how it will look? Who knows. Who cares. It hasn't been debated yet and won't be until just about everything else relevant has been debated at length and a clear plan forward is already established. Hence, don't focus on what that looks like. Focus on the notion that you can pass a code block to an object which [A] deconstructs that object into local variables and auto-declares these locals within the scope of said block, and [B] at the end of the block, constructs a new instance by passing each deconstructed local variable.

答案2

得分: 1

在我看来,建造者模式是解决这个问题的最佳方法。正如你所强调的,使用伸缩法会使代码变得冗长,就像《战争与和平》一样长!

建造者模式不应该感觉像是一种笨拙的方法,它是一种经过验证的模式,有多种实现方式。我过去曾使用过 Lombok,或者你还可以使用 @FreeBuilder,你可以在这里找到它 - https://www.baeldung.com/java-builder-pattern-freebuilder

英文:

In my opinion, the Builder pattern is the best approach to this problem. As you've highlighted with telescoping it can grow to be the length of war and peace!

The Builder pattern shouldn't feel hacky, its a tried and tested pattern with several implementations to it. I've used Lombok in the past, alternatively there is @FreeBuilder which can be found here - https://www.baeldung.com/java-builder-pattern-freebuilder

huangapple
  • 本文由 发表于 2023年7月10日 18:33:25
  • 转载请务必保留本文链接:https://go.coder-hub.com/76652896.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定