adesso Blog

Entwicklerinnen und Entwickler kennen sich hervorragend mit Unit-Tests aus und sind mit integrativen Ansätzen vertraut, wie zum Beispiel @SpringBootTest. Allerdings fehlt ihnen häufig eine klare Strategie für Design, Entwicklung oder Tests. Deshalb bleiben sie meistens in ihrer bevorzugten Programmiersprache. Acceptance Test Driven Design (ATDD) ist ein strukturierter Ansatz, um Tests und die Anwendung von außen nach innen zu entwickeln. Dabei liegt der Fokus auf größeren Funktionsblöcken anstatt auf einzelnen Klassen (wie auf dem Testverhalten anstatt auf Klassen). Der Nutzen dieses Ansatzes wird dadurch erzielt, dass die Akzeptanztests in einer Nichtprogrammiersprache wie Cucumber abgebildet werden. Dadurch können auch Personen ohne Programmierkenntnisse Testszenarien schreiben, die dann automatisch ausgeführt werden. Genau das zeigen wir euch in diesem Artikel am Beispiel eines kleinen Testfalls und eines voll funktionsfähigen, kleinen Spring-Boot-/Java-Projekts.

Einführung: Getting Things Done

Als Testfall wurde die Methode „Getting Things Done“ von David Allen verwendet (Wikipedia):

Getting Things Done (GTD) ist eine persönliche Produktivitätsmethode, die von David Allen entwickelt und unter diesem Namen auch als Buch veröffentlicht wurde. GTD wird im Buch als Selbstmanagement-System beschrieben. Allen erklärt im Buch, dass es quasi einen entgegengesetzten Zusammenhang zwischen den Aufgaben gibt, die man im Kopf hat, und den Aufgaben, die man erledigen muss.

Die GTD-Methode basiert auf diesem Grundprinzip: Alle Ideen, relevanten Informationen, Probleme, Termine, Aufgaben und Projekte werden extern aufgezeichnet (aufgeschrieben), damit man sich nicht alles merken muss. Anschließend werden sie in umsetzbare Aufgaben aufgeteilt, die einen festgelegten Zeitrahmen erhalten. Auf diese Weise muss man sich nicht immer wieder seine Aufgaben in Erinnerung rufen. Dadurch kann man sich voll auf die Aufgaben konzentrieren, die in der externen Liste festgehalten wurden.

Unser erstes Feature: „Alle Gedanken sammeln“

Bei der GTD-Methode werden im allerersten Schritt alle Gedanken und Ideen, die man im Kopf hat, an einem sicheren Ort notiert. Von dort aus können sie später wieder aufgerufen werden, um sie weiter zu bearbeiten. Das heißt, ihr schreibt wirklich alles auf, was euch in den Sinn kommt. Und das kann alles Mögliche sein: Lebensmittel, die ihr auf die Einkaufsliste setzen müsst, oder eure Geschäftsidee für ein Elektroauto, die euch irgendwann zum zweitreichsten Menschen weltweit machen könnte.

Die sprachliche Beschreibung des Anwendungsfalles sieht also so aus: Ein Gedanke, eine Aufgabe, eine Idee oder ein Termin wird in einem sogenannten „Eingangskorb“ mit wenigen Worten oder einem kurzen Text aufgeschrieben und gesammelt. Später könnt ihr eure Notizen jederzeit in diesem Eingangskorb aufrufen.

Die Akzeptanztest-Szenarien definieren

Bevor wir aber mit dem ersten Feature „Alle Gedanken sammeln“ (Collect Thoughts) beginnen, definieren wir zuallererst die Akzeptanztests für dieses Feature:

	
		Feature: Capture Stage
		  Scenario: Collect Thought
		    When Thought "Send Birthday Wishes to Mike" is collected
		    Then Inbox contains "Send Birthday Wishes to Mike"
		src/test/resources/features/collect-thought.feature
	

Jetzt passiert etwas Magisches! Denn die Akzeptanztest-Szenarien werden nicht in der QS-Phase nach der Implementierung der Funktion definiert. Sondern die Akzeptanztests werden erstellt, bevor die Aufgabe der Implementierung überhaupt an die/den Entwicklerin oder Entwickler übergeben wird. Dieses Vorgehen wird als Shift-Left-Ansatz bezeichnet. Die QS-Aufgabe zum Definieren von Akzeptanztest-Szenarien am Ende des Prozesses wird (von rechts) an den Anfang des Prozesses (weiter nach links) verschoben. Dieser Ansatz hat folgenden Vorteil: Statt eine allgemein gehaltene Idee des Features in nur ein bis zwei Sätzen zu formulieren, muss die Anforderung an das Feature präzise beschrieben werden. Dadurch sind Entwicklerinnen und Entwickler gezwungen, genauer ins Detail zu gehen.

Los geht’s mit der Einrichtung

Diese Aufgabe ist eine Standardaufgabe: Wir starten ein Java-/Maven-Projekt und lassen IntelliJ die erste pom.xml generieren. Dabei fügen wir ein paar Abhängigkeiten für eine In-Memory-Datenbank für die Tests und Cucumber in die pom.xml ein:

	
	<?xml version="1.0" encoding="UTF-8"?>
	<project xmlns="http://maven.apache.org/POM/4.0.0"
	         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	    <modelVersion>4.0.0</modelVersion>
	    <groupId>de.adesso.thalheim.gtd</groupId>
	    <artifactId>cucumber_demo</artifactId>
	    <version>1.0-SNAPSHOT</version>
	    <properties>
	        <maven.compiler.source>17</maven.compiler.source>
	        <maven.compiler.target>17</maven.compiler.target>
	        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	    </properties>
	</project>
	

Da ein Spring-Boot-Projekt gestartet werden soll und ich ein Fan von Lombok bin, füge ich folgende Abhängigkeiten und die Spring-Boot-Starter-Parent-Beziehung zur pom.xml hinzu:

	
	    <parent>
	        <groupId>org.springframework.boot</groupId>
	        <artifactId>spring-boot-starter-parent</artifactId>
	        <version>2.5.4</version>
	        <relativePath /> <!-- lookup parent from repository -->
	    </parent>
	    ...
	    <dependencies>
	        <dependency>
	            <groupId>org.springframework.boot</groupId>
	            <artifactId>spring-boot-starter-data-jpa</artifactId>
	        </dependency>
	        <dependency>
	            <groupId>org.springframework.boot</groupId>
	            <artifactId>spring-boot-starter-web</artifactId>
	        </dependency>
	        <dependency>
	            <groupId>org.projectlombok</groupId>
	            <artifactId>lombok</artifactId>
	            <optional>true</optional>
	            <scope>provided</scope>
	        </dependency>
	    </dependencies>
	

Wenn wir damit fertig sind, sollen die folgenden zwei Ziele erreicht werden:

  • Die Anwendung soll mit einer externen Datenbank starten. (Dazu wird auf meinem lokalen Rechner die PostgreSQL-Datenbank in Docker ausgeführt.)
  • Ein einfacher „@SpringBootTest“ soll mit einer integrierten H2-Datenbank starten.

Lange Rede, kurzer Sinn: Dafür müssen mehrere Dinge durchgeführt werden. Die pom.xml benötigt einige weitere Abhängigkeiten:

	
	        <dependency>
	            <groupId>org.postgresql</groupId>
	            <artifactId>postgresql</artifactId>
	        </dependency>
	        <dependency>
	            <groupId>org.springframework.boot</groupId>
	            <artifactId>spring-boot-starter-test</artifactId>
	            <scope>test</scope>
	        </dependency>
	        <dependency>
	            <groupId>com.h2database</groupId>
	            <artifactId>h2</artifactId>
	            <scope>test</scope>
	        </dependency>
	

Die Datenquelle muss konfiguriert werden. Sie wird in normalen Operationen unserer Anwendung in der src/main/resources/application.yml verwendet:

	
		spring.jpa:
		  database: POSTGRESQL
		  hibernate.ddl-auto: create-drop
		  show-sql: true
		spring.datasource:
		  driverClassName: org.postgresql.Driver
		  url: jdbc:postgresql://localhost:5432/mydb
		  username: foo
		  password: bar
	

Ein kleiner Hinweis am Rande: Die PostgreSQL-Datenbank kann ganz einfach mit docker run --name postgres-db -e POSTGRES_PASSWORD=docker -p 5432:5432 -d postgres gestartet werden. Die Datenbank und der Nutzer können mit CREATE DATABASE ... und CREATE USER ... erstellt werden.

Wir müssen eine alternative Datenquelle konfigurieren, die beim Unit-Test unserer Anwendung in der src/test/resources/application.yml verwendet wird:

	
		spring.datasource:
		  driver-class-name: org.h2.Driver
		  url: jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
		  username: sa
		  password: sa
	

Ich weiche jetzt etwas vom Thema ab, aber diese Informationen sind trotzdem wichtig. Bei vielen Projekten wird nämlich vergessen, ihre Codebasis so früh wie möglich für diese Art von Test (integrativer Komponententest mit einer eingebetteten Datenbank) einzurichten. Deshalb mein Vorschlag: Erstellt sie so früh wie möglich, und zwar bevor ihr die erste Zeile produktiven Code für euer Projekt schreibt. Dadurch erhaltet ihr saubere Testmöglichkeiten für alle Entwicklerinnen und Entwickler während der Projektentwicklung.

Die Cucumber-Maven-Abhängigkeit hinzufügen und konfigurieren

Um die Testspezifikation ausführen zu können, brauchen wir einige Abhängigkeiten in der pom.xml:

	
	<dependency>
	   <groupId>io.cucumber</groupId>
	   <artifactId>cucumber-java</artifactId>
	   <version>6.11.0</version>
	</dependency>
	<dependency>
	   <groupId>io.cucumber</groupId>
	   <artifactId>cucumber-spring</artifactId>
	   <version>6.11.0</version>
	</dependency>
	<dependency>
	   <groupId>io.cucumber</groupId>
	   <artifactId>cucumber-junit</artifactId>
	   <version>6.11.0</version>
	</dependency>
	

Jetzt können wir den Akzeptanztest, den wir oben definiert haben, unserer Codebasis in src/test/resources/features/collect-thought.feature hinzufügen:

	
		Feature: Capture Stage
		  Scenario: Collect Thought
		    When Thought "Send Birthday Wishes to Mike" is collected
		    Then Inbox contains "Send Birthday Wishes to Mike"
	

Die Cucumber-Testspezifikation ausführen lassen

Damit Maven diese Spezifikation ausführt, benötigen wir etwas Boilerplate-Code.

Als Erstes brauchen wir eine Testklasse, die auf die Cucumber-Testspezifikationen verweist:

	
	@RunWith(Cucumber.class)
	@CucumberOptions(features = {"src/test/resources/features"})
	public class CucumberTest {
	}
	

src/test/java/de/adesso/thalheim/gtd/CucumberTest.java

Außerdem muss Cucumber-Kontext bereitgestellt werden. Dafür verwenden wir @SpringBootTest:

	
		@CucumberContextConfiguration
		@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
		class CucumberSpringBootDemoApplicationTest {
	

src/test/java/de/adesso/thalheim/gtd/CucumberSpringBootDemoApplicationTest.java

Stellt sicher, dass der Port RANDOM eure regulär ausgeführte lokale Instanz dieses Services nicht beeinträchtigt.

Wir führen jetzt Maven aus. Während des Testlaufs wird eine Fehlermeldung angezeigt, die angibt, dass der Glue-Code fehlt. Also fügen wir ihn hinzu:

	
	public class CaptureStepDefinitions {
	    @When("Thought {string} is collected")
	    public void thoughtIsCollected(String thought) {
	        Assert.fail("Implement me!");
	    }
	    @Then("Inbox contains {string}")
	    public void inboxContains(String thought) {
	        Assert.fail("Implement me!");
	    }
	}
	

src/test/java/de/adesso/thalheim/gtd/CaptureStepDefinitions.java

Unsere Testspezifikation schlägt jetzt fehl. Aber sie schlägt leider nicht aus dem korrekten Grund fehl! Deshalb implementierten wir den Glue-Code in src/test/java/de/adesso/thalheim/gtd/CaptureStepDefinitions.java:

	
	    @Value(value = "")
	    private int port;
	    @When("Thought {string} is collected")
	    public void thoughtIsCollected(String thought) throws IOException {
	        // given
	        HttpPost post = new HttpPost("http://localhost:%d/gtd/inbox".formatted(port));
	        post.setEntity(new StringEntity(thought));
	        // when
	        HttpResponse postResponse = HttpClientBuilder.create().build().execute(post);
	        // then
	        Assertions.assertThat(postResponse.getStatusLine().getStatusCode()).isEqualTo(200);
	    }
	

Im Test ist definiert, dass wir einen POST-Endpunkt brauchen, der im Kontextpfad gtd/thoughts ausgegeben wird. Dieser sollte den HTTP-Statuscode 200 zurückgeben.

Ich habe außerdem die Bibliothek „AssertJ Core“ zu den Maven-Abhängigkeiten hinzugefügt. assertThat(...)... klingt mehr nach BDD als nach den standardmäßigen JUnit-Assert-Anweisungen.

Wenn jetzt die Cucumber-Tests oder das Maven-Build ausgeführt werden, schlägt die Testausführung fehl, weil kein REST-Controller einen korrekten Endpunkt anbietet. Jetzt haben wir einen Test, der aus dem korrekten Grund fehlschlägt:

	
		[ERROR] Collect Thought  Time elapsed: 0.248 s  <<< ERROR!
		org.apache.http.conn.HttpHostConnectException: Connect to localhost:8080 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] failed: Connection refused: connect
		Caused by: java.net.ConnectException: Connection refused: connect
	

Der Grund, warum unser Test fehlschlägt: Es gibt kein REST-Endpunkt-Listening an der Stelle, an der wir das erwarten.

Das bedeutet, dass wir jetzt endlich den Produktionscode schreiben können:

	
	@RestController
	@RequestMapping("gtd/inbox")
	@Slf4j
	public class InboxController {
	    @PostMapping
	    public void collect(@RequestBody String thought) {
	        // TODO: implement me!
	        log.debug("Received " + thought);
	    }
	}
	

src/main/java/de/adesso/thalheim/gtd/controller/InboxController.java

Der Akzeptanztest schlägt erneut fehl, weil es keinen Glue-Code für die When-Klausel im Cucumber-Szenario gibt. Deshalb schreiben wir den Glue-Code in die src/test/java/de/adesso/thalheim/gtd/CaptureStepDefinitions.java:

	
	    @Value(value = "")
	    private int port;
	    @Then("Inbox contains {string}")
	    public void inboxContains(String thought) throws IOException {
	        // given
	        HttpUriRequest get = new HttpGet("http://localhost:%d/gtd/inbox".formatted(port));
	        // when
	        CloseableHttpResponse response = HttpClientBuilder.create().build().execute(get);
	        // then
	        String entity = EntityUtils.toString(response.getEntity());
	        assertThat(StringUtils.strip(entity)).isEqualTo("[{\"description\":\"%s\"}]".formatted(thought));
	    }
	
Hinweis zur Abstraktionsebene

Hier ist zu erkennen, dass ich den Glue-Code und damit den Akzeptanztest auf einer höheren Abstraktionsebene angesiedelt habe, und zwar über der konkreten Schnittstelle. Natürlich hätte ich mit @Inject den REST-Controller einfügen und einfaches Java für die Tests verwenden können. Das hätte einiges einfacher gemacht. Aber dadurch wäre der Test konkreter als nötig geworden und der Test wäre auch an Implementierungsdetails gebunden gewesen.

Nun können wir eine Methode für den GET-Endpunkt schreiben. Sie sollte eine Liste von Klassen zurückgeben, die genau ein Feld „description“ enthält. Wir müssen den Controller implementieren. Also schreiben wir das zuerst im normalen TDD-Stil mit einem Testfall:

	
		@ExtendWith(MockitoExtension.class)
		class InboxControllerTest {    
		@InjectMocks
		    InboxController controller;
		    @Mock
		    ThoughtRepository repository;
		    @Captor
		    ArgumentCaptor<Thought> thoughtArgumentCaptor;
		    @Test
		    public void testPutThoughtIntoRepository() throws UnsupportedEncodingException {
		        // given
		        String thoughtDescription = "foiaxöniso";
		        // when
		        controller.collect(thoughtDescription);
		        // then
		        verify(repository).save(thoughtArgumentCaptor.capture());
		        assertThat(thoughtArgumentCaptor.getValue().getDescription()).isEqualTo(thoughtDescription);
		    }
		    @Test
		    public void testGetAllThoughts() {
		        // given
		        String thoughtDescription = "foiaxöniso";
		        Thought thought = new Thought(UUID.randomUUID(), thoughtDescription);
		        when(repository.findAll()).thenReturn(Set.of(thought));
		        // when
		        List<Thought> thoughts = controller.get();
		        // then
		        assertThat(thoughts).hasSize(1);
		        assertThat(thoughts.iterator().next()).isEqualTo(thought);
		    }
		}
	

src/test/java/de/adesso/thalheim/gtd/controller/InboxControllerTest.java

Controller, Entität und Repository usw. können jetzt fertiggeschrieben werden.

	
	@RestController
	@RequestMapping("gtd/inbox")
	@Slf4j
	public class InboxController {
	    @Inject
	    private ThoughtRepository thoughtRepository;
	    @PostMapping
	    public void collect(@RequestBody String thought) {
	        log.debug("Received " + thought);
	        Thought theThought = new Thought(UUID.randomUUID(), thought);
	        thoughtRepository.save(theThought);
	    }
	    @GetMapping
	    public List<Thought> get() {
	        Iterable<Thought> all = thoughtRepository.findAll();
	        return StreamSupport.stream(all.spliterator(), false).toList();
	    }
	}
	

src/main/java/de/adesso/thalheim/gtd/controller/InboxController.java

	
	@RequiredArgsConstructor
	@AllArgsConstructor
	@Entity
	public class Thought {
	    @Id
	    private UUID id;
	    @Getter
	    private String description;
	}
	

src/main/java/de/adesso/thalheim/gtd/controller/Thought.java

	
	public interface ThoughtRepository extends CrudRepository<Thought, UUID> {}
	

src/main/java/de/adesso/thalheim/gtd/repository/ThoughtRepository.java

Normalerweise würdet ihr eine @Entity niemals als Ergebnistyp eines REST-Aufrufs ausgeben. Aber zu Demonstrationszwecken ist das hier in Ordnung.

Und das war’s schon! Wir haben ein kleines Feature implementiert, indem wir ein Akzeptanztest-Szenario und Glue-Code geschrieben haben, um das Verhalten eines Teils unserer Anwendung zuerst in Cucumber zu testen.

Zusammenfassung

Wie bereits erwähnt, würde ich hier ein Acceptance Test Driven Design (ATDD) durchführen. Das bedeutet, dass ich zunächst einen fehlgeschlagenen Akzeptanztest erstellt und dann nur Schnittstellen implementiert habe. Danach habe ich normale Unit-Tests verwendet, um die Interna meiner Implementierung fertigzustellen. Die Akzeptanztests bilden eine äußere Schleife und die Unit-Tests eine innere Schleife des Implementierungsprozesses.

Welchen Vorteil hat es, die Cucumber-Szenarien zuerst zu schreiben? Euer Requirements Engineer muss die Anforderungen so präzise wie möglich beschreiben.

Noch vor dem Schreiben der ersten Zeile produktiven Codes habe ich mir die Zeit genommen und sichergestellt, dass in diesem Dummy-Projekt das Ausführen von Unit-Tests, von @SpringBootTest und Cucumber-Tests möglich war.

Ich habe die Akzeptanztests frei von Implementierungsdetails gehalten, die für sie nicht relevant sind. So habe ich die Sicherheit beim Refactoring erhöht. Dasselbe würde ich auch mit regulären @SpringBootTests machen.

Ihr könnt den gesamten Code übrigens in diesem Repository nachlesen

Bild Björn Thalheim

Autor Björn Thalheim

Björn ist Softwareentwickler und -architekt mit einem starken Interesse an Softwarequalität, insbesondere durch TDD, Clean Code und Architekturthemen.

Diese Seite speichern. Diese Seite entfernen.