This article is also available in English

Letztes Jahr im November ist mit Version 3.0 nach über fünf Jahren das nächste Major Release von Spring Boot erschienen. Damit läuft auch dieses Jahr im November der freie Support für den letzten noch unterstützten Strang von Spring Boot 2, nämliche 2.7, aus. Gleichzeitig endet auch der freie Support für 3.0 und damit wird das gerade erst erschienene 3.1 die einzige Version mit freiem Support sein.

Die Kernpunkte von Spring Boot 3.0 waren die Unterstützung von neueren Java-Versionen, es muss nun mindestens Java 17 verwendet werden, und das Update von Java EE auf Jakarta EE und die damit einhergehenden Änderungen der Package-Namen. Daneben wurde auch eine ganze Menge weiterer Abhängigkeiten auf den aktuellen Stand gebracht. Vor allem das Upgrade auf Spring Security 6 erfordert dabei in der Regel eine Reihe von Änderungen, bei denen der Migration Guide hilfreich ist. Natürlich gab es auch noch eine Reihe von kleinen Änderungen und Verbesserungen an bestehenden Funktionalitäten.

Spring Boot 3.1 enthält, passend zu meiner letzten Kolumne, vor allem eine bessere und direktere Integration von Testcontainers. Außerdem sticht der neue Support für Docker Compose während der lokalen Entwicklung heraus.

Diese beiden neuen Features wollen wir uns deswegen in dieser Kolumne einmal im Detail anschauen.

Testen mit Testcontainers

Meine letzte Kolumne zeigt zum Schluss einen eigenen Testslice, um auf PostgreSQL basierende Repositories gegen eine mit Testcontainers gestartete Datenbank zu testen. In der Realität wurde jedoch meistens eine simplere Möglichkeit genutzt. Hierzu werden die beiden in Listing 1 zu sehenden Abhängigkeiten inklusive der BOM für Testcontainers benötigt.

...
<dependencies>
  ...
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
  </dependency>
  ...
</dependencies>
...
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>1.18.3</version>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
...
Listing 1: Abhängigkeiten für Testcontainers

Anschließend können wir die Testcontainers JUnit 5 Extension in Kombination mit der von Spring bereitgestellten @DynamicPropertySource verwenden, um einen Testcontainer zu starten und im Spring ApplicationContext bekannt zu machen (s. Listing 2).

@JdbcTest
@Import(GreetingTextRepository.class)
@Testcontainers
@AutoConfigureTestDatabase(replace = NONE)
class GreetingTextRepositoryTest {

    @Container
    static PostgreSQLContainer<?> DATABASE =
        new PostgreSQLContainer<>("postgres:15");

    @DynamicPropertySource
    static void databaseProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",DATABASE::getJdbcUrl);
        registry.add("spring.datasource.username", DATABASE::getUsername);
        registry.add("spring.datasource.password", DATABASE::getPassword);
    }

    @Autowired
    GreetingTextRepository greetingTextRepository;

    @Test
    void getDefaultGreeting_shouldReturnGreetingTextFromDatabase() {
        var greetingText = greetingTextRepository.getDefaultGreetingText();

        assertThat(greetingText)
            .isEqualTo("Hallo %s.");
    }
}
Listing 2: Test mit Testcontainers vor Spring Boot 3.1

Dadurch, dass mit Spring Boot 3.1 nun auch Testcontainers Teil des automatischen Abhängigkeitsmanagements ist, brauchen wir die BOM nicht mehr selbst zu importieren, da dies bereits innerhalb von spring-boot-dependencies geschieht und wir diese BOM entweder indirekt über spring-boot-starter-parent importieren oder es explizit selbst machen.

Weiterhin können wir nun zusätzlich zu den beiden Abhängigkeiten aus Listing 1 noch die in Listing 3 zu sehende Abhängigkeit hinzufügen. Mit dieser ist es nun möglich, die neue Annotation @ServiceConnection zu verwenden und dafür auf eine manuelle Registrierung über @DynamicPropertySource zu verzichten (s. Listing 4).

...
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
...
Listing 3: Spring Boot Testcontainers, Abhängigkeit
@JdbcTest
@Import(GreetingTextRepository.class)
@Testcontainers
@AutoConfigureTestDatabase(replace = NONE)
class GreetingTextRepositoryConnectionDetailsTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> DATABASE =
        new PostgreSQLContainer<>("postgres:15");

    @Autowired
    GreetingTextRepository greetingTextRepository;

    @Test
    void getDefaultGreeting_shouldReturnGreetingTextFromDatabase() {
        var greetingText = greetingTextRepository.getDefaultGreetingText();

        assertThat(greetingText)
            .isEqualTo("Hallo %s.");
    }
}
Listing 4: Test mit Testcontainers mit Spring Boot 3.1

Diese Annotation sorgt dafür, dass eine Spring-Bean vom Typ ConnectionDetails erzeugt wird. Mit Spring Boot 3.1 werden Beans von diesem Typ, beziehungsweise genau genommen von den zur Verfügung gestellten Subtypen, für die Konfiguration der Verbindung zu externen Diensten verwendet. Für per JDBC angebundene Datenbanken wird somit nun eine Bean vom Typen JdbcConnectionDetails verwendet. Sollten wir selbst keine Bean von diesem Typen registrieren, direkt oder über @ServiceConnection an einem Testcontainer, dann wird diese mit den Properties unter spring.datasource erzeugt.

Um bei der Nutzung von @ServiceConnection an einem Testcontainer den genauen Typ zu erkennen, wird in der Regel der Typ des Testcontainers genutzt. Die oben erwähnten JdbcConnectionDetails werden für alle Container registriert, die vom Typen JdbcDatabaseContainer sind. Für andere Arten von Containern, beispielsweise für Redis, wird der Name des Dienstes ausgewertet, um festzustellen, welche ConnectionDetails zur Verfügung gestellt werden müssen. Wird der Name über das Attribut value oder name an der Annotation nicht explizit angegeben, wird der Name des Docker-Images analysiert.

Für Testslices, die @AutoConfigureTestDatabase nutzen, bei denen wir eine mit @ServiceConnection registrierte Verbindung verwenden wollen, müssen wir manuell das Attribute replace auf NONE setzen. Machen wir das nicht, verwendet der Testslice eine In-Memory-Datenbank. Hier kann es in Zukunft noch Verbesserungen geben, beispielsweise über das Ticket 19038, wodurch wir auf dieses explizite Überschreiben der Standards verzichten können.

Lokal entwickeln mit Testcontainers

Neben der Verwendung in Tests gibt es nun auch die Möglichkeit, Testcontainers während der Entwicklung zu nutzen. Hierzu benötigen wir innerhalb unseres Testklassenpfades eine über die main-Methode startbare Klasse (s. Listing 5).

public class TestApplication {

    public static void main(String[] args) {
        SpringApplication
            .from(Application::main)
            .with(ContainerConfiguration.class)
            .run(args);
    }
}
Listing 5: Testanwendungsklasse

Diese sollte, per Konvention, im selben Package wie die Anwendungsklasse liegen und denselben Namen mit dem Präfix Test besitzen. Innerhalb der main-Methode nutzen wir die Möglichkeit, über die from-Methode die gesamte Konfiguration der Anwendung zu laden und diese über with um eine Testkonfiguration zu erweitern. In dieser Testkonfiguration (s. Listing 6) können wir nun Testcontainers als Beans registrieren und über die @ServiceConnection-Annotation dafür sorgen, dass diese als Verbindung verwendet werden.

@TestConfiguration(proxyBeanMethods = false)
public class ContainerConfiguration {

    @Bean
    @ServiceConnection
    public PostgreSQLContainer<?> postgreSQLContainer() {
        return new PostgreSQLContainer<>("postgres:15");
    }
}
Listing 6: Testkonfiguration für Testcontainers

Die Anwendung kann nun lokal gestartet werden, indem wir die Testanwendungsklasse in unserer IDE starten oder durch Ausführung des neuen Maven-Goals spring-boot:test-run beziehungsweise des Gradle-Tasks bootTestRun. Reichen uns die Defaults einer @ServiceConnection nicht oder müssen wir noch weitere Properties konfigurieren, ist es auch, ähnlich wie bei den Tests, möglich, innerhalb der Bean-Registrierung einer Testkonfiguration die DynamicPropertyRegistry zu nutzen (s. Listing 7).

@TestConfiguration(proxyBeanMethods = false)
public class ContainerConfiguration {

    @Bean
    public PostgreSQLContainer<?> postgreSQLContainer(
            DynamicPropertyRegistry registry) {
        var container = new PostgreSQLContainer<>("postgres:15");
        registry.add("spring.datasource.url", container::getJdbcUrl);
        registry.add("spring.datasource.username", container::getUsername);
        registry.add("spring.datasource.password", container::getPassword);
        return container;
    }
}
Listing 7: Testkonfiguration für Testcontainers mit DynamicPropertyRegistry

Nutzen wir während der Entwicklung die spring-boot-devtools, sehen wir, dass beim erneuten Laden der Anwendung nach einer Änderung auch ein neuer Container gestartet wird. Dies kann erwünscht sein, führt aber auch dazu, dass nach jedem neuen Laden alle vorher erstellten Daten wieder verschwunden sind. Wollen wir das vermeiden, so können wir die Bean-Registrierung des Testcontainers um die Annotation @RestartScope erweitern (s. Listing 8).

@TestConfiguration(proxyBeanMethods = false)
public class ContainerConfiguration {

    @Bean
    @RestartScope
    @ServiceConnection
    public PostgreSQLContainer<?> postgreSQLContainer() {
        return new PostgreSQLContainer<>("postgres:15");
    }
}
Listing 8: Testkonfiguration für Testcontainers mit RestartScope

Alternativ kann auch das, aktuell noch experimentelle, Feature für Reusable Containers genutzt werden. Da dies jedoch vom in Spring Boot dokumentierten Vorgehen abweicht und noch experimentell ist, würde ich zur Verwendung von @RestartScope raten.

Lokal entwickeln mit Docker Compose

In meinen letzten Projekten war es üblich, um die zur Entwicklung benötigten externen Services zu starten, Docker Compose zu nutzen. Hierzu existierte eine compose.yml-Datei, siehe Listing 9, und bevor die Anwendung gestartet wurde musste docker compose up ausgeführt werden. Nachdem die Anwendung gestoppt wurde, konnte dann mit docker compose down dafür gesorgt werden, dass auch die externen Services gestoppt werden.

services:

    postgres:

        image: 'postgres:15'

        environment:

            POSTGRES_PASSWORD: password

        ports:

            - 5432
Listing 9: Beispiel für compose.yml

Genau dieser Workflow wird nun mit Spring Boot 3.1 direkt unterstützt. Dazu müssen wir, wie in Listing 10 zu sehen, eine Abhängigkeit auf das neue spring-boot-docker-compose hinzufügen. Starten wir nun unsere Anwendung, können wir im Log (s. Listing 11) sehen, dass unsere compose.yml erkannt und der darin definierte Service postgres gestartet wurde.

...
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-docker-compose</artifactId>
  <scope>runtime</scope>
  <optional>true</optional>
</dependency>
...
Listing 10: Spring Boot Docker Compose, Abhängigkeit
... : Starting Application using Java ...
...
... : Using Docker Compose file '... /compose.yml'
... : Network javaspektrum-spring-boot31_default Creating
... : Network javaspektrum-spring-boot31_default Created
... : Container javaspektrum-spring-boot31-postgres-1 Creating
... : Container javaspektrum-spring-boot31-postgres-1 Created
... : Container javaspektrum-spring-boot31-postgres-1 Starting
... : Container javaspektrum-spring-boot31-postgres-1 Started
... : Container javaspektrum-spring-boot31-postgres-1 Waiting
... : Container javaspektrum-spring-boot31-postgres-1 Healthy
...
... : Started Application in 5.546 seconds (process running for 5.946)
...
Listing 11: Log beim Start mit Spring Boot Docker Compose

Standardmäßig wird dabei jedoch nicht down, sondern stop zum Stoppen verwendet. Somit bleibt der Container, nachdem wir die Anwendung anhalten, erhalten. Wollen wir dies ändern, lässt sich das über den Konfigurationswert spring.docker.compose.stop.command erledigen. Ähnliches gilt für den Ort und den Namen der Docker Compose-Datei. Dieser kann über spring.docker.compose.file geändert werden.

Neben dem Starten und Stoppen der innerhalb von Docker Compose definierten Services werden auch hier, wie beim Testcontainer Support, automatisch Service Connections erzeugt. Um zu erkennen, welche Art von Service Connection von einem Service bereitgestellt wird, wird der Name des Container Images analysiert. Sollte das nicht funktionieren, weil ein eigenes Image verwendet wird, gibt es, wie in Listing 12 zu sehen, die Möglichkeit, dies selbst zu spezifizieren. Listing 12 zeigt dabei gleichzeitig auch noch, wie es möglich ist, einen Service zu definieren, der gleichzeitig mit der Anwendung gestartet und gestoppt wird, aber für den keine Service Connection erzeugt werden soll.

services:

    postgres:

        image: 'postgres:15'

        environment:

            POSTGRES_PASSWORD: password

        ports:

            - 5432

        labels:

            org.springframework.boot.service-connection: jdbc

    redis:

        image: 'redis:7'

        ports:

            - 6379

        labels:

            org.springframework.boot.ignore: true
Listing 12: Verwendung von Labels zur Konfiguration von Service Connections

Um zu erkennen, wann ein Service erfolgreich gestartet ist, wird der definierte healthcheck aus der Docker Compose-Datei verwendet. Sollte hier keiner definiert sein, wartet Spring Boot Docker Compose so lange, bis der definierte Port per TCP erreichbar ist. Dies kann auch ausgeschaltet werden, die standardmäßigen Timeouts lassen sich ebenfalls verändern.

Fazit

In diesem Artikel haben wir uns die mit Spring Boot 3.1 eingeführte Unterstützung von Testcontainers für Integrationstests und die lokale Entwicklung und die neue Docker Compose-Funktionalität angeschaut. Wir haben dabei gesehen, wie diese eingesetzt werden können, um sowohl in Tests als auch in der lokalen Entwicklung unser Leben bequemer zu machen.

Ich bin mir sicher, dass diese Features vom Spring Boot-Team in Zukunft noch verbessert und erweitert werden, sodass deren Einsatz in Projekten unbedenklich sein sollte. Natürlich muss jetzt nicht zwangsweise auf diese umgebaut werden, wenn im Projekt ein funktionierender Weg existiert, aber zumindest bei neuen Projekten lohnt ein Blick definitiv.