Untangle Your
Spaghetti Test Code

Why does the quality of test code matter?

Its intention should be immediately obvious.

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

Its functionality should not be obscured.

Its results should be meaningful.

Its failure causes should be easy to find.

Its maintenance should not be hard.

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

Tests rely on non-obvious setup mechanisms and/or test data

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() {
    var response = restTemplate
        .getForEntity(baseUrl + "/unicorns", 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.

Directly setup the database according to your tests' needs

Test Data Manager

@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() {
    var response = restTemplate
        .getForEntity(baseUrl + "/unicorns", 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);
  }
}

Test Data Manager

@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() {
    testDataManager
        .clean()
        .withUnicorn(unicorn);

    var response = restTemplate
        .getForEntity(baseUrl + "/unicorns", 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);
  }
}

Test Data Manager

@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

@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() {
    testDataManager
        .clean()
        .withUnicorn(unicorn);

    var response = restTemplate
        .getForEntity(baseUrl + "/unicorns", 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);
  }
}

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

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

Magic Values

@Test
void ageWorksHereAlso() {
  var gilly =
      new Unicorn(
          randomUUID(), // DOESN'T MATTER… OBVIOUSLY
          "Gilly", // NOT IMPORTANT
          ManeColor.RED, // JUST RANDOM
          111, // NEVER MIND
          11, // COULD BE ANY NUMBER
          LocalDate.now().minusYears(62).plusDays(1)); // IMPORTANT

  assertThat(gilly.age()).isEqualTo(61); // WHY?
}

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

E.g. Data objects are being created with all fields

Even if the set value doesn't matter for the case

@Test
void ageWorksHereAlso() {
  var gilly =
      new Unicorn(
          randomUUID(), // DOESN'T MATTER… OBVIOUSLY
          "Gilly", // NOT IMPORTANT
          ManeColor.RED, // JUST RANDOM
          111, // NEVER MIND
          11, // COULD BE ANY NUMBER
          LocalDate.now().minusYears(62).plusDays(1)); // IMPORTANT

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

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.

Long Arrange

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 ageWorksHereAlso() {
  var gilly =
      new Unicorn(
          randomUUID(), // DOESN'T MATTER
          "Gilly", // NOT IMPORTANT
          ManeColor.RED, // JUST RANDOM
          111, // NEVER MIND
          11, // COULD BE ANY NUMBER
          LocalDate.now().minusYears(62).plusDays(1)); // IMPORTANT

  assertThat(gilly.age()).isEqualTo(61); // WHY 61?
}

Test Data Builder

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

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

Test Data Builder

@Test
void ageWorksHereAlso() {
  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

Long/Technical Act

There are multiple acts in one test case

The act is bloated with technical details

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

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

  @Test
  void postNewUnicorn() {
    var garryJson = aUnicorn().buildJson();

    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);
  }
}

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/Technical Act

Use a method to avoid code duplications and make the unit under test more obvious

Act Helper (Method)

Act Helper (Method)

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

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

  @Test
  void postNewUnicorn() {
    var garryJson = aUnicorn().buildJson();

    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);
  }
}
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Autowired
  FrontendActHelper frontend;

  @Test
  @DirtiesContext
  void postNewUnicorn() {
    var garryJson = aUnicorn().buildJson();

    var response = frontend.postUnicorn(garryJson);
    
    assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(201));
    assertThat(response.getHeaders().get("Location")).isNotNull().hasSize(1);
  }
}

Act Helper (Method)

@Component
class FrontendActHelper {

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

  public Response postUnicorn(String unicornJson) {
    return restTemplate.exchange(
            post("%s/unicorns/".formatted(baseUrl))
                .header("Content-Type", "application/json")
                .body(garryJson),
            String.class);
  }
}

Act Helper (Method)

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

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

  @Autowired
  FrontendActHelper frontend;
  
  @Autowired
  TestDataManager testDataManager;

  @Test
  void getSingleUnicornWorksAndReturnsData() throws JsonProcessingException {
    var unicorn = aUnicorn().build();
    testDataManager.clear().withUnicorn(unicorn);
  
    var response = frontend.getUnicorn(unicorn.id());

    var unicornData = 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

Assert Helper (Method)

Group long asserts that check one logical thing in verification methods

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

  @Autowired
  FrontendActHelper frontend;
  
  @Autowired
  TestDataManager testDataManager;
  
  @Autowired
  ObjectMapper objectMapper

  @Test
  void getSingleUnicornWorksAndReturnsData() throws JsonProcessingException {
    var unicorn = aUnicorn().build();
    testDataManager.clear().withUnicorn(unicorn);
  
    var response = frontend.getUnicorn(unicorn.id());

    var unicornData = objectMapper.readTree(response.getBody());
    assertThat(unicornData.get("name").asText()).isEqualTo("Grace");
    assertThat(unicornData.get("maneColor").asText()).isEqualTo("RAINBOW");
    assertThat(unicornData.get("hornLength").asInt()).isEqualTo(42);
    assertThat(unicornData.get("hornDiameter").asInt()).isEqualTo(10);
    assertThat(unicornData.get("dateOfBirth").asText()).isEqualTo("1982-02-19");
  }
}

Assert Helper (Method)

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

  @Autowired
  FrontendActHelper frontend;
  
  @Autowired
  TestDataManager testDataManager;
  
  @Autowired
  AssertHelper assertHelper;

  @Test
  void getSingleUnicornWorksAndReturnsData() {
    var unicorn = aUnicorn().build();
    testDataManager.clear().withUnicorn(unicorn);
  
    var response = frontend.getUnicorn(unicorn.id());

	assertHelper.assertEquals(response.getBody(), unicorn)
  }
}

Assert Helper (Method)

Assert Helper (Method)

@Component
class AssertHelper {
  
  @Autowired
  ObjectMapper objectMapper

  public void assertEquals(String unicornJson, Unicorn unicorn) throws JsonProcessingException {
    var unicornData = objectMapper.readTree(unicornJson);
    assertThat(unicornData.get("name").asText()).isEqualTo(unicorn.name());
    assertThat(unicornData.get("maneColor").asText()).isEqualTo(unicorn.maneColor());
    assertThat(unicornData.get("hornLength").asInt()).isEqualTo(unicorn.age());
    assertThat(unicornData.get("hornDiameter").asInt()).isEqualTo(unicorn.hornDiameter());
    assertThat(unicornData.get("dateOfBirth").asText()).isEqualTo(unicorn.dateOfBirth());
  }
}
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class ApplicationTest {

  @Autowired
  FrontendActHelper frontend;
  
  @Autowired
  TestDataManager testDataManager;
  
  @Autowired
  AssertHelper assertHelper;

  @Test
  void getSingleUnicornWorksAndReturnsData() {
    var unicorn = aUnicorn().build();
    testDataManager.clear().withUnicorn(unicorn);
  
    var response = frontend.getUnicorn(unicorn.id());

	assertHelper.assertEquals(response.getBody(), unicorn)
  }
}

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.

Assert Helper (Method)

Test case names don't reflect the actual test content

Names are chosen inconsistently

Lying/Inconsistent/Bad Names

@Test
void testHDNotGivenResultsIn500() {
  var larryJson = aUnicornJson()
      .hornDiameter(0)
      .build()
      
  var response = actHelper.postUnicorn(larryJson);

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

Lying/Inconsistent/Bad Names

class UnicornApiTest {

  @Test
  void getUnicornsWorksAndReturnsNonEmptyList() {}

  @Test
  void getSingleUnicornWorksAndReturnsData() {}

  @Test
  void postNewUnicornShouldWork() {}

  @Test
  void testHLTooMuchYields400() {}

  @Test
  void testHDNotGivenResultsIn500() {}
}

Lying/Inconsistent/Bad Names

class UnicornTest {

  @Test
  void ageWorks() { /* … */ }

  @Test
  void ageWorksHereToo() { /* … */ }

  @Test
  void ageWorksHereAlso() { /* … */ }

  @Test
  void negativeAge() { /* … */ }
}

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/Inconsistent/Bad Names

Apply a consistent test case naming scheme

Consistent & Concise Test Case Names

Consistent & Concise Test Case Names

Important info about a test:

<expectedBehavior>
<stateUnderTest>
<unitUnderTest>
<ClassUnderTest> +
<methodUnderTest>
class <ClassUnderTest>Test {
  
  void test<methodUnderTest>Should<expectedBehavior>With<stateUnderTest>() { … }
}

Consistent & Concise Test Case Names

Important info about a test:

<expectedBehavior>
<stateUnderTest>
<unitUnderTest>
<ClassUnderTest> +
<methodUnderTest>
class <ClassUnderTest>Test {
  
  void test<methodUnderTest>Should<expectedBehavior>With<stateUnderTest>() { … }

  void <methodUnderTest>_<stateUnderTest>_<expectedBehavior>() { … }
}

Consistent & Concise Test Case Names

Important info about a test:

<expectedBehavior>
<stateUnderTest>
<unitUnderTest>
<ClassUnderTest> +
<methodUnderTest>
class <ClassUnderTest>Test {
  
  void test<methodUnderTest>Should<expectedBehavior>With<stateUnderTest>() { … }

  void <methodUnderTest>_<stateUnderTest>_<expectedBehavior>() { … }

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

Consistent & Concise Test Case Names

Important info about a test:

<expectedBehavior>
<stateUnderTest>
<unitUnderTest>
<ClassUnderTest> +
<methodUnderTest>
class <ClassUnderTest>Test {
  
  void test<methodUnderTest>Should<expectedBehavior>With<stateUnderTest>() { … }

  void <methodUnderTest>_<stateUnderTest>_<expectedBehavior>() { … }

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

  void <methodUnderTest>Test.test<Counter>() {
    … // see arrange code for <stateUnderTest>
    … // see assert code for <expectedBehavior>
  }
}

Consistent & Concise Test Case Names

Important info about a test:

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

  @Test
  void ageWorks() {}

  @Test
  void ageWorksHereToo() {}

  @Test
  void ageWorksHereAlso() {}

  @Test
  void negativeAge() {}
}

Consistent & Concise Test Case Names

class UnicornTest {

  @Test
  void age_birthday_whole_years_ago() {}

  @Test
  void ageWorksHereToo() {}

  @Test
  void ageWorksHereAlso() {}

  @Test
  void negativeAge() {}
}

Consistent & Concise Test Case Names

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

  @Test
  void age_birthday_whole_years_ago() {}

  @Test
  void age_birthday_years_plus_ago() {}

  @Test
  void ageWorksHereAlso() {}

  @Test
  void negativeAge() {}
}

Consistent & Concise Test Case Names

class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}
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() {}
}

Consistent & Concise Test Case Names

class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}
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() {}
}

Consistent & Concise Test Case Names

class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}
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() {}
}

Consistent & Concise Test Case Names

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

Consistent & Concise Test Case Names

class UnicornApiTest {

  @Test
  void getUnicornsWorksAndReturnsNonEmptyList() {}

  @Test
  void getSingleUnicornWorksAndReturnsData() {}

  @Test
  void postNewUnicornShouldWork() {}

  @Test
  void testHLTooMuchYields400() {}

  @Test
  void testHDNotGivenResultsIn500() {}
}
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() {}
}
class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}

Consistent & Concise Test Case Names

class UnicornApiTest {

  @Test
  void GET_unicorns() {}

  @Test
  void GET_unicorn() {}

  @Test
  void POST_unicorn() {}

  @Test
  void POST_unicorn_invalid_hornLength() {}

  @Test
  void POST_unicorn_invalid_hornDiameter() {}
}
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() {}
}
class <ClassUnderTest>Test {
  void <methodUnderTest>_<stateUnderTest>() {
    … // see code for <expectedBehavior>
  }
}

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 & Concise Test Case 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

Behaviour Over-Specification

UnicornController
getUnicorn(id: String): Response
UnicornService
findUnicorn(id: String): Unicorn
UnicornRepository
findById(id: String): Unicorn
UnicornControllerTest
getUnicorn()

Behaviour Over-Specification

UnicornController
getUnicorn(id: String): Response
UnicornControllerTest
getUnicorn()

MOCK

@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())
}

Behaviour Over-Specification

UnicornController
getUnicorn(id: String): Response
UnicornControllerTest
getUnicorn()

MOCK

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

Instead of mocks use nullables to stub infrastructure components

Nullable Infrastructure

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())
}
UnicornController
getUnicorn(id: String): Response
UnicornControllerTest
getUnicorn()

MOCK

Nullable Infrastructure

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

  assertThat(response.getStatusCode())
      .isEqualTo(HttpStatusCode.valueOf(200));
}
UnicornController
getUnicorn(id: String): Response
UnicornControllerTest
getUnicorn()
UnicornService
findUnicorn(id: String): Unicorn
UnicornRepositoryNullable
findById(id: String): Unicorn

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.

Nullable Infrastructure

Slides

Cheat Sheet

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.

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

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

Untangle Your Spaghetti Test Code

By Michael Kutz

Untangle Your Spaghetti Test Code

  • 1,098