Tutorial - test driven development (TDD)

In this tutorial we will look at how to use Cover Plugin for IntelliJ when using test-driven development (TDD) for implementing a new feature.

To follow this part of the tutorial on your own, please do the following steps:

  • git clone https://github.com/Diffblue-benchmarks/Spring-petclinic

  • cd Spring-petclinic

  • git checkout a-test-tdd

  • Open the project in IntelliJ.

  • Navigate to the OwnerController class.

1. We are going to add a new feature to the OwnerController class (that is specified as follows):

   /**
   * @GetMapping("/owners") public String processFindForm(Owner owner, ...)
   * Look up the owner in the database by the given last name.
   * If a single owner is found, redirect to /owners/{ownerId}.
   * If several owners are found, allow selection of an owner in owners/ownersList.
   */

TDD requires us to write the tests before implementing the functionality. So, we start by manually implementing tests for this new processFindForm method.

2. We first have to set up the test class wiring the required beans:

@WebMvcTest(controllers = {OwnerController.class})
@ExtendWith(SpringExtension.class)
public class OwnerControllerTest {

  @Autowired
  private MockMvc mockMvc;

  @MockBean(name = "ownerRepository")
  private OwnerRepository ownerRepository;

  @MockBean(name = "visitRepository")
  private VisitRepository visitRepository;
...

3. We then add a test method for the case where a single owner is found and we redirect to that owner:


@Test
public void testProcessFindForm_singleOwner() throws Exception {
  Owner owner = new Owner();
  owner.setLastName("Doe");
  owner.setId(1);
  owner.setCity("Oxford");
  owner.setAddress("42 Main St");
  owner.setFirstName("Jane");
  owner.setTelephone("4105551212");
  when(this.ownerRepository.findByLastName(or(isA(String.class), isNull())))
     .thenReturn(Collections.singletonList(owner));
  this.mockMvc.perform(get("/owners").param("lastName", "Doe"))
     .andExpect(status().is3xxRedirection())
     .andExpect(view().name("redirect:/owners/1"));
}

4. We then write a test for the case where multiple owners are found and we display the list for selecting an owner:


@Test
public void testProcessFindForm_severalOwners() throws Exception {

  Owner owner1 = new Owner();
  owner1.setLastName("Doe");
  owner1.setId(1);
  owner1.setCity("Oxford");
  owner1.setAddress("42 Main St");
  owner1.setFirstName("Jane");
  owner1.setTelephone("4105551212");

  Owner owner2 = new Owner();
  owner2.setLastName("Doe");
  owner2.setId(1);
  owner2.setCity("Oxford");
  owner2.setAddress("1 High St");
  owner2.setFirstName("John");
  owner2.setTelephone("5121241055");

  when(this.ownerRepository.findByLastName(or(isA(String.class), isNull())))
     .thenReturn(Arrays.asList(owner1, owner2));

  this.mockMvc.perform(get("/owners").param("lastName", "Doe"))
     .andExpect(status().isOk())
     .andExpect(view().name("owners/ownersList"));
}

These tests should fail because we haven’t implemented the corresponding functionality yet.

5. The next step is to implement processFindForm:

public class OwnerController {

@GetMapping("/owners")
public String processFindForm(
    Owner owner, BindingResult result, Map<String, Object> model) {

  Collection<Owner> results = this.owners.findByLastName(owner.getLastName());
   if (results.size() == 1) {
     owner = results.iterator().next();
     return"redirect:/owners/" + owner.getId();
   } else {
     model.put("selections", results);
     return"owners/ownersList";
  }
}

6. We then run the manually written tests to check whether our implementation is correct. These tests succeed. So, are we finished? Let's use the Diffblue Cover plugin for IntelliJ to see what it produces for us.

7. We receive a couple of tests. Let’s review them:

public class OwnerControllerTest {

@Test
public void testProcessFindForm() throws Exception {

  when(this.ownerRepository.findByLastName(or(isA(String.class), isNull())))
     .thenReturn(new ArrayList<Owner>());

  this.mockMvc.perform(get("/owners"))
     .andExpect(status().isOk());
     .andExpect(view().name("owners/ownersList"));
}

This first test shows us behaviour for the case where no owner is found. That’s a case we haven’t considered in the requirements and therefore missed it when we wrote our manual tests. Thus our implementation might also be incorrect in that situation.

8. We have to amend our requirements to make them complete, i.e. we need to define the behaviour for the case where no owner is found. In this case we don’t want to show the owner selection list, but instead redirect back to the find owners page and show an error:

   /**
   * @GetMapping("/owners") public String processFindForm(Owner owner, …)
   * If no owner is found, return to owners/findOwners.
   * Look up the owner in the database by the given last name.
   * If a single owner is found, redirect to /owners/{ownerId}.
   * If several owners are found, allow selection of an owner in owners/ownersList.
   */

9. We also need to modify the test to make it match with this additional requirement:

public class OwnerControllerTest {

@Test
public void testProcessFindForm_notFound() throws Exception {

  when(this.ownerRepository.findByLastName(or(isA(String.class), isNull())))
     .thenReturn(new ArrayList<Owner>());

  this.mockMvc.perform(get("/owners"))
     .andExpect(status().isOk());
     .andExpect(view().name("owners/findOwners"));
}

10. This test is now expected to fail because our implementation doesn’t consider this unforeseen requirement yet. Let’s implement it:

public class OwnerController {

@GetMapping("/owners")
public String processFindForm(
    Owner owner, BindingResult result, Map<String, Object> model) {

  Collection<Owner> results = this.owners.findByLastName(owner.getLastName());
  if (results.isEmpty()) {
    result.rejectValue("lastName", "notFound", "not found");
    return"owners/findOwners";
  } else if (results.size() == 1) {
     owner = results.iterator().next();
     return "redirect:/owners/" + owner.getId();
  } else {
     model.put("selections", results);
     return "owners/ownersList"**;
  }
}

11. The test should pass now, but we have one more test to review:

public class OwnerControllerTest {

@Test
public void testProcessFindForm1 throws Exception {
  Owner owner = new Owner();
  owner.setLastName("Doe");
  owner.setId(1);
  owner.setCity("Oxford");
  owner.setAddress("42 Main St");
  owner.setFirstName("Jane");
  owner.setTelephone("4105551212");

  when(this.ownerRepository.findByLastName(or(isA(String.class), isNull())))
     .thenReturn(Collections.singletonList(owner));

  this.mockMvc.perform(get("/owners"))
     .andExpect(status().is3xxRedirection())
     .andExpect(view().name("redirect:/owners/1"));
}

12. In this test we search for owners without actually specifying a last name. What should happen in this case? It’s not specified in our requirements. Again, we need to fill this gap in the requirements:

   /**
   * @GetMapping("/owners") public String processFindForm(Owner owner, …)
   * If no owner is found, return to owners/findOwners.
   * Look up the owner in the database by the given last name.
   * If a single owner is found, redirect to /owners/{ownerId}.
   * If several owners are found, allow selection of an owner in owners/ownersList.*
   * If no last name is given, allow selection among all owners.
   */

The above test is correct as there is only a single owner returned in our mock. We could add another test that returns multiple owners and redirects to owners/ownersList to cover that case too.

13. To complete the implementation, we make the following change:

public class OwnerController {

@GetMapping("/owners")
public String processFindForm(
    Owner owner, BindingResult result, Map<String, Object> model) {
  // allow parameterless GET request for /owners to return all records
  if (owner.getLastName() == null) {
    owner.setLastName(""); // empty string signifies broadest possible search
  }
  Collection<Owner> results = this.owners.findByLastName(owner.getLastName());
  if (results.isEmpty()) {
    result.rejectValue("lastName", "notFound", "not found");
    return "owners/findOwners";
  } else if (results.size() == 1) {
     owner = results.iterator().next();
     return "redirect:/owners/" + owner.getId();
  } else {
     model.put("selections", results);
     return "owners/ownersList";
  }
}

14. All the tests should pass now and cover the entire implementation.

The diagram below shows our journey through this tutorial:

Summary

In this use case, we started the TDD process as usual by analyzing the requirements and implementing the unit tests. Then we implemented the feature to make the unit tests pass. At this point we would usually stop and commit the code. The value add that Diffblue Cover Plugin for IntelliJ provides for us is that it gives us additional tests that might include cases that we haven’t considered in our initial requirements analysis. That means that Diffblue Cover helps us identify gaps in the requirements. We did that by reviewing the tests Diffblue Cover created for us, amending the requirements with the missing cases and adapting the unit tests to match these unforeseen requirements. We then completed the implementation to make the unit tests pass.

Last updated