11. Mai 2023 von Björn Thalheim
ATDD in Spring Boot mit Cucumber
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