Unit Test Contracts mit Default Methoden

“I think when you hear the phrase ‘it’s just test code’. To me that’s a code smell.”

Alan Page

Lange Zeit habe ich mir das JUnit 5 Feature Test Interfaces nicht angeschaut. Vermutlich weil mir default Methoden in Java Interfaces noch immer ein wenig suspekt sind. Langsam freunde ich mich aber mit diesen eigenartigen Schimären an und freue mich, endlich einen schönen Anwendungsfall für Test Interfaces gefunden zu haben.

Auf der Homepage von JUnit5 gibt es ein Beispiel für den Einsatz von Test Interfaces um Equals und Comparable Eigenschaften zu testen. Wie so oft fragt man sich dann, was sonst noch möglich wäre. Der reale Einsatz in einem meiner Projekte lies dann aber lange auf sich warten.

In dem Junit5 Beispiel werden Interface Contracts vorgestellt um die Vereinbarungen bzgl. eines Interfaces zu prüfen. Diese Idee kann man weiter fassen um gemeinsame Eigenschaften von Komponenten zu prüfen.

Wie in vielen Beitragen zuvor, bemühe ich einmal mehr mein Ancestor Projekt, dem ich vor längerer Zeit eine Spring Boot Rest Schnittstelle spendierte. Dort sind Controller implementiert um Familienmitglieder abzufragen und Stammbäume zu generieren. Diese Controller haben einige gemeinsame Anforderungen, die ich gerne zentral prüfen möchte, ohne eine Vererbungshierarchie bei meinen Testklassen realisieren zu müssen.

Ein Controller Test schaut in meinem Projekt in etwa wie das folgende Beispiel aus. Es existieren Tests für das ordnungsgemäße Funktionieren und ein paar Tests, die Authentisierung und Autorisierung für den Endpoint prüfen.

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class GetAncestorTest {
  @Autowired
  private MockMvc mockMvc;
  @MockBean
  private GedcomService service;

  @Test
  public void testAllAnchestorForbidden() throws Exception {
    mvc.perform(get("/ancestors")).andExpect(status().isUnauthorized());
  }

  @Test @WithMockUser(roles = "GUEST")
  public void testAllAnchestorWrongRole() throws Exception {
    mvc.perform(get("/ancestors")).andExpect(status().isForbidden());
  }

  @Test @WithMockUser(roles="user")
  void getUser() throws Exception {
    Mockito.when(service.findAll()).thenReturn(Collections.emptyList());
    mockMvc.perform(get("/ancestors")).andExpect(status().isOk());
  }
}

Bei vielen Endpointen und ordentlichen Unit Tests produziert man eine Unmenge an Tests mit dem Namen forbidden und unauthorized. Um diesen unschönen Umstand zu entgehen, erstellen wir nun mit einem Test Interface einen Contract für unsere Sicherheitsvorgaben.

public interface SecureControllerContract {
    MockHttpServletRequestBuilder createBuilder();

  @Test
  default void unauthorized(@Autowired MockMvc mockMvc) throws Exception {
    mvc.perform(createBuilder()).andExpect(status().isUnauthorized());
  }

  @Test @WithMockUser(roles = "GUEST")
  default void forbidden(@Autowired MockMvc mockMvc) throws Exception {
    mvc.perform(createBuilder()).andExpect(status().isForbidden());
  }
}

Das Interface enthält zwei default Methoden, die den Methoden in der Test Klasse recht ähnlich sind. Der einzige augenfällige Unterschied ist der @Autowired Parameter um das MockMvc Object zu erhalten. Die Methode createBuilder dient dazu, das Objekt zu erhalten, auf dem getestet werden soll.

Mit diesem SecureControllerContract können wir jetzt unsere Controller Test Klassen versehen und erhalten folgende gekürzte Version.

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class GetAncestorTest implements SecureControllerContract {
  @Autowired
  private MockMvc mockMvc;
  @MockBean
  private GedcomService service;

  @Test @WithMockUser(roles="user")
  void getUser() throws Exception {
    Mockito.when(service.findById(1)).thenReturn(Optional.of(new Person()));
    mockMvc.perform(createBuilder() ).andExpect(status().isOk());
  }

  public MockHttpServletRequestBuilder createBuilder() {
    return get("/ancestors");
  }
}

Werden die Testmethoden dieser Klasse ausgeführt, dann werden auch die beiden default Methoden aus dem Interface beachtet, da sie mit der @Test Annotation versehen sind.

Alle weiteren Controller Tests können durch implementieren des SecureControllerContract ihren Endpoint auch auf die geforderten Security Anforderungen testen. Der Code bleibt zentral und kann durch weitere Tests ergänzt und die Testklassen um weitere Test Contracts erweitert werden.