Eine bessere Teststruktur dank Lambdas und Mockitos Answer

Keine Kommentare

Obwohl die Verwendung von Mock-Objekten kontrovers diskutiert wird, müssen wir als Entwickler sie von Zeit zu Zeit einsetzen. Die fast 6.000 Sterne, die Mockito auf GitHub hat, deuten darauf hin, dass andere dem zustimmen würden. Besonders, wenn es sich um Library-Klassen handelt, die wir nicht einfach instanziieren können, oder um Klassen, die z. B. eine HTTP-Verbindung herstellen, zeigen Mocks ihre Stärke. Um die Tests lesbarer zu machen, können uns Javas Lambdas und Mockitos Answer helfen.

Ein einfaches Beispiel

Eine Klasse, die ein guter Kandidat fürs Mocking ist, ist Springs RestTemplate. Für einen simplen und schnellen Unit-Test wollen wir in der Regel nicht den kompletten Spring Application Context hochfahren. Besser wäre es, das RestTemplate zu mocken und einige vorgefertigte Antworten zu benutzen. Als einfaches Beispiel für diesen Artikel habe ich einen Service erstellt, welcher Chuck-Norris-Fakten per HTTP abruft. Das komplette Beispiel befindet sich auf GitHub.

Tests, die einen Mock des RestTemplates verwenden, sehen oft aus wie folgt:

public class ChuckNorrisServiceNeedsRefactoringTest {
 
    private static final Long EXISTING_JOKE = 1L;
    private static final Map<String, Long> GOOD_HTTP_PARAMS = Collections.singletonMap("id", EXISTING_JOKE);
    private static final Long NON_EXISTING_JOKE = 15123123L;
    private static final Map<String, Long> NON_EXISTING_HTTP_PARAMS = Collections.singletonMap("id", NON_EXISTING_JOKE);
    private static final Long BAD_JOKE = 99999999L;
    private static final Map<String, Long> BAD_HTTP_PARAMS = Collections.singletonMap("id", BAD_JOKE);
 
    private static final ResponseEntity ERROR_RESPONSE =
            new ResponseEntity<>(new ChuckNorrisFactResponse("NoSuchQuoteException", "No quote with id=15123123."), HttpStatus.OK);
    private static final ResponseEntity ITEM_RESPONSE =
            new ResponseEntity<>(new ChuckNorrisFactResponse("success", new ChuckNorrisFact(1L, "Chuck Norris is awesome")), HttpStatus.OK);
 
    @Test
    public void serviceShouldReturnFact() {
        RestTemplate restTemplate = mock(RestTemplate.class);
        when(restTemplate.getForEntity(FACT_URL, ChuckNorrisFactResponse.class, GOOD_HTTP_PARAMS))
                .thenReturn(ITEM_RESPONSE);
        ChuckNorrisService myServiceUnderTest = new ChuckNorrisService(restTemplate);
 
        ChuckNorrisFact chuckNorrisFact = myServiceUnderTest.retrieveFact(EXISTING_JOKE);
 
        assertThat(chuckNorrisFact, is(new ChuckNorrisFact(EXISTING_JOKE, "Chuck Norris is awesome")));
    }
 
    @Test
    public void serviceShouldReturnNothing() {
        RestTemplate restTemplate = mock(RestTemplate.class);
        when(restTemplate.getForEntity(FACT_URL, ChuckNorrisFactResponse.class, NON_EXISTING_HTTP_PARAMS))
                .thenReturn(ERROR_RESPONSE);
        ChuckNorrisService myServiceUnderTest = new ChuckNorrisService(restTemplate);
 
        ChuckNorrisFact chuckNorrisFact = myServiceUnderTest.retrieveFact(NON_EXISTING_JOKE);
 
        assertThat(chuckNorrisFact, is(nullValue()));
    }
 
    @Test(expected = ResourceAccessException.class)
    public void serviceShouldPropagateException() {
        RestTemplate restTemplate = mock(RestTemplate.class);
        when(restTemplate.getForEntity(FACT_URL, ChuckNorrisFactResponse.class, BAD_HTTP_PARAMS))
                .thenThrow(new ResourceAccessException("I/O error"));
        ChuckNorrisService myServiceUnderTest = new ChuckNorrisService(restTemplate);
 
        myServiceUnderTest.retrieveFact(BAD_JOKE);
    }
}

In diesem Test werden die beiden Mockito-Methoden mock() und when() statisch importiert. mock() erzeugt das RestTemplate-Mock-Objekt und when() zeichnet das erwartete Verhalten auf.

Dieser Testcode ist nicht allzu schlecht, aber auch nicht allzu gut. Wir sehen bereits einige Wiederholungen (s. DRY), und wenn wir jemals vom RestTemplate zu etwas anderem wechseln würden, müssten wir jeden Test anfassen. Schauen wir also, wie wir das verbessern können.

Es ist deutlich zu sehen, dass die Extraktion einer Methode die ersten beiden Tests verbessern würde. Diese Methode nimmt dann die erwartete Antwort und den HTTP-Parameter entgegen und konfiguriert den Mock. Die dritte Testmethode passt nicht ganz in das Schema, weil sie eine Exception wirft, anstatt eine ResponseEntity zurückzugeben. Neben der Duplizierung von Code halten wir uns hier eigentlich zu sehr mit technischen Details auf. Müssen wir beim Lesen der Tests wirklich wissen, ob GET oder POST ausgeführt wird? Müssen wir überhaupt die Art der Antwort kennen? Was uns eigentlich interessiert, ist, wie sich der ChuckNorrisService verhält. Die HTTP-Kommunikation ist in dem Service versteckt.

Lambdas zur Hilfe

Genau an diesem Punkt können uns Javas Lambdas helfen, unsere Teststruktur zu verbessern. Neben den wohl bekannten Mockito-Methoden thenReturn und thenThrow gibt es auch thenAnswer. Diese Methode erwartet einen Parameter, der das generische Interface Answer implementiert, welches prinzipiell alles tun kann. Der Vorteil ist, dass eine Answer berechnen kann, welcher Wert zurückgegeben werden soll. Dies unterscheidet sich von den Werten, die thenReturn und thenThrow erhalten, da diese fix sind. Ich weiß nicht, ob es Absicht war oder nicht, aber Mockitos Answer Interface erfüllt die Anforderungen eines Java 8 Functional Interface. Mit seiner einzigen Methode T answer(InvocationOnMock invocation) throws Throwable; ist es äquivalent zu java.util.function.Function. Der einzige Unterschied ist das throws. Mit diesem Wissen können wir die Code-Duplikationen loswerden und deutlicher machen, was unsere Absicht im Test ist.

Starten wir direkt mit der überarbeiteten Version des vorherigen Beispiels:

public class ChuckNorrisServiceStepOneTest {
 
    private static final Long EXISTING_JOKE = 1L;
    private static final Map<String, Long> GOOD_HTTP_PARAMS = Collections.singletonMap("id", EXISTING_JOKE);
    private static final Long NON_EXISTING_JOKE = 15123123L;
    private static final Map<String, Long> NON_EXISTING_HTTP_PARAMS = Collections.singletonMap("id", NON_EXISTING_JOKE);
    private static final Long BAD_JOKE = 99999999L;
    private static final Map<String, Long> BAD_HTTP_PARAMS = Collections.singletonMap("id", BAD_JOKE);
 
    private static final ResponseEntity ERROR_RESPONSE =
            new ResponseEntity<>(new ChuckNorrisFactResponse("NoSuchQuoteException", "No quote with id=15123123."), HttpStatus.OK);
    private static final ResponseEntity ITEM_RESPONSE =
            new ResponseEntity<>(new ChuckNorrisFactResponse("success", new ChuckNorrisFact(1L, "Chuck Norris is awesome")), HttpStatus.OK);
 
    @Test
    public void serviceShouldReturnFact() {
        RestTemplate restTemplate = restEndpointShouldAnswer(GOOD_HTTP_PARAMS, (invocation) -> ITEM_RESPONSE);
        ChuckNorrisService myServiceUnderTest = new ChuckNorrisService(restTemplate);
 
        ChuckNorrisFact chuckNorrisFact = myServiceUnderTest.retrieveFact(EXISTING_JOKE);
 
        assertThat(chuckNorrisFact, is(new ChuckNorrisFact(EXISTING_JOKE, "Chuck Norris is awesome")));
    }
 
    @Test
    public void serviceShouldReturnNothing() {
        RestTemplate restTemplate = restEndpointShouldAnswer(NON_EXISTING_HTTP_PARAMS, (invocation -> ERROR_RESPONSE));
        ChuckNorrisService myServiceUnderTest = new ChuckNorrisService(restTemplate);
 
        ChuckNorrisFact chuckNorrisFact = myServiceUnderTest.retrieveFact(NON_EXISTING_JOKE);
 
        assertThat(chuckNorrisFact, is(nullValue()));
    }
 
    @Test(expected = ResourceAccessException.class)
    public void serviceShouldPropagateException() {
        RestTemplate restTemplate = restEndpointShouldAnswer(BAD_HTTP_PARAMS, (invocation -> {throw new ResourceAccessException("I/O error");}));
        ChuckNorrisService myServiceUnderTest = new ChuckNorrisService(restTemplate);
 
        myServiceUnderTest.retrieveFact(BAD_JOKE);
    }
 
    private RestTemplate restEndpointShouldAnswer(Map<String, Long> httpParams, Answer<ResponseEntity> response){
        RestTemplate restTemplate = mock(RestTemplate.class);
        when(restTemplate.getForEntity(FACT_URL, ChuckNorrisFactResponse.class, httpParams)).thenAnswer(response);
        return restTemplate;
    }
}

Was hat sich verbessert? Erstens können wir direkt sehen, wie ein HTTP-Parameter einer bestimmten Antwort entspricht. Wir müssen nicht den gesamten Test lesen, um diese Korrelation zu sehen. Zweitens, innerhalb eines einzelnen Tests sind die Details der REST-Aufrufe nun vor uns verborgen. Wir brauchen nichts über URL, HTTP-Methode und Antwortklasse zu wissen, es sei denn, wir wollen dies verändern. Schließlich ist es uns gelungen, die Handhabung des RestTemplate Mocks durch das Extrahieren einer Methode zu vereinheitlichen. Die „normalen“ Antworten und die Exception werden nicht mehr unterschiedlich behandelt. Um den REST-Aufruf von GET auf POST zu ändern, müsste nur eine Zeile im Test geändert werden.

Mehr Refactoring

Was wir nicht gelöst haben, ist, dass das RestTemplate in jedem einzelnen Test verwendet wird. Dies können wir allerdings beheben, indem wir Felder benutzen und eine @Before-Methode:

public class ChuckNorrisServiceStepTwoTest {
 
    private static final Long EXISTING_JOKE = 1L;
    private static final Map<String, Long> GOOD_HTTP_PARAMS = Collections.singletonMap("id", EXISTING_JOKE);
    private static final Long NON_EXISTING_JOKE = 15123123L;
    private static final Map<String, Long> NON_EXISTING_HTTP_PARAMS = Collections.singletonMap("id", NON_EXISTING_JOKE);
    private static final Long BAD_JOKE = 99999999L;
    private static final Map<String, Long> BAD_HTTP_PARAMS = Collections.singletonMap("id", BAD_JOKE);
 
    private static final ResponseEntity ERROR_RESPONSE =
            new ResponseEntity<>(new ChuckNorrisFactResponse("NoSuchQuoteException", "No quote with id=15123123."), HttpStatus.OK);
    private static final ResponseEntity ITEM_RESPONSE =
            new ResponseEntity<>(new ChuckNorrisFactResponse("success", new ChuckNorrisFact(1L, "Chuck Norris is awesome")), HttpStatus.OK);
 
    private RestTemplate restTemplate;
    private ChuckNorrisService myServiceUnderTest;
 
    @Before
    public void setUp(){
        restTemplate = mock(RestTemplate.class);
        myServiceUnderTest = new ChuckNorrisService(restTemplate);
    }
 
    @Test
    public void serviceShouldReturnFact() {
        restEndpointShouldAnswer(GOOD_HTTP_PARAMS, (invocation) -> ITEM_RESPONSE);
 
        ChuckNorrisFact chuckNorrisFact = myServiceUnderTest.retrieveFact(EXISTING_JOKE);
 
        assertThat(chuckNorrisFact, is(new ChuckNorrisFact(EXISTING_JOKE, "Chuck Norris is awesome")));
    }
 
    @Test
    public void serviceShouldReturnNothing() {
        restEndpointShouldAnswer(NON_EXISTING_HTTP_PARAMS, (invocation -> ERROR_RESPONSE));
 
        ChuckNorrisFact chuckNorrisFact = myServiceUnderTest.retrieveFact(NON_EXISTING_JOKE);
 
        assertThat(chuckNorrisFact, is(nullValue()));
    }
 
    @Test(expected = ResourceAccessException.class)
    public void serviceShouldPropagateException() {
        restEndpointShouldAnswer(BAD_HTTP_PARAMS, (invocation -> {throw new ResourceAccessException("I/O error");}));
 
        myServiceUnderTest.retrieveFact(BAD_JOKE);
    }
 
    private void restEndpointShouldAnswer(Map<String, Long> httpParams, Answer<ResponseEntity> response){
        when(restTemplate.getForEntity(FACT_URL, ChuckNorrisFactResponse.class, httpParams)).thenAnswer(response);
    }
}

Die Verwendung von Feldern und das Verschieben der Instanziierung der zu testenden Klasse in das Setup mag nicht in jedem Fall vorteilhaft sein, aber es ist gut zu sehen, dass dadurch noch mehr Wiederholungen entfernt werden. Auch die Methode restEndpointShouldAnswer() sieht ohne Rückgabewert sauberer aus.

Fazit

Ein wichtiger Punkt, den wir beim Schreiben von Tests im Hinterkopf behalten sollten, ist es, deutlich zu machen, was die Absicht des Tests ist, d. h. genau zu zeigen, was wir eigentlich testen wollen. Wenn nicht klar ist, was genau der Test tut und testen soll, wird es schwierig, den Test zukünftig zu ändern. Außerdem wird es komplizierter festzustellen, ob die Klasse gründlich getestet wurde. Der Einsatz von Lambdas und die Extraktion von dupliziertem Code helfen uns, die Teststruktur sowie die Lesbarkeit zu verbessern.

Ronny Bräunlich

Ronny arbeitet seit Mai 2017 bei der codecentric AG. Er ist überzeugter Anhänger von TDD und arbeitet meist im JVM-Ökosystem.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.