Anleitung zum Testen mit dem Spring Boot Starter Test

von Björn Wilmsmann

Dieser Artikel ist gesponsert von Philip (rieckpil) und aus dem Englischen übersetzt von workshops.de - Philip bietet einen umfassenden Online-Kurs zum Thema Testen von Spring Boot Anwendungen (englischsprachig) an.

- Deutsche Übersetzung von Justus Wildhagen -

Mit Spring Boot benötigst du nur eine Abhängigkeit, um eine solide Testinfrastruktur zu haben: Spring Boot Starter Test. Durch die Verwendung dieses Starters wirst du eine Anzahl von “opinionated” Testbibliotheken deinem Projekt hinzufügen. Das Spring Boot-Team sorgt sogar dafür, dass diese Testbibliotheken regelmäßig geupgradet werden und kompatibel bleiben. Du kannst direkt nach dem Bootstrapping deines Projekts mit dem Schreiben von Tests beginnen.

Diese Anleitung gibt dir praktische Einblicke in den Spring Boot Starter Test, oder wie ich ihn nenne, das Schweizer Taschenmesser des Testens. Dazu gehört eine Einführung in die einzelnen Testbibliotheken, die dieser Spring Boot Starter transitiv in dein Projekt pullt. Für das Unit- und Integrationstesten von Spring Boot Anwendungen im Allgemeinen, schau dir diesen Überblick an.

Anatomie des Spring Boot Starter Tests

Jedes Spring Boot Projekt, das wir mit dem Spring Initializr erstellen, beinhaltet standardmäßig de folgendenden Starter:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>

Dieser Starter beinhaltet Spring-spezifische Abhängigkeiten und Abhängigkeiten für die Autokonfiguration sowie eine Reihe von Testbibliotheken. Dazu gehören JUnit, Mockito, Hamcrest, AssertJ, JSONassert, und JsonPath.

Diese Bibliotheken dienen alle einem bestimmten Zweck und manche davon können durch andere ersetzt werden, wie wir später sehen werden.

Nichtsdestotrotz ist diese Auswahl an “opinionated” Testing Tools alles, was wir für Unit Tests benötigen. Für das Schreiben von Integrationstests möchten wir vielleicht zusätzliche Abhängigkeiten einbeziehen (z.B. WireMock, Testcontainers, oder Selenium), abhängig von unserer Anwendungskonfiguration.

Mit Maven können wir alle transitiven Abhängigkeiten, die mit spring-boot-starter-test kommen, mittels mvn dependency:tree untersuchen:

[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:2.5.5:test
[INFO] |  +- org.springframework.boot:spring-boot-test:jar:2.5.5:test
[INFO] |  +- org.springframework.boot:spring-boot-test-autoconfigure:jar:2.5.5:test
[INFO] |  +- com.jayway.jsonpath:json-path:jar:2.5.0:test
[INFO] |  |  +- net.minidev:json-smart:jar:2.4.7:test
[INFO] |  |  |  \- net.minidev:accessors-smart:jar:2.4.7:test
[INFO] |  |  |     \- org.ow2.asm:asm:jar:9.1:test
[INFO] |  |  \- org.slf4j:slf4j-api:jar:1.7.32:compile
[INFO] |  +- jakarta.xml.bind:jakarta.xml.bind-api:jar:2.3.3:test
[INFO] |  |  \- jakarta.activation:jakarta.activation-api:jar:1.2.2:test
[INFO] |  +- org.assertj:assertj-core:jar:3.19.0:test
[INFO] |  +- org.hamcrest:hamcrest:jar:2.2:test
[INFO] |  +- org.junit.jupiter:junit-jupiter:jar:5.7.2:test
[INFO] |  |  +- org.junit.jupiter:junit-jupiter-params:jar:5.7.2:test
[INFO] |  |  \- org.junit.jupiter:junit-jupiter-engine:jar:5.7.2:test
[INFO] |  |     \- org.junit.platform:junit-platform-engine:jar:1.7.2:test
[INFO] |  +- org.mockito:mockito-core:jar:3.9.0:test
[INFO] |  |  +- net.bytebuddy:byte-buddy:jar:1.10.22:test
[INFO] |  |  +- net.bytebuddy:byte-buddy-agent:jar:1.10.22:test
[INFO] |  |  \- org.objenesis:objenesis:jar:3.2:test
[INFO] |  +- org.mockito:mockito-junit-jupiter:jar:3.9.0:test
[INFO] |  +- org.skyscreamer:jsonassert:jar:1.5.0:test
[INFO] |  |  \- com.vaadin.external.google:android-json:jar:0.0.20131108.vaadin1:test
[INFO] |  +- org.springframework:spring-core:jar:5.3.10:compile
[INFO] |  |  \- org.springframework:spring-jcl:jar:5.3.10:compile
[INFO] |  +- org.springframework:spring-test:jar:5.3.10:test
[INFO] |  \- org.xmlunit:xmlunit-core:jar:2.8.2:test                               |                                                              |

Sofern nicht einige unserer Tests noch JUnit 4 verwenden, können wir die JUnit Vintage Engine aus dem Starter entfernen. Die Vintage Engine ermöglicht die Ausführung von JUnit 3- und 4-Tests neben JUnit 5.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
       <groupId>org.junit.vintage</groupId>
       <artifactId>junit-vintage-engine</artifactId>
     </exclusion>
  </exclusions>
</dependency>

UPDATE: Ab Spring Boot 2.4 ist die JUnit Vintage Engine nicht mehr Teil des Spring Boot Starter Tests. Daher müssen wir sie nicht mehr explizit ausschließen.

Für Projekte, die noch nicht vollständig auf JUnit 5 migriert sind und Spring Boot > 2.4 verwenden, müssen wir die Unterstützung für frühere JUnit-Versionen mit dem folgenden Import zurückbringen:

<dependency>
  <groupId>org.junit.vintage</groupId>
  <artifactId>junit-vintage-engine</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest-core</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Bei Verwendung dieses Starters müssen wir die Versionen aller Abhängigkeiten nicht manuell aktualisieren. Das Spring Boot Parent POM verwaltet alle Abhängigkeitsversionen, und das Spring Boot-Team stellt sicher, dass die verschiedenen Testabhängigkeiten ordnungsgemäß zusammenarbeiten.

Wenn wir aus irgendeinem Grund eine andere Version einer Abhängigkeit wünschen, die von diesem Starter kommt, können wir sie in unserem Abschnitt properties in unserer pom.xml überschreiben:

<project> 
 
  <properties>
    <mockito.version>3.1.0</mockito.version>
  </properties>
 
</project>

Für den Moment ist dies das grundlegende Test-Setup, das jede Spring Boot Anwendung standardmäßig verwendet. Die folgenden Abschnitte befassen sich allen Testabhängigkeiten, die mit diesem Starter geliefert werden.

Einführung in JUnit

JUnit ist die wichtigste Bibliothek, wenn es um das Testen unserer Java-Anwendungen geht.

Es ist das de facto Standard-Testing Framework für Java. Dieses Einführungskapitel wird nicht alle Features von JUnit abdecken, sondern sich eher auf die Grundlagen konzentrieren.

Bevor wir mit den Basics anfangen, lasst uns einen kurzen Blick auf die Geschichte von JUnit werfen. Für lange Zeit war JUnit 4.12 die Hauptversion des Frameworks.

Im Jahr 2017 wurde JUnit 5 eingeführt und besteht nun aus mehreren Modulen:

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

Das JUnit-Team hat viel in dieses Refactoring investiert, um einen stärker plattformbasierten Ansatz mit einem umfassenden Erweiterungsmodell zu erhalten.

Dennoch ist die Migration von JUnit 4 auf 5 mit Aufwand verbunden. Alle Annotations wie @Test befinden sich nun im Paket org.junit.jupiter.api und einige Annotations wurden umbenannt oder entfernt und müssen ersetzt werden.

Hier ein kurzer Überblick über die Unterschiede zwischen den beiden Framework-Versionen:

  • Assertions sind in org.junit.jupiter.api.Assertions enthalten
  • Assumptions sind in org.junit.jupiter.api.Assumptions enthalten
  • @Before und @After existieren nicht mehr; nutze stattdessen @BeforeEach und @AfterEach.
  • @BeforeClass und @AfterClass existieren nicht mehr; nutze stattdessen @BeforeAll und @AfterAll.
  • @Ignore existiert nicht mehr, nutze stattdessen @Disabled oder eine der anderen eingebauten Ausführungsbedingungen
  • @Category existiert nicht mehr, nutze stattdessen @Tag
  • @Rule und @ClassRule existieren nicht mehr; ersetzt durch @ExtendWith und @RegisterExtension
  • @RunWith existiert nicht mehr; ersetzt durch das Erweiterungsmodell mit @ExtendWith

Wenn unsere Codebasis JUnit 4 verwendet, ist die Umstellung der Annotations auf die JUnit-5-Annotations der erste Schritt. Der größte Teil des Migrationsaufwands entfällt auf die Migration benutzerdefinierter JUnit-4-Regeln in JUnit-5-Erweiterungen.

Für unsere tägliche Testentwicklung werden wir die meiste Zeit die folgenden Annotations/Methoden verwendet:

  • @Test, um eine Methode als Test zu markieren
public class FirstTest {
 
  @Test
  void testOne() {
    // our test
  }
 
}
  • assertEquals oder andere Assertions zur Verifizierung des Testergebnisses
import static org.junit.jupiter.api.Assertions.*;
 
@Test
void testOne() {
  assertNotNull("NOT NULL");
  assertNotEquals("John", "Duke");
  assertThrows(NumberFormatException.class, () -> Integer.valueOf("duke"));
  assertEquals("hello world", "HELLO WORLD".toLowerCase());
}
  • @BeforeEach/@AfterEach, um vor und nach einer Testdurchführung Setup-/Teardown-Aufgaben zu erledigen
@BeforeEach
void setup() {
  // setup tasks like populating sample data
}
 
@AfterEach
void tearDown() {
  // cleanup tasks like deleting database rows
}
  • @ExtendWith, um eine Erweiterung wie @ExtendWith(SpringExtension.class) mit einzuschließen
@ExtendWith(SpringExtension.class)
class OrderServiceTest {
 
}
  • @ParametrizedTest, um einen parametrisierten Test auf der Grundlage verschiedener Eingabequellen (z. B. CSV-Datei oder Liste) durchzuführen

Weitere Informationen zu JUnit 5 und Tipps zur Migration findest du im ausgezeichneten User Guide von JUnit 5.

Während wir die grundlegenden JUnit-Funktionen für fast jeden Test verwenden, gibt es auch großartige fortgeschrittene Funktionen von JUnit 5, die nicht allen bekannt sind.

Einführung in Mockito

Mockito stellt sich selbst als…

Tasty mocking framework for unit tests in Java

vor, also als „leckeres Mocking-Framework für Unit Tests in Java“.

Der Hauptgrund, Mockito zu verwenden, ist das Stubbing von Methoden und die Verifizierung der Interaktion an Objekten. Ersteres ist wichtig, wenn wir Unit-Tests schreiben und unsere zu testende Klasse Kollaborateure hat (andere Klassen, von denen diese Klasse abhängt).

Da sich unser Unit Test nur auf das Testen der zu testenden Klasse konzentrieren soll, mocken wir das Verhalten aller abhängigen Kollaborateure.

Ein Beispiel erklärt das womöglich besser. Angenommen, wir wollen Unit Tests für den folgenden PricingService schreiben:

public class PricingService {
 
  private final ProductVerifier productVerifier;
 
  public PricingService(ProductVerifier productVerifier) {
    this.productVerifier = productVerifier;
  }
 
  public BigDecimal calculatePrice(String productName) {
    if (productVerifier.isCurrentlyInStockOfCompetitor(productName)) {
      return new BigDecimal("99.99");
    }
 
    return new BigDecimal("149.99");
  }
}

Unsere Klasse benötigt eine Instanz von ProductVerifier, damit die Methode calculatePrice(String productName) funktioniert.

Während des Schreibens eines Unit Tests wollen wir keine Instanz von ProductVerifier erschaffen und lieber einen Stub dieser Klasse verwenden.

Der Grund dafür ist, dass unser Unit-Test sich darauf konzentrieren sollte, nur eine Klasse zu testen und nicht mehrere zusammen. Darüber hinaus könnte der ProductVerifier auch andere Objekte/Ressourcen/Netzwerke/Datenbanken benötigen, um richtig zu funktionieren, was zu einer Testkonfigurations-Hölle führen könnte (ohne Mocks).

Mit Mockito können wir ganz einfach einen Mock (auch Stub genannt) vom ProductVerifier erschaffen. Das ermöglicht uns, das Verhalten dieser Klasse zu kontrollieren und sie dazu zu bringen, alles zurückzugeben, was wir in einem bestimmten Testfall benötigen.

Ein erster Test könnte verlangen, dass das ProductVerifier-Objekt true zurückgibt.

Mit Mockito ist das Ganze so simpel:

import java.math.BigDecimal;
 
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
 
// register the Mockito JUnit Jupiter extension
@ExtendWith(MockitoExtension.class)
class PricingServiceTest {
 
  @Mock // Instruct Mockito to mock this object
  private ProductVerifier mockedProductVerifier;
 
  @Test
  void shouldReturnCheapPriceWhenProductIsInStockOfCompetitor() {
    //Specify what boolean value to return for this test
    when(mockedProductVerifier.isCurrentlyInStockOfCompetitor("AirPods")).thenReturn(true);
 
    PricingService classUnderTest = new PricingService(mockedProductVerifier);
 
    assertEquals(new BigDecimal("99.99"), classUnderTest.calculatePrice("AirPods"));
  }
}

Das obige Beispiel soll eine erste Vorstellung liefern, warum wir Mockito brauchen.

Ein zweiter Anwendungsfall für Mockito ist die Verifizierung einer Interaktion eines Objekts während der Testausführung.

Lasst uns den PricingService so erweitern, dass er den niedrigeren Preis meldet, wenn der Mitbewerber das gleiche Produkt auf Lager hat:

public BigDecimal calculatePrice(String productName) {
  if (productVerifier.isCurrentlyInStockOfCompetitor(productName)) {
    productRepoter.notify(productName);
    return new BigDecimal("99.99");
  }
 
  return new BigDecimal("149.99");
}

Die Methode notify(String productName) ist void. Daher müssen wir den Rückgabewert dieses Calls nicht mocken, da er nicht für den Ausführungsablauf unserer Implementierung verwendet wird. Dennoch wollen wir verifizieren, dass unser PricingService ein Produkt meldet.

Dafür können wir nun Mockito verwenden, um zu überprüfen, ob die Methode notify(String productName) mit dem richtigen Argument aufgerufen wurde. Damit dies funktioniert, müssen wir auch den ProductReporter während unserer Testausführung mocken und können dann die verify(...)-Methode von Mockito verwenden:

@ExtendWith(MockitoExtension.class)
class PricingServiceTest {
 
  // ... rest like above
 
  @Mock
  private ProductReporter mockedProductReporter;
 
  @Test
  void shouldReturnCheapPriceWhenProductIsInStockOfCompetitor() {
    when(mockedProductVerifier.isCurrentlyInStockOfCompetitor("AirPods")).thenReturn(true);
 
    PricingService cut = new PricingService(mockedProductVerifier, mockedProductReporter);
 
    assertEquals(new BigDecimal("99.99"), cut.calculatePrice("AirPods"));
 
    //verify the interaction
    verify(mockedProductReporter).notify("AirPods"); 
  }
}

Einführung in Hamcrest

Auch wenn JUnit seine eigenen Assertions im Paket org.junit.jupiter.api.Assertions mitliefert, können wir auch eine andere Assertion-Bibliothek verwenden. Hamcrest ist solch eine Assertion-Bibliothek.

Die Assertions, die wir mit Hamcrest schreiben, folgen einem eher satzartigen Ansatz, der sie lesbarer macht.

Während die folgende Assertion mit JUnit so aussehen könnte:

assertEquals(new BigDecimal("99.99"), classUnderTest.calculatePrice("AirPods"));

…erreichen wir mit Hamcrest das Gleiche, indem wir das hier machen:

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
 
assertThat(classUnderTest.calculatePrice("AirPods"), equalTo(new BigDecimal("99.99")));

Abgesehen von der Tatsache, dass es sich eher wie ein englischer Satz liest, ist auch die Reihenfolge der Parameter anders. JUnit assertEquals nimmt den erwarteten Wert als erstes Argument und den tatsächlichen Wert als zweites Argument.

Hamcrest macht es genau andersherum:

assertEquals(expected, actual);
assertThat(actual, equalTo(expected));

Die Hamcrest Matchers-Klasse stellt funktionsreiche Matcher wie contains(), isEmpty(), hasSize(), etc. zur Verfügung, die wir zum Schreiben von Tests benötigen.

Ob wir die Assertions von JUnit, Hamcrest oder die Matcher der Assertions-Bibliothek aus dem nächsten Kapitel verwenden, hängt von unserem persönlichen Gusto ab. Alle Assertion-Bibliotheken erreichen das Gleiche - sie unterscheiden sich lediglich in der Syntax und der Anzahl der unterstützten Assertions.

Nichtsdestotrotz empfehle ich, innerhalb desselben Projekts oder zumindest derselben Testklasse an einer einzigen Assertion-Bibliothek festzuhalten.

Einführung in AssertJ

AssertJ ist eine weitere Assertion-Bibliothek, die das Schreiben von Fluent Assertions für Java-Tests ermöglicht. Sie verfolgt einen ähnlichen Ansatz wie Hamcrest, da sie die Assertion besser lesbar macht.

Lasst uns unsere JUnit-Assertion als Beispiel für einen Vergleich wieder aufnehmen:

assertEquals(new BigDecimal("99.99"), classUnderTest.calculatePrice("AirPods"));

Mit AssertJ würde das wie folgt geschrieben werden:

import static org.assertj.core.api.Assertions.assertThat;
 
assertThat(classUnderTest.calculatePrice("AirPods")).isEqualTo(new BigDecimal("99.99"));

Die verfügbaren Assertions sind ebenfalls funktionsreich und bieten alles, was wir brauchen.

Da Spring Boot Starter verschiedene Bibliotheken für Assertions enthält und man während der Testentwicklung durcheinander kommen könnte, müssen wir sicherstellen, dass wir die richtige Assertion für die Testfälle importieren.

Es ist nicht möglich, sie innerhalb einer Assertion zu mischen, und da sie alle sehr ähnlich benannt sind, sollten wir innerhalb der Testklasse bei einer bleiben.

Einführung in JSONassert

JSONassert hilft beim Schreiben von Unit-Tests für JSON-Datenstrukturen. Das kann beim Testen der API-Endpunkte unserer Spring Boot Anwendung sehr hilfreich sein.

Die Bibliothek arbeitet sowohl mit JSON als String als auch mit der JSONObject / JSONArray-Klasse von org.json.

Die meisten der assertEquals()-Methoden erwarten einen boolean Wert, um die “strictness” der Assertion zu definieren. Wenn sie auf false gesetzt ist, schlägt die Assertion nicht fehl, wenn das JSON mehr Felder als erwartet enthält.

Die offizielle Empfehlung für die Striktheit ist die Folgende:

Es wird empfohlen, strictMode ausgeschaltet zu lassen, damit deine Tests weniger zerbrechlich sind. Schalte ihn ein, wenn du eine bestimmte Reihenfolge für Arrays erzwingen musst oder wenn du sicherstellen willst, dass das tatsächliche JSON keine Felder enthält, die über das erwartete Maß hinausgehen.

Ein Beispiel hilft beim Verständnis:

@Test
void jsonAssertExample() throws JSONException {
  String result = "{\"name\": \"duke\", \"age\":\"42\"}";
  JSONAssert.assertEquals("{\"name\": \"duke\"}", result, false);
}

Ein JUnit-Test mit der obigen Assertion wird grün sein, da das erwartete Feld name den Wert duke enthält.

Wenn wir jedoch die “strictness” auf true setzen, wird der obige Test mit der folgenden Fehlermeldung fehlschlagen:

String result = "{\"name\": \"duke\", \"age\":\"42\"}";
JSONAssert.assertEquals("{\"name\": \"duke\"}", result, true);
 
java.lang.AssertionError: 
Unexpected: age
   at org.skyscreamer.jsonassert.JSONAssert.assertEquals(JSONAssert.java:417)
   ....

Einführung in JsonPath

Während JSONAssert hilft, Assertions für ganze JSON-Dokumente zu schreiben, ermöglicht JsonPath die Extraktion bestimmter Teile unseres JSON unter Verwendung einer JsonPath-Expression.

Die Bibliothek selbst stellt keinerlei Assertions zur Verfügung und wir können sie mit jeder der bereits genannten Assertion-Bibliotheken nutzen.

Was XPath für XML-Dokumente ist, ist JsonPath für JSON-Payloads. JsonPath definiert ein Set von Operatoren und Funktionen, die wir für unsere Expressions verwenden können.

Als erstes Beispiel wollen wir die Länge eines Arrays und den Wert eines Attributs verifizieren:

@Test
void jsonPathExample() {
  String result = "{\"age\":\"42\", \"name\": \"duke\", \"tags\":[\"java\", \"jdk\"]}";
 
  // Using JUnit 5 Assertions
  assertEquals(2, JsonPath.parse(result).read("$.tags.length()", Long.class));
  assertEquals("duke", JsonPath.parse(result).read("$.name", String.class));
}

Sobald wir mit der Syntax der JsonPath-Expression vertraut sind, können wir jede Eigenschaft unseres verschachtelten JSON-Objekts lesen:

assertEquals("your value", JsonPath.parse(result).read("$.my.nested.values[0].name", String.class));

Zusammenfassung des Spring Boot Starter Tests

Die wichtigsten Erkenntnisse aus dieser Anleitung lassen sich wie folgt zusammenfassen:

  • Der Spring Boot Starter Test ergänzt jedes Spring Boot-Projekt um eine solide Testgrundlage.
  • Die Versionen der Testabhängigkeiten werden von Spring Boot verwaltet, können aber überschrieben werden.
  • JUnit ist das Test-Framework zum Starten von Tests auf der JVM.
  • Mockito ist das de-facto Standard-Mocking-Framework für Java-Projekte.
  • Wähle eine Assertion-Bibliothek für das Schreiben von Tests: JUnit’s eingebaute Assertions, Hamcrest oder AssertJ..
  • Sowohl JSONassert als auch JsonPath helfen beim Schreiben von Tests für JSON-Datenstrukturen.

Wenn es um Integrationstests geht, solltest du in Erwägung ziehen, die folgenden Abhängigkeiten hinzuzufügen: WireMock, Testcontainers oder Selenium.

Viel Spaß beim Testen deiner Spring Boot Anwendung mit dem Spring Boot Starter Test!

Geschrieben von

Mein Name ist Björn. Ich bin freiberuflicher IT Berater, Trainer und Autor. Ich beschäftige mich mit Enterprise Anwendungen und Web Apps. Mein technologischer ...