Dragan Marjanovic

Dragan Marjanovic

Defaulted Builders for UnitTestData

TL;DR

Tests should be explicit about what they are testing. Large UnitTestData files generally hide what is being exercised, whilst standalone tests generally have significant boilerplate overhead. Using builders with consistent default values we can find a "happy medium" - expressive whilst minimising duplication.

Status Quo

Generally, I've found Unit Tests fall between two types:

  1. Helper heavy tests which make it difficult to understand what underlying property is being tested
  2. Standalone tests, relying on no helpers which end up being hard to read due to their verbosity

Test Spectrum

The Defaulted Builder approach sits somewhere in the middle.

Defaulted Builder Approach

The current approach I use for Unit Tests is to create a series of defaulted builders for different test objects and then override any needed properties in the test itself. The advantages of this approach are:

  1. Avoids telescoping constructor anti-pattern
  2. Allows for succinct test data creation with properties under test still being defined explicitly

The following examples illustrate the approach:

// file: UnitTestData.java

public Car.Builder car() {
  return Car.builder()
    .rego("071YAM")
    .make("Mazda")
    .model("MX-5")
    .year(2004)
    .insured(true);
}
// file: VehicleTests.java

@Test
public void test_insuranceProvider_insuresTheCar() {
  Car uninsuredCar = UnitTestData.car()
                        .insured(false)
                        .build();
  Car insuredCar = UnitTestData.car()
                        .insured(true)
                        .build();

  insurer.insureCar(uninsuredCar);

  assertThat(uninsuredCar.isInsured()).toBeTrue();
  assertThat(uninsuredCar).isEqualTo(insuredCar);
}

This test makes it explicit what parameters are being set (insured) as well as that these parameters are the only ones we really care about as part of this test - the remaining properties are hidden from us.

UnitTestData Dumping Ground / Telescoping Constructor

This is unfortunately one of the most common approaches I've seen. A UnitTestData file is created and serves as somewhat of a dumping ground for helper functions. Eventually this seems to result in two things:

  1. Telescoping constructor anti-pattern [2] - ending up with a lot of different constructors which can be confusing (particularly in the case where the parameters might all be of the same type eg. String)
  2. Properties set within UnitTestData as defaults are tested as if they were explicitly defined in the test
// file: UnitTestData.java

public Car buildCar(int year) {
  return buildCar()
}

public Car buildCar(boolean insured) {
  return buildCar("071YAM", "Mazda", "MX-5", 2004, insured);
}

public Car buildCar() {
  return buildCar("071YAM", "Mazda", "MX-5", 2004, true);
}

public Car buildCar(String rego, String make, String model, int year, boolean insured) {
    return Car.builder()
        .rego(rego)
        .make(make)
        .model(model)
        .year(year)
        .insured(insured)
        .build();
}
// file: VehicleTests.java

@Test
public void test_insuranceProvider_insuresTheCar() {
  Car uninsuredCar = buildCar(false);
    // Unclear whether insured=true without looking at UnitTestData.java
  Car insuredCar = buildCar();

  insurer.insureCar(uninsuredCar);

  assertThat(uninsuredCar.isInsured()).toBeTrue();
  assertThat(uninsuredCar).isEqualTo(insuredCar);
}

Self Contained Tests

This goal of this approach is to have tests as "self contained" as possible and they should be readable without really needing to jump into other files or code.

// file: UnitTestData.java

// Nothing here - we avoid using shared test data
// file: VehicleTests.java

@Test
public void test_insuranceProvider_insuresTheCar() {
    Car uninsuredCar = Car.builder()
        .rego("071YAM")
        .make("Mazda")
        .model("MX-5")
        .year(2004)
        .insured(false)
        .build();
    Car insuredCar = Car.builder()
        .rego("071YAM")
        .make("Mazda")
        .model("MX-5")
        .year(2004)
        .insured(true)
        .build();

    insurer.insureCar(uninsuredCar);

    assertThat(uninsuredCar.isInsured()).toBeTrue();
    assertThat(uninsuredCar).isEqualTo(insuredCar);
}

Whilst not a huge issue in this toy example, the test is longer than the others and more critically it's not immediately clear whether fields such as rego, make, model, and year matter at all or are just dummy data.

Summary

Ideally, a test is explicit about what it is testing and what it is not. The Defaulted Builder approach helps achieve this while minimising test verbosity.

References

[1] https://refactoring.guru/design-patterns/builder
[2] http://www.captaindebug.com/2011/05/telescoping-constructor-antipattern.html#.YWLsAp4zZqs