Integration-Tests mit Testcontainers in Spring Boot 3
1. Übersicht
Integration-Tests auf H2 sehen auf Ihrem Rechner oft grün aus, schlagen aber auf Production mit PostgreSQL fehl. Der Grund sind unterschiedliche SQL-Syntax, Datentypen und Transaktionsverhalten der beiden Datenbanksysteme. Testcontainers löst dieses Problem, indem es beim Test-Start eine echte Datenbank in einem Docker-Container startet.
In diesem Artikel konfigurieren Sie Testcontainers für eine Spring-Boot-3.x-Anwendung und verwenden @ServiceConnection, um den Container mit Spring zu verbinden. Danach teilen Sie einen Container zwischen mehreren Test-Klassen und lernen häufige Fehler kennen.
2. Vorbereitung
2.1. Abhängigkeiten
Sie benötigen Docker, das auf Ihrem Rechner läuft, oder einen kompatiblen Docker-Daemon. Fügen Sie dann diese Abhängigkeiten in pom.xml hinzu:
<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.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<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>
Das Spring-Boot-Parent verwaltet bereits die Version von Testcontainers, daher müssen Sie <version> nicht angeben. Das Artefakt spring-boot-testcontainers gibt es ab Spring Boot 3.1 und stellt die Annotation @ServiceConnection bereit.
2.2. Zu testender Code
Wir verwenden eine einfache Entity Order:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerEmail;
private BigDecimal total;
@Enumerated(EnumType.STRING)
private OrderStatus status;
protected Order() {
}
public Order(String customerEmail, BigDecimal total, OrderStatus status) {
this.customerEmail = customerEmail;
this.total = total;
this.status = status;
}
public Long getId() { return id; }
public String getCustomerEmail() { return customerEmail; }
public BigDecimal getTotal() { return total; }
public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }
Das Repository hat eine Native Query, die ILIKE von PostgreSQL verwendet:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query(value = "select * from orders where customer_email ilike :pattern",
nativeQuery = true)
List<Order> searchByEmail(@Param("pattern") String pattern);
List<Order> findByStatus(OrderStatus status);
}
Native Queries sind genau die Art von Code, die auf H2 funktioniert, aber auf Postgres kaputt gehen, oder umgekehrt. Hier sollten Sie auf einer echten Datenbank testen.
3. Repository testen mit @ServiceConnection
3.1. Container deklarieren
@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Test
void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));
List<Order> result = orderRepository.searchByEmail("%an.nguyen%");
assertThat(result)
.extracting(Order::getCustomerEmail)
.containsExactly("An.Nguyen@example.com");
}
}
Hier ist, was diese Komponenten bewirken:
@Testcontainersaktiviert die JUnit-5-Erweiterung. Diese sucht nach Feldern mit@Containerund verwaltet deren Lebenszyklus.@Containerauf einemstatic-Feld: Der Container wird einmal vor allen Tests in der Klasse gestartet und nach dem letzten Test beendet. Wenn das Feld nichtstaticist, wird der Container für jede Test-Methode neu erstellt.@ServiceConnection: Spring Boot liest Host, zufälliger Port, Benutzername und Passwort aus dem Container und erstellt einJdbcConnectionDetails-Bean. Dadurch müssen Siespring.datasource.urlnicht manuell konfigurieren.@AutoConfigureTestDatabase(replace = NONE): Standardmäßig kann@DataJpaTestdie DataSource durch eine eingebettete Datenbank ersetzen. Ab Spring Boot 3.4 erkennt das Framework automatisch, dass die DataSource von@ServiceConnectionkommt. Allerdings hilft die explizite Deklaration, dass der Code auf allen Versionen 3.x richtig läuft.ddl-auto=create-drop: Bei nicht eingebetteten Datenbanken erstellt Spring Boot standardmäßig keine Schema. In echten Projekten sollten Sie Flyway oder Liquibase verwenden, damit Tests die Production-Migrationen korrekt ausführen.
Beim Ausführen der Tests sieht das Log so aus:
Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...
Das erste Mal dauert länger, da Docker das Image pullen muss. Danach dauert es normalerweise nur noch wenige Sekunden.
3.2. Die alte Weise: @DynamicPropertySource
Vor Spring Boot 3.1 oder bei Containern, die noch nicht von @ServiceConnection unterstützt werden, pushen Sie Properties manuell:
@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
Dies funktioniert immer noch. Allerdings ist @ServiceConnection für PostgreSQL, MySQL, Redis, Kafka, MongoDB und viele andere beliebte Services prägnanter und weniger anfällig für Tippfehler bei Property-Namen.
4. Testen der gesamten Anwendung mit @SpringBootTest
Repository-Tests laden nur die JPA-Schicht. Um den Fluss HTTP → Service → Datenbank zu überprüfen, müssen Sie den gesamten Kontext laden. Nehmen Sie diesen Controller an:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderRepository orderRepository;
public OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public record CreateOrderRequest(String customerEmail, BigDecimal total) {}
@PostMapping
public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
Order saved = orderRepository.save(
new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@GetMapping("/{id}")
public ResponseEntity<Order> get(@PathVariable Long id) {
return ResponseEntity.of(orderRepository.findById(id));
}
}
4.1. Container mit @TestConfiguration teilen
Statt den Container in jeder Test-Klasse zu deklarieren, machen Sie ihn zu einem Bean:
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}
Hier wird der Container von Spring verwaltet, nicht von JUnit. Spring startet den Container, bevor die DataSource erstellt wird, und beendet ihn, wenn der Kontext geschlossen wird. Spring TestContext cached Kontexte zwischen Test-Klassen mit derselben Konfiguration, daher teilen diese Klassen einen Container.
4.2. Test schreiben
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void cleanDatabase() {
orderRepository.deleteAll();
}
@Test
void whenCreateOrder_thenItIsPersistedAndReadable() {
var request = new OrderController.CreateOrderRequest(
"chi@example.com", new BigDecimal("250000"));
ResponseEntity<Order> created =
restTemplate.postForEntity("/orders", request, Order.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = created.getBody().getId();
Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);
assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
}
}
Ein paar Punkte zu beachten:
RANDOM_PORTstartet einen echten Server.TestRestTemplatezeigt automatisch auf diesen Port.- Dieser Test hat keine
@Transactional, da HTTP-Requests auf einem anderen Thread als der Test laufen. Daten werden wirklich in Postgres committed, daher müssen Sie in@BeforeEachaufräumen. isEqualByComparingTovergleichtBigDecimalnach Wert. Dies vermeidet Fehler wie250000unterscheidet sich von250000.00aufgrund unterschiedlicher Skalierung.- Die Klasse
Ordermuss von Jackson deserialisiert werden. Wenn Sie einen Deserialisierungsfehler erhalten, überprüfen Sie den Standardkonstruktor und Getter oder verwenden Sie einen separaten DTO-Record für die Response.
4.3. Konfiguration beim lokalen Ausführen der Anwendung wiederverwenden
TestcontainersConfig hat noch einen wenig bekannten Zweck: Sie können die Anwendung lokal ausführen, ohne Postgres zu installieren. Erstellen Sie diese Klasse in src/test/java:
public class TestOrderApplication {
public static void main(String[] args) {
SpringApplication.from(OrderApplication::main)
.with(TestcontainersConfig.class)
.run(args);
}
}
Führen Sie dann diesen Befehl aus:
./mvnw spring-boot:test-run
Die Anwendung startet mit einem Postgres-Container. Der Container stoppt, wenn Sie die Anwendung beenden.
5. Häufige Fehler
5.1. Statischer @Container in mehreren Klassen, die einen Kontext teilen
Dies ist der frustrierendste Fehler. Angenommen, Sie haben eine abstrakte Basisklasse mit @Container static und @ServiceConnection, und mehrere Test-Klassen erben davon:
OrderControllerIT -> Container startet auf Port 55001, Kontext wird gecacht
CustomerControllerIT -> JUnit beendet alten Container, startet neuen auf Port 55017
-> Spring verwendet gecachten Kontext wieder und zeigt immer noch auf 55001
-> Connection refused / HikariPool timeout
JUnit beendet den Container nach jeder Klasse, aber Spring hält den alten Kontext im Cache. Sie haben zwei Lösungsoptionen:
- Verwenden Sie einen Bean-Container wie in Abschnitt 4.1. Der Container-Lebenszyklus ist dann an den Kontext gebunden. Dies ist die empfohlene Methode.
- Verwenden Sie einen Singleton-Container: Lassen Sie
@Containerund@Testcontainersweg und starten Sie den Container manuell in einem statischen Block. Ryuk bereinigt den Container, wenn die JVM beendet wird.
public abstract class AbstractIntegrationTest {
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRES.start();
}
}
5.2. Das Tag latest verwenden
Hinweis: Heften Sie immer die Image-Version an (
postgres:16-alpine), und idealerweise sollte sie mit der Production-Version übereinstimmen. Das Taglatestkann dazu führen, dass Tests unerwartet ihr Verhalten ändern, wenn ein neues Image veröffentlicht wird.
5.3. Datenlecks zwischen Tests
@DataJpaTest rollback standardmäßig nach jedem Test, aber @SpringBootTest mit RANDOM_PORT nicht. Wenn Sie einen Container teilen, befinden sich Daten aus der vorherigen Klasse immer noch in der Datenbank. Bereinigen Sie die Daten in @BeforeEach oder verwenden Sie @Sql, um ein TRUNCATE-Skript auszuführen. Schreiben Sie keine Tests, die von der Ausführungsreihenfolge abhängen.
5.4. CI-Umgebung ohne Docker
Wenn Docker nicht läuft, berichten Tests einen Fehler:
java.lang.IllegalStateException: Could not find a valid Docker environment.
Sie müssen sicherstellen, dass der CI-Runner Docker hat. Wenn Sie Tests überspringen möchten, wenn Docker nicht vorhanden ist, anstatt fehlzuschlagen, verwenden Sie @Testcontainers(disabledWithoutDocker = true). Überdenken Sie dies jedoch sorgfältig, da stillschweigend übersprungene Tests leicht übersehen werden können.
5.5. Lokale Beschleunigung durch Wiederverwendung
Um Container zwischen lokalen Test-Läufen lebendig zu halten, aktivieren Sie Wiederverwendung in ~/.testcontainers.properties:
testcontainers.reuse.enable=true
Rufen Sie dann .withReuse(true) beim Erstellen des Containers auf. Aktivieren Sie diese Option nicht auf CI, da Container nicht automatisch bereinigt werden.
6. Wann Sie Testcontainers nicht verwenden sollten
Testcontainers machen Tests zuverlässiger, aber jeder Test wird langsamer und erfordert Docker. Es gibt einige Fälle, in denen Sie es nicht verwenden sollten:
- Reine Logik wie Preisberechnung, Validierung oder Mapping: Unit-Tests mit Mock oder echten Objekten sind ausreichend. Unit-Tests laufen hundertfach schneller.
- Jede Test-Klasse mit einer anderen Kontext-Konfiguration: Jede Konfiguration erstellt einen neuen Kontext. Wenn der Container an den Kontext gebunden ist, werden Sie viele Container parallel ausführen. Gruppieren Sie Tests um einige wenige gemeinsame Konfigurationen.
- Drittanbieter-Dienste über HTTP: Normalerweise sollten Sie WireMock verwenden, anstatt zu versuchen, den echten Dienst in einem Container auszuführen.
Ein vernünftiges Verhältnis ist: die Mehrheit sind Unit-Tests, während Integration-Tests mit Testcontainers sich auf Stellen konzentrieren, die die Infrastruktur berühren, wie Repository, Native Queries, Migrationen und Message Broker.
7. Fazit
In diesem Artikel haben Sie Testcontainers verwendet, um Integration-Tests auf echtem PostgreSQL auszuführen. @ServiceConnection eliminiert die manuelle DataSource-Konfiguration vollständig. Das Platzieren des Containers als Bean in @TestConfiguration hilft sowohl, einen Container zwischen mehreren Test-Klassen zu teilen, als auch, die Anwendung lokal mit spring-boot:test-run auszuführen.
Wichtige Punkte zum Merken:
- Heften Sie die Image-Version an, um sie mit Production abzustimmen.
- Mischen Sie nicht statische
@Containermit Spring’s Kontext-Cache. - Bereinigen Sie Daten selbst, wenn Tests nicht in einer Transaktion laufen.
- Verwenden Sie Testcontainers nur dort, wo Sie wirklich eine echte Datenbank benötigen.