Maki oder Inside-Out? – Ansätze zur Testabdeckung

Die folgende Beschreibung basiert auf wahren Begebenheiten, haben aber keinen aktuellen Bezug zu meinem Arbeitgeber, sondern sind das Resultat von Erfahrungen bei verschiedenen Arbeitgebern und den dort vorgefundenen Code-Zuständen.

Letztens kam mein Chef zu mir und meinte: “Ich hätte mir mehr Integrationstests von dir gewünscht.” Diese Aussage hat mich darüber nachdenken lassen, wie ich in verschiedenen Projekten an das Thema automatisierte Tests herangehe. Zu meiner Beruhigung konnte ich meinem Chef mitteilen, dass ich normalerweise mehr Tests “von außen nach innen” schreibe. Das heisst, wenn ich ein neues Feature implementiere, versuche ich so weit außen wie möglich mit meinem ersten Test anzufangen. Im Idealfall wäre das eine Art Akzeptanztest, der die Schnittstelle oder das User-Interface testet.
Die Frage, die ich mir allerdings im Anschluss stellen musste, war: “Warum hast du im aktuellen Projekt mit reinen Unit-Tests angefangen?” Die Erklärung ist so einfach wie niederschmetternd. Der Code war einerseits so unlesbar, dass ich mir erstmal viele Learning- und Unittests erstellen musste, um zu verstehen was der Code tut. Diesen Trial-und-Error-Ansatz kann ich nur empfehlen, da er einerseits schnell klarmacht, was ein Code tut oder auch nicht tut und man hat danach zumindest erstmal die Gewissheit, dass der Status Quo gewährleistet ist, so gut oder schlecht er auch sein mag. Der zweite Grund, weswegen ich keine Integrations- bzw. Akzeptanztests geschrieben habe, war die Applikationskonfiguration. Normalerweise versuche ich in meinen eigenen Projekten bestimmte Konfiguration, wie Sicherheit, Service-Discovery, Config-Server, soweit wie möglich abschaltbar oder zumindest sehr leicht konfigurierbar zu machen. Leider war das im aktuellen Projekt nicht der Fall. Hier mussten eine ganze Reihe von Systemaufrufen erfolgen, bevor ich ein Access-Token erhalten habe, mit dem ich dann die Requests auf die Schnittstelle durchführen konnte. Das hat mich so extrem abgeschreckt, dass ich mich meist auf Service-Ebene begeben habe. D.h. die komplette Controller-Schicht ignoriert habe. Desweiteren waren externe Anbindungen hart im Code verankert, sodass diese häufig nicht abgeschaltet oder zumindest gemockt werden konnten. Ich stolpere auch heute noch über Log-Ausgaben im Jenkins, die mir den Angstschweiss auf das Steißbein treibt. An dieser Stelle einen hochachtungsvollen Dank an diverse Service-Partner, die unsere Denial-of-Service-Angriffe so schadlos überstanden haben und keine gesonderte Rechnung gestellt haben. Wir haben gerne eure Lasttests übernommen ;).

Gibt es eine Praxis, die es zu empfehlen gilt, wenn man Tests schreibt? Viele Autoren und Bücher konzentrieren sich stark auf Unit-Tests als automatisierte Tests oder legen ihren Schwerpunkt auf die technische Umsetzbarkeit von End-to-End-Tests. Dies ist vor allem im Rahmen von Microservices ein sehr wichtiges Thema. Ich glaube aber, dass es ebenfalls darauf ankommt, zu erkennen, wann ein Feature nicht mehr funktioniert. D.h. es spielt auch eine Business-Perspektive eine Rolle, wie ich Tests schreibe bzw. von welcher Richtung aus ich Tests entwickle. Im Idealfall habe ich ein durchgängiges Gefüge von Unit-, Integrations- und Akzeptanztests. Leider kommt es aber zu häufig vor, dass Arbeiten unterbrochen werden müssen oder sich ein Liefertermin verschiebt und man gewisse Tests liegen lassen muss. Wie schon erwähnt versuche ich meist so weit außen wie möglich mit meinen Tests zu beginnen um einerseits das System und andererseits das Feature zu testen. Um dies tun zu können müssen die entsprechenden Test-Tools leicht zu nutzen sein. Nichts ist – aus meiner Sicht – frustrierender, wie wenn man nach teilweise stundenlanger “Frickelarbeit” alle Änderungen mit einem git revert ins digitale Nirvana schicken darf, da der Boilerplate für die Tests so immens groß ist, dass die Tests nicht mehr verständlich sind, ohne, dass man das komplette System dahinter durchdrungen hat bzw. man schon ein komplettes Parallelsystem aufgebaut hat, so dass gar nicht mehr das eigentliche Produktionssystem getestet wird.

Eine interessante Möglichkeit diese Art von Tests zu schreiben bieten Frameworks wie JBehave und Cucumber. Zusammengefasst laufen sie unter dem Begriff Behaviour-Driven-Development (BDD). Ich möchte in diesem Artikel ein Beispiel mit Cucumber, Restassured und Spring-Boot vorstellen. Leider liegt Cucumber etwas hinter der aktuellen Spring-Entwicklung, weswegen bestimmte Annotationen, wie @SpringBootTest (noch) nicht unterstützt werden.

Wer sich parallel zu den folgenden Erläuterungen den Quellcode anschauen möchte, findet das Projekt auf GitHub.

Neben restassured und spring-boot-test müssen folgende Dependencies in der pom.xml eingebunden werden. Vor allem cucumber-spring übernimmt hier die “Magie” um die Feature-Spezifikationen an Klassen mit der Annotation ContextConfiguration zu binden.

<dependency>
  <groupId>info.cukes</groupId>
  <artifactId>cucumber-java</artifactId>
  <version>1.2.5</version>
  <scope>test</scope>
</dependency>

<dependency>
  <groupId>info.cukes</groupId>
  <artifactId>cucumber-junit</artifactId>
  <version>1.2.5</version>
  <scope>test</scope>
</dependency>

<dependency>
  <groupId>info.cukes</groupId>
  <artifactId>cucumber-spring</artifactId>
  <version>1.2.5</version>
  <scope>test</scope>
</dependency>

Danach erstellen wir den Testeinstiegspunkt für Cucumber:

package de.pincservices.axon.controllers;

import cucumber.api.CucumberOptions;
import cucumber.api.junit.Cucumber;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@CucumberOptions(plugin = {"pretty"}, features = {"src/test/features"})
public class SampleCommandControllerCucumberTest {
}

Ich habe mich für den Ansatz entschieden, die Feature-Spezifikationen in ein separates Verzeichnis zu legen. Man kann auch src/test/resources nutzen. Meistens habe ich aber dort bereits Testdateien für andere Tests oder Test-Konfigurationsdateien liegen und möchte diese nicht mit den Spezifikationsdateien vermischen. Das ist aber reine Geschmackssache.

Eine solche Feature-Spezifikation könnte wie folgt aussehen:

Feature: Get samples
  Scenario: User calls web service to get all samples
	Given an embedded sample exists with content 'sample1'
	And an amqp sample exists with content 'sample2'
	When a user retrieves the samples
	Then the status code is 200
        And number of results is 2

Die Spezifikation ist, entsprechend BDD, eine reine Textdatei. Wobei die Endung .feature sowohl notwendig als auch hilfreich zum Finden der Dateien ist.

Als letzten Schritt müssen wir noch die Schritt-Definitionen (Step-Definitions) hinterlegen. Hier kommt zum ersten Mal Spring ins Spiel. Wenn man keine Spring-Applikation hat, oder nicht auf das Hochfahren der Applikation angewiesen ist, kann man die Annotationen auch getrost weglassen.

package de.pincservices.axon.controllers;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import cucumber.api.java.en.And;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;
import de.pincservices.axon.Application;
import de.pincservices.axon.queries.SampleQueryObject;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.SpringApplicationContextLoader;
import org.springframework.boot.test.WebIntegrationTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;

/**
 * {@link SpringBootTest} is not support in cucumber version 1.2.5
 */
@ContextConfiguration(classes = Application.class, loader = SpringApplicationContextLoader.class)
@WebIntegrationTest(randomPort = true)
@ActiveProfiles("test")
public class SampleCommandControllerStepDefinitions {

    @Value("${local.server.port}")
    private int port;

    private Response response;

    @Given("an embedded sample exists with content '(.*)'")
    public void an_embedded_sample_exists_with(String content) throws Exception {
        Map<String, Object> requestMap = new HashMap<>();
        requestMap.put("content", content);

        ObjectMapper objectMapper = new ObjectMapper();
        given().port(port).body(objectMapper.writeValueAsBytes(requestMap)).contentType(ContentType.JSON).when().post("/api/v1/embedsamples").then().statusCode(HttpStatus.CREATED.value());
    }

    @And("an amqp sample exists with content '(.*)'")
    public void an_amqp_sample_exists_with(String content) throws Exception {
        Map<String, Object> requestMap = new HashMap<>();
        requestMap.put("content", content);

        ObjectMapper objectMapper = new ObjectMapper();
        given().port(port).body(objectMapper.writeValueAsBytes(requestMap)).contentType(ContentType.JSON).when().post("/api/v1/amqpsamples").then().statusCode(HttpStatus.CREATED.value());
    }

    @When("a user retrieves the samples")
    public void user_retrieves_samples() throws Exception {
        response = given().port(port).when().get("/api/v1/samples");
    }


    @Then("the status code is (\\d+)")
    public void the_status_code_is(int status) {
        response.then().statusCode(status);
    }

    @And("number of results is '(\\d)'")
    public void number_of_results_is(int numberOfResults) throws Exception {
        String json = response.body().asString();
        ObjectMapper objectMapper = new ObjectMapper();
        List<SampleQueryObject> result = objectMapper.readValue(json, new TypeReference<List<SampleQueryObject>>() {});
        assertThat(result).hasSize(numberOfResults);
    }
}

Was auffällt ist, dass kein @RunWith bei den Schritt-Definitionen nötig ist. Die Verarbeitung übernimmt Cucumber. Nur der SpringApplicationContextLoader ist nötig um die Applikation zu starten.

Als Fazit der Überlegungen würde ich jedem Entwickler empfehlen vor allem mit feature-getriebenen Tests auf API-Ebene zu beginnen. In der Regel lassen sich diese recht gut überblicken und zeitlich abschätzen – natürlich abhängig vom existierenden System. Desweiteren, verzettelt man sich nicht so leicht in diversen Unit-Tests, die am Ende keine Aussage mehr über den Zustand der Gesamtapplikation treffen. Wichtig ist aber sich bewusst zu machen, dass Tests, die das System als Black-Box ansehen, auch den Weg bereiten für Seiteneffekte innerhalb der Applikation. Endziel sollte dementsprechend ein sinnvolles Zusammenspiel von Unit- und Integration-Tests sein.

Bildquelle: alexutemov / 123RF Lizenzfreie Bilder

Schreibe einen Kommentar

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