Untangle Your
Spaghetti Test Code

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Test Code Tangles

Tests rely on non-obvious setup mechanisms

Hidden Arrange

Hidden Arrange

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Value("http://localhost:${local.server.port}")
  String baseUrl;

  @Autowired TestRestTemplate restTemplate;
  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  void getUnicornsWorksAndReturnsNonEmptyList() throws JsonProcessingException {
    var response = restTemplate.getForEntity("%s/unicorns".formatted(baseUrl), String.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

    var body = objectMapper.readTree(response.getBody());

    assertThat(body).isNotNull();
    assertThat(body.isArray()).isTrue();
    assertThat(body.size()).isEqualTo(1);
  }
}
TRUNCATE TABLE
  unicorns;

INSERT
  INTO
    unicorns(
      id,
      name,
      mane_color,
      horn_length,
      horn_diameter,
      date_of_birth
     )
  VALUES(
    '44eb6bdc-a0c9-4ce4-b28b-86d5950bcd23',
    'Grace',
    'RAINBOW',
    42,
    10,
    '1982-2-19'
  );

/src/test/resources/data.sql

Hidden Arrange

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Literal values in the test code chosen for no immediately obvious reason

Magic Values

class UnicornTest {

  @Test
  void ageWorksHereAlso() {
    var gilly =
        new Unicorn(
            randomUUID(),
            "Gilly",
            ManeColor.RED,
            111,
            11,
            LocalDate.now().minusYears(62).plusDays(1));

    assertThat(gilly.age()).isEqualTo(61);
  }
}

Magic Values

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Magic Values

Long Arrange

Data objects are being created with all fields

Even if the set value doesn't matter

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Value("http://localhost:${local.server.port}")
  String baseUrl;

  @Autowired TestRestTemplate restTemplate;
  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  @DirtiesContext
  void postNewUnicorn() {
    var garryJson =
        objectMapper.writeValueAsString(
            Map.of(
                "name", "Garry",
                "maneColor", "BLUE",
                "hornLength", 37,
                "hornDiameter", 11,
                "dateOfBirth", "1999-10-12"));

    var response =
        restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(garryJson),
            String.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
    assertThat(response.getHeaders().get("Location")).isNotNull().hasSize(1);

    var anotherResponse =
        restTemplate.getForEntity(
            requireNonNull(response.getHeaders().get("Location")).getFirst(), String.class);

    assertThat(anotherResponse.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(200));
  }
}

Long Arrange

Long Arrange

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Duplicate Arrange/
Assert Code

Arrangement or Assertion code is duplicated in multiple test cases and classes

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Value("http://localhost:${local.server.port}")
  String baseUrl;

  @Autowired TestRestTemplate restTemplate;
  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  @DirtiesContext
  void postNewUnicorn() {
    var garryJson = objectMapper.writeValueAsString(
            Map.of(
                "name", "Garry",
                "maneColor", "BLUE",
                "hornLength", 37,
                "hornDiameter", 11,
                "dateOfBirth", "1999-10-12"));

    var response =
        restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(garryJson),
            String.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
    assertThat(response.getHeaders().get("Location")).isNotNull().hasSize(1);

    var anotherResponse =
        restTemplate.getForEntity(
            requireNonNull(response.getHeaders().get("Location")).getFirst(), String.class);

    assertThat(anotherResponse.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(200));
  }

  @Test
  @DirtiesContext
  void testHLZero() throws JsonProcessingException {
    var larryJson =
        objectMapper.writeValueAsString(
            Map.of(
                "name", "Larry",
                "maneColor", "BLUE",
                "hornLength", 0,
                "hornDiameter", 18,
                "dateOfBirth", "1999-10-12"));

    var response =
        restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(larryJson),
            List.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(400));
    assertThat(response.getHeaders().containsKey("Location")).isFalse();
    assertThat(response.getBody()).contains("hornLength must be between 1 and 100");
  }

  @Test
  @DirtiesContext
  void testHLTooMuch() throws JsonProcessingException {
    var larryJson =
        objectMapper.writeValueAsString(
            Map.of(
                "name", "Larry",
                "maneColor", "BLUE",
                "hornLength", 101,
                "hornDiameter", 18,
                "dateOfBirth", "1999-10-12"));

    var response =
        restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(larryJson),
            List.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(400));
    assertThat(response.getHeaders().containsKey("Location")).isFalse();
    assertThat(response.getBody()).contains("hornLength must be between 1 and 100");
  }

  @Test
  @DirtiesContext
  void testHDNotGiven() throws JsonProcessingException {
    var larryJson =
        objectMapper.writeValueAsString(
            Map.of(
                "name", "Larry",
                "maneColor", "BLUE",
                "hornLength", 66,
                "hornDiameter", 0,
                "dateOfBirth", "1999-10-12"));
                
    var response =
        restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(larryJson),
            List.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(400));
    assertThat(response.getHeaders().containsKey("Location")).isFalse();
    assertThat(response.getBody()).contains("hornDiameter must be between 1 and 40");
  }
}

Duplicate Arrange/
Assert Code

Duplicate Arrange/
Assert Code

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Long/Technical Act

There are multiple acts in one test case

The act is bloated with technical details

Long/Technical Act

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Value("http://localhost:${local.server.port}")
  String baseUrl;

  @Autowired TestRestTemplate restTemplate;
  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  @DirtiesContext
  void postNewUnicorn() {
    var garryJson = objectMapper.writeValueAsString(
            Map.of(
                "name", "Garry",
                "maneColor", "BLUE",
                "hornLength", 37,
                "hornDiameter", 11,
                "dateOfBirth", "1999-10-12"));

    var response =
        restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(garryJson),
            String.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
    assertThat(response.getHeaders().get("Location")).isNotNull().hasSize(1);

    var anotherResponse =
        restTemplate.getForEntity(
            requireNonNull(response.getHeaders().get("Location")).getFirst(), String.class);

    assertThat(anotherResponse.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(200));
  }
}

Long/Technical Act

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Long Assert

Verify multiple aspects in one test case

Check properties of data objects one by one

Long Assert

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Value("http://localhost:${local.server.port}")
  String baseUrl;

  @Autowired TestRestTemplate restTemplate;
  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  void getSingleUnicornWorksAndReturnsData() throws JsonProcessingException {
        restTemplate.getForEntity(
            "%s/unicorns/%s".formatted(baseUrl, "44eb6bdc-a0c9-4ce4-b28b-86d5950bcd23"),
            String.class);

    var unicornData = objectMapper.readTree(response.getBody());

    assertThat(unicornData.has("name")).isTrue();
    assertThat(unicornData.get("name").asText()).isEqualTo("Grace");

    assertThat(unicornData.has("maneColor")).isTrue();
    assertThat(unicornData.get("maneColor").asText()).isEqualTo("RAINBOW");

    assertThat(unicornData.has("hornLength")).isTrue();
    assertThat(unicornData.get("hornLength").asInt()).isEqualTo(42);

    assertThat(unicornData.has("hornDiameter")).isTrue();
    assertThat(unicornData.get("hornDiameter").asInt()).isEqualTo(10);

    assertThat(unicornData.has("dateOfBirth")).isTrue();
    assertThat(unicornData.get("dateOfBirth").asText()).isEqualTo("1982-02-19");
  }
}

Long Assert

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Long Assert

Multiple interactions with the unit under test in the same test case

Multiple Acts

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Value("http://localhost:${local.server.port}")
  String baseUrl;

  @Autowired TestRestTemplate restTemplate;
  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  @DirtiesContext
  void postNewUnicorn() {
    var garryJson =
        "{\"dateOfBirth\":\"1999-10-12\",\"hornDiameter\":11,\"hornLength\":37,\"maneColor\":\"BLUE\",\"name\":\"Garry\"}";
        
    var response =
        restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(garryJson),
            String.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
    assertThat(response.getHeaders().get("Location")).isNotNull().hasSize(1);

    var anotherResponse =
        restTemplate.getForEntity(
            requireNonNull(response.getHeaders().get("Location")).getFirst(), String.class);

    assertThat(anotherResponse.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(200));
  }
}

Multiple Acts

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Multiple Acts

Test case names don't reflect the actual test content

Names are chosen inconsistently

Lying Names

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Value("http://localhost:${local.server.port}")
  String baseUrl;

  @Autowired TestRestTemplate restTemplate;
  ObjectMapper objectMapper = new ObjectMapper();

  @Test
  void postInvalidUnicornYieldsA500Response() {
    var response = restTemplate
        .postForEntity(url, String.class);

    assertThat(response.getStatusCode())
        .isEqualTo(HttpStatusCode.valueOf(400));
    assertThat(response
        .getHeaders()
        .containsKey("Location"))
        .isFalse();
    assertThat(response.getBody())
        .contains("invalid unicorn");
  }
}

Lying Names

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Lying Names

Massive use of Mocks can lead to a very brittle test suite

Tests break even for trivial refactoring due to a behaviour over-specification

Behaviour Over-Specification

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Behaviour Over-Specification

Test Code Untangles

All tests have three parts (or less):

  1. Arrange / Given: sets everything up for the test (optional)
  2. Act / When: the actual interaction with the unit under test
  3. Assert / Then: checks the effects of act

Arrange, Act, Assert

Apply a consistent test case naming scheme

Consistent Test Case Names

class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}

Consistent Test Case Names

class UnicornTest {

  @Test
  void ageWorks() {}

  @Test
  void ageWorksHereToo() {}

  @Test
  void ageWorksHereAlso() {}

  @Test
  void negativeAge() {}
}
class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}

Consistent Test Case Names

class UnicornTest {

  @Test
  void age_birthday_whole_years_ago() {}

  @Test
  void ageWorksHereToo() {}

  @Test
  void ageWorksHereAlso() {}

  @Test
  void negativeAge() {}
}
class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}

Consistent Test Case Names

class UnicornTest {

  @Test
  void age_birthday_whole_years_ago() {}

  @Test
  void age_birthday_years_plus_ago() {}

  @Test
  void ageWorksHereAlso() {}

  @Test
  void negativeAge() {}
}
class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}

Consistent Test Case Names

class UnicornTest {

  @Test
  void age_birthday_whole_years_ago() {}

  @Test
  void age_birthday_years_plus_ago() {}

  @Test
  void age_birthday_years_minus_ago() {}

  @Test
  void negativeAge() {}
}
class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}

Consistent Test Case Names

class UnicornTest {

  @Test
  void age_birthday_whole_years_ago() {}

  @Test
  void age_birthday_years_plus_ago() {}

  @Test
  void age_birthday_years_minus_ago() {}

  @Test
  void age_birthday_in_future() {}
}

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Consistent Test Case Names

Split test cases with multiple acts with assumtions

Split with Assumptions

@Test
@DirtiesContext
void postNewUnicorn() {
  var garryJson = /* … */;

  var response = restTemplate.exchange(url, garryJson, /* … */);

  assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
  assertThat(response.getHeaders().get("Location")).isNotNull().hasSize(1);

  var location = response.getHeaders().get("Location").getFirst()

  var anotherResponse = restTemplate.getForEntity(location, String.class);

  assertThat(anotherResponse.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(200));
}

Split with Assumptions

@Test
@DirtiesContext
void postNewUnicorn() {
  var garryJson = /* … */;

  var response = restTemplate.exchange(url, garryJson, /* … */);

  assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
  assertThat(response.getHeaders().get("Location")).isNotNull().hasSize(1);
}

@Test
@DirtiesContext
void getLocationHeader() {
  var garryJson = /* … */;
  var postResponse = restTemplate.exchange(url, garryJson, /* … */);
  assumeThat(postResponse.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
  var location = response.getHeaders().get("Location").getFirst()

  var response = restTemplate.getForEntity(location, String.class);

  assertThat(anotherResponse.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(200));
}

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Split with Assumptions

Directly setup the database according to your tests' needs

Test Data Manager

@Test
void get_single() {
  testDataManager.withUnicorn(unicorn);

  var response =
      restTemplate.getForEntity(
          "%s/unicorns/%s".formatted(baseUrl, unicorn.id()), String.class);

  var unicornData = objectMapper.readTree(response.getBody());
  // …
}
@Component
public class TestDataManager {

  private final JdbcTemplate jdbcTemplate;

  public TestDataManager(DataSource daraSource) {
    this.jdbcTemplate = new JdbcTemplate(daraSource);
  }

  TestDataManager withUnicorn(Unicorn unicorn) {
    jdbcTemplate.update(
        """
            INSERT
              INTO
                  UNICORNS(
                      ID,
                      NAME,
                      MANE_COLOR,
                      HORN_LENGTH,
                      HORN_DIAMETER,
                      DATE_OF_BIRTH
                  )
              VALUES(
                  ?,
                  ?,
                  ?,
                  ?,
                  ?,
                  ?
              );
            """,
        unicorn.id(),
        unicorn.name(),
        unicorn.maneColor().name(),
        unicorn.hornLength(),
        unicorn.hornDiameter(),
        unicorn.dateOfBirth());
    return this;
  }

  TestDataManager clear() {
    jdbcTemplate.execute("TRUNCATE TABLE UNICORNS;");
    return this;
  }
}

Test Data Manager

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Test Data Manager

Use a Builder class to create whatever object is required for the test

Make massive use of defaults & data generators to pre-fill the objects

Test Data Builder

@Test
void age_birthday() {
  var gilly = new Unicorn(
      randomUUID(),
      "Gilly",
      ManeColor.RED,
      111,
      11,
      LocalDate.now().minusYears(62));

  assertThat(gilly.age()).isEqualTo(62);
}

Test Data Builder

@Test
void age_birthday() {
  var gilly = aUnicorn()
      .dateOfBirth(LocalDate.now().minusYears(62))
      .build();

  assertThat(gilly.age()).isEqualTo(62);
}

Test Data Builder

public class UnicornTestDataBuilder {

  private final SecureRandom random = new SecureRandom();
  private UUID id = randomUUID();
  private String name = "Gilly";
  private ManeColor maneColor = ManeColor.values()[random.nextInt(ManeColor.values().length)];
  private Integer hornLength = random.nextInt(1, 101);
  private Integer hornDiameter = random.nextInt(1, 41);
  private LocalDate dateOfBirth = LocalDate.of(2000, 1, 1);

  private UnicornTestDataBuilder() {}

  public static UnicornTestDataBuilder aUnicorn() {
    return new UnicornTestDataBuilder();
  }

  public UnicornTestDataBuilder id(UUID id) {
    this.id = id;
    return this;
  }

  public UnicornTestDataBuilder name(String name) {
    this.name = name;
    return this;
  }
  
  // …
  
  public Unicorn build() {
    return new Unicorn(id, name, maneColor, hornLength, hornDiameter, dateOfBirth);
  }
}

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Test Data Builder

Instead of mocks use nullables to stub infrastructure components

Nullable Infrastructure

@Test
void getUnicorn() {
  var gilly = aUnicorn().build();
  var serviceMock = mock(UnicornService);
  when(serviceMock.findUnicorn(anyString()))
      .thenReturn(gilly)
  var controller = new UnicornController(
      serviceMock);
      
  var response = controller.getUnicorn(gilly.id());

  assertThat(response.getStatusCode())
      .isEqualTo(HttpStatusCode.valueOf(200));
  verify(serviceMock, times(1)).findUnicorn(gilly.id())
}

Test Data Builder

@Test
void getUnicorn() {
  var gilly = aUnicorn().build();
  var repository = UnicornRepository
      .createNullable()
      .add(gilly);
  var controller = new UnicornController(
      new UnicornService(repository));
      
  var response = controller.getUnicorn(gilly.id());

  assertThat(response.getStatusCode())
      .isEqualTo(HttpStatusCode.valueOf(200));
}

Test Data Builder

Test code is code.
What's considered bad in production code, is bad in test code.

 

Its intention should be immediately obvious.
Its functionality shouldn't be obscured.
Its results should be meaningful.
Its failure causes should be easy to find.

Its maintenance shouldn't be hard.

Test Data Builder

Untangle Your Spaghetti Test Code

By Michael Kutz

Untangle Your Spaghetti Test Code

  • 264