Search code examples
javaunit-testingmockitojava-record

Java records: How to create test fixtures with defaults?


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.


Solution

  • 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 write1:

    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 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.