Java: Integrationstests mit Testcontainern
Noch vor einiger Zeit waren die Entwicklung und der Betrieb von Software zwei getrennte Aufgabenbereiche. Mit Einzug der agilen Software-Entwicklung stieg auch die Erwartungshaltung, schneller zu entwickeln und häufiger die Aktualisierungen auszurollen. Dadurch rückte auch verstärkt das Testen von Software in den Vordergrund. Denn eine hohe Testabdeckung schafft Selbstvertrauen, dass die Software auch weiterhin stabil läuft. Unit-Tests sind schnell und einfach realisiert, testen aber nur einen kleinen Ausschnitt der Anwendung. Dies bedeutet, dass sie zwar für die Überprüfung spezifischer Funktionen oder Komponenten nützlich sind, aber nicht das Verhalten der gesamten Anwendung unter realen Bedingungen abbilden. E2E-Tests hingegen testen die gesamte Anwendung und die Laufzeitumgebung, sind dabei aber die teuerste Art zu testen und auch sehr langsam (manuelles Testen). Dennoch bleiben die E2E-Tests der Lackmustest mit Testdaten und Testszenarien, denn nur diese vermitteln die notwendige Sicherheit, dass die Software die geforderte Stabilität für den Produktivbetrieb erreicht hat. Mit immer kürzeren Release-Zyklen waren die althergebrachten E2E-Test-Strategien nicht mehr zielführend – neue Ansätze beim Testen waren gefragt.
Testcontainer wurde im Jahr 2015 von Richard North auf Github als Open-Source-Bibliothek veröffentlicht. Mittlerweile gibt es Testcontainer für eine Vielzahl an Sprachen. Testcontainer nutzt die API von Docker, um Container zu starten, zu stoppen und zu löschen. Auch Netzwerke lassen sich mit Testcontainer verwenden. Es können alle Container verwendet werden, die auf Docker Hub zu finden sind. Für die populärsten gibt es vorgefertigte Container, z. B. für PostgreSQL oder Redis.
Einsatzszenarien
Bei Datenbanktests wird gerne die H2 verwendet, statt einer richtigen relationalen Datenbank. Beim Testen funktioniert noch alles wunderbar, aber eine H2-Datenbank ist keine PostgreSQL- und im Betrieb schon mal gar nicht. Es kann also keine Betriebssicherheit gewährleistet werden. Auch können Upgrades von Datenbankversionen ein Szenario sein, das man mit Testcontainer testen kann. Einfach die neueste Version der DB in den Testcontainer-Test eintragen und schon kann eine erste Überprüfung stattfinden. Auch bei der Entwicklung von Microservices, die auf andere Microservices zugreifen, können diese mit Testcontainer getestet werden. Dies ermöglicht eine realistischere Simulation der Produktionsumgebung und hilft dabei, potenzielle Probleme frühzeitig zu identifizieren. Testcontainer können hier eine stabile Umgebung bereitstellen, die durch die Entwicklung der Drittsysteme sichergestellt ist. So kann die Entwicklung der Microservices unabhängig voneinander erfolgen und die Tests sind aussagekräftig und sicher.
Voraussetzungen
Wir konzentrieren uns hier auf Java mit Spring Boot, JUnit 5 und als Build-Werkzeug nehmen wir Maven.
Docker
Wir brauchen für die Tests eine docker-kompatible Laufzeitumgebung. Für die Installation von Docker auf Windows und Mac gibt es Docker Desktop. Es bieten sich auch andere Runtimes wie Rancher Desktop, Podman oder Orbstack an. Auf die Installation von Docker gehen wir in diesem Artikel nicht ein.
Ausführen
Um unsere Beispieltests ausführen zu können, reicht es bei einer laufenden Docker-Schicht, das Maven Goal auszuführen, das die Tests anstößt – den Rest erledigt dann Testcontainer.
Typen und Arbeitsweise von Testcontainer
Testcontainer startet einen Hilfscontainer, der das Einrichten und Starten der Container vornimmt. Nach den Tests werden die Container wieder heruntergefahren und gelöscht. Testcontainer verwendet die Docker API, um die Container zu verwalten. Für populäre Container gibt es von Testcontainer vorgefertigte Klassen, die schon einige Einstellungen mitbringen. Hier ein Beispiel von PostgreSQL:
public class PostgreSQLContainer<SELF extends PostgreSQLContainer<SELF>> extends JdbcDatabaseContainer<SELF> {
public static final String NAME = "postgresql";
public static final String IMAGE = "postgres";
public static final String DEFAULT_TAG = "9.6.12";
private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("postgres");
public static final Integer POSTGRESQL_PORT = 5432;
static final String DEFAULT_USER = "test";
static final String DEFAULT_PASSWORD = "test";
private String databaseName;
private String username;
private String password;
private static final String FSYNC_OFF_OPTION = "fsync=off";
/** @deprecated */
@Deprecated
public PostgreSQLContainer() {
this(DEFAULT_IMAGE_NAME.withTag("9.6.12"));
}
public PostgreSQLContainer(String dockerImageName) {
this(DockerImageName.parse(dockerImageName));
}
public PostgreSQLContainer(DockerImageName dockerImageName) {
super(dockerImageName);
this.databaseName = "test";
this.username = "test";
this.password = "test";
dockerImageName.assertCompatibleWith(new DockerImageName[]{DEFAULT_IMAGE_NAME});
this.waitStrategy = (new LogMessageWaitStrategy()).withRegEx(".*database system is ready to accept connections.*\\s").withTimes(2).withStartupTimeout(Duration.of(60L, ChronoUnit.SECONDS));
this.setCommand(new String[]{"postgres", "-c", "fsync=off"});
this.addExposedPort(POSTGRESQL_PORT);
}
protected @NotNull Set<Integer> getLivenessCheckPorts() {
return Collections.singleton(this.getMappedPort(POSTGRESQL_PORT));
}
protected void configure() {
this.withUrlParam("loggerLevel", "OFF");
this.addEnv("POSTGRES_DB", this.databaseName);
this.addEnv("POSTGRES_USER", this.username);
this.addEnv("POSTGRES_PASSWORD", this.password);
}
public String getDriverClassName() {
return "org.postgresql.Driver";
}
public String getJdbcUrl() {
String additionalUrlParams = this.constructUrlParameters("?", "&");
return "jdbc:postgresql://" + this.getHost() + ":" + this.getMappedPort(POSTGRESQL_PORT) + "/" + this.databaseName + additionalUrlParams;
}
public String getDatabaseName() {
return this.databaseName;
}
public String getUsername() {
return this.username;
}
public String getPassword() {
return this.password;
}
public String getTestQueryString() {
return "SELECT 1";
}
public SELF withDatabaseName(String databaseName) {
this.databaseName = databaseName;
return (PostgreSQLContainer)this.self();
}
public SELF withUsername(String username) {
this.username = username;
return (PostgreSQLContainer)this.self();
}
public SELF withPassword(String password) {
this.password = password;
return (PostgreSQLContainer)this.self();
}
protected void waitUntilContainerStarted() {
this.getWaitStrategy().waitUntilReady(this);
}
}
Auf die Erstellung von eigenen Containern gehen wir später ein.
Konfiguration und Einrichtung
Einfaches Beispiel
Testcontainer wird von Spring Boot seit einiger Zeit unterstützt. Ein einfaches Spring-Boot-Beispiel mit PostgreSQL:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "user", schema = "public")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@NotNull
@Column(name = "uuid")
private UUID uuid;
@NotNull
@Column(name = "email")
private String email;
@NotNull
@Column(name = "passwort")
private String passwort;
@NotNull
@Column(name = "salz")
private String salz;
}
public interface UserRepository extends JpaRepository<User, UUID> {
User findByUuid(UUID uuid);
}
@Service
@AllArgsConstructor
public class UserService {
private final UserRepository userRepository;
public List<User> getUsers() {
return userRepository.findAll();
}
}
@RestController
@AllArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/api/v1/users")
public List<User> getUsers() {
return userService.getUsers();
}
}
@SpringBootTest
public class PostgresControllerTest {
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:16.0-alpine"
);
@BeforeAll
static void beforeAll() {
postgres.start();
}
@AfterAll
static void afterAll() {
postgres.stop();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void setUp() {
final UUID uuid = UUID.randomUUID();
User user = User.builder()
.uuid(uuid)
.email("info@crowdcode.io")
.salz("BQxuGhOyzsOir36nHV4RJA==")
.passwort("123456")
.build();
userRepository.saveAndFlush(user);
userRepository.findAll().forEach(System.out::println);
User checkUser = userRepository.findByUuid(uuid);
assertThat(checkUser).isNotNull();
assertThat(user).isEqualTo(checkUser);
}
}
Die Klasse PostgresControllerTest ist ein JUnit-5-Test. Die Annotation @SpringBootTest startet die Spring Boot Context. Mit der Annotation @DynamicPropertySource werden die Properties zur Laufzeit gesetzt.
Testcontainer mit Spring Boot
Mit der Version 3.1 sind weitere Verbesserungen hinzugekommen. Eine der neuen Funktionen erleichtert Integrationstests mit Testcontainer. Die neue Annotation @ServiceConnection kann für die Container-Instanzfelder der Tests verwendet werden. Die Annotation @TestConfiguration(proxyBeanMethods = false) sorgt dafür, dass die Klasse nicht als Bean in den ApplicationContext aufgenommen wird. Mit der Annotation @ServiceConnection wollen wir nur den PostgreSQLContainer als Bean in den ApplicationContext aufnehmen. Die Annotation @ServiceConnection ist von Spring eine eigene Annotation für Testcontainer.
Dazu erstellen wir eine Konfigurationsklasse, mit der wir die Testcontainer starten und die Properties setzen.
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainerConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgreSQLContainer() {
final PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:16.0-alpine")
.withUsername("test")
.withPassword("test");
postgreSQLContainer.start();
System.setProperty("spring.datasource.url", postgreSQLContainer.getJdbcUrl());
System.setProperty("spring.datasource.username", postgreSQLContainer.getUsername());
System.setProperty("spring.datasource.password", postgreSQLContainer.getPassword());
return postgreSQLContainer;
}
}
Der Test sieht nun wie folgt aus:
@SpringBootTest
@Import(TestcontainerConfiguration.class)
class UserServiceTest {
@Autowired
private UserRepository userRepository;
@Test
void testGetAllUsers() {
final UUID uuid = UUID.randomUUID();
User user = User.builder()
.uuid(uuid)
.email("info@crowdcode.io")
.salz("BQxuGhOyzsOir36nHV4RJA==")
.passwort("123456")
.build();
userRepository.saveAndFlush(user);
userRepository.findAll().forEach(System.out::println);
User checkUser = userRepository.findByUuid(uuid);
assertThat(checkUser).isNotNull();
assertThat(user).isEqualTo(checkUser);
}
}
Eigene Testcontainer
Es gibt viele vorgefertigte Testcontainer, die wir verwenden können. Doch was ist, wenn wir einen eigenen Container benötigen? Dafür gibt es die Klasse GenericContainer. Schauen wir uns die Konfiguration eines Payara Micro Container an. Payara ist ein Open Source Application Server, der auf Glassfish basiert. Wir verwenden hier die Micro-Variante, die nur eine Größe von 100 MB hat. Die Konfiguration sieht wie folgt aus:
@Container
GenericContainer microContainer = new GenericContainer("payara/micro")
.withExposedPorts(8080);
Alles, was an Containern z. B. auf Docker Hub zu finden ist, kann verwendet werden – aber Vorsicht: Docker ist öffentlich! Wir verwenden in Kundenprojekten oft vordefinierte Datenbank-Container, die wir dann mit Testdaten füllen. Die Kollegen loggen sich in unser Nexus Repository ein und können die Container verwenden. Und die Integrationstests laufen dann auch mit den Testdaten.
Container-Netzwerke
Netzwerk
Netzwerke gehen mit Testcontainer sehr einfach. Dazu erweitern wir die Konfigurationsklasse:
static private Network network = Network.newNetwork();
Das Netzwerk wird dem Container mit der Methode withNetwork(network) hinzugefügt:
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainerConfiguration {
static private Network network = Network.newNetwork();
@Bean
@ServiceConnection(name = "redis")
public GenericContainer<?> redisContainer() {
final GenericContainer<?> redisContainer = new GenericContainer<>("redis:6.2.4-alpine");
redisContainer.withExposedPorts(6379);
redisContainer.withNetwork(network);
return redisContainer;
}
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgreSQLContainer() {
final PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:16.0-alpine")
.withUsername("test")
.withPassword("test");
postgreSQLContainer.withNetwork(network);
postgreSQLContainer.start();
System.setProperty("spring.datasource.url", postgreSQLContainer.getJdbcUrl());
System.setProperty("spring.datasource.username", postgreSQLContainer.getUsername());
System.setProperty("spring.datasource.password", postgreSQLContainer.getPassword());
return postgreSQLContainer;
}
}
Proxy
Mit dem Testcontainer-Modul Toxiproxy von Shopify können wir Netzwerkprobleme simulieren. Dazu müssen wir den Proxy-Container starten und die entsprechenden Ports konfigurieren, damit der Proxy-Container weiß, welche Ports er überwachen soll. Noch nicht jeder Testcontainer kann die 3.1 Konfigklassen verwenden, daher gehen wir hier den "manuellen" Weg.
public Network network = Network.newNetwork();
public GenericContainer<?> redis = new GenericContainer<>("redis:5.0.4")
.withExposedPorts(6379)
.withNetwork(network)
.withNetworkAliases("redis");
public ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0")
.withNetwork(network);
Den Proxy-Container konfigurieren wir wie folgt, um mit dem Redis-Container zu kommunizieren:
final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort());
final Proxy proxy = toxiproxyClient.createProxy("redis", "0.0.0.0:8666", "redis:6379");
Um jetzt die Downstream-Geschwindigkeit zu reduzieren, konfigurieren wir den Proxy wie folgt:
proxy.toxics()
.latency("latency", ToxicDirection.DOWNSTREAM, 1100)
.setJitter(100);
Die Latenz beträgt jetzt zwischen 1000 und 1200 ms. Gerade im Bereich der verteilten Microservices kann es zu Latenzproblemen kommen. Mit dem Toxiproxy können wir diese simulieren und testen, wie unsere Software damit umgeht.
Integration in CI/CD Pipelines
E2E-Tests lokal auszuführen hilft beim Debugging. Ihren wahren Wert können wir heben, wenn wir die Ausführung in einer CI/CD-Pipeline bewerkstelligen. Im Folgenden zwei Beispiele, wie so etwas aussehen könnte.
Bitbucket-Pipelines
Die minimale Konfiguration kann so aussehen:
image: maven:3.6.1
pipelines:
default:
- step:
script:
- export TESTCONTAINERS_RYUK_DISABLED=true
- mvn clean install
services:
- docker
definitions:
services:
docker:
memory: 2048
Ein realitätsnäheres Setup sieht naturgemäß etwas umfangreicher aus. Ein Beispiel:
image:
name: nexus.local/maven:temurin11
username: $Nexus_Docker_Username
password: $Nexus_Docker_Password
definitions:
services:
docker:
memory: 2048
custom-dind:
image:
name: nexus.local/custom-dind-image
username: $Nexus_Docker_Username
password: $Nexus_Docker_Password
type: docker
variables:
DOCKER_OPTS: "--insecure-registry=nexus.local"
steps:
- step: &Build
name: Build and test
runs-on:
- 'self.hosted'
- 'linux'
script:
- export TESTCONTAINERS_RYUK_DISABLED=true
- mvn -U -B -e clean install -Pcloud -DskipTests=true
artifacts:
- target/**
services:
- docker
- step: &Unit-tests
name: Unit tests
runs-on:
- 'self.hosted'
- 'linux'
script:
- export TESTCONTAINERS_RYUK_DISABLED=true
- mvn -U -B -e test -Pcloud -Dskip.unit.tests=false -Dskip.integration.tests=true
artifacts:
- target/**
services:
- docker
- step: &Integration-tests
name: Integration tests
runs-on:
- 'self.hosted'
- 'linux'
script:
- export TESTCONTAINERS_RYUK_DISABLED=true
- mvn -U -B -e verify -Pcloud -Dskip.unit.tests=true -Dskip.integration.tests=false
artifacts:
- target/**
services:
- docker
pipelines:
default:
- step: *Build
- step: *Unit-tests
- step: *Integration-tests
Jenkins
Mit Jenkins gestaltet sich die Konfiguration wie folgt:
pipeline {
agent none
options {
gitLabConnection('Crowdcode-Gitlab')
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '6'))
}
stages {
stage('Build-Java-17') {
agent { label 'master' }
stages {
stage('Prepare') {
steps {
withCredentials(
[usernamePassword(credentialsId: 'Crowdcode-Gitlab',
usernameVariable: 'gitUser',
passwordVariable: 'gitPwd'
)]) {
script {
sh "git status"
sh "git checkout ${env.BRANCH_NAME}"
}
}
}
}
stage('Build') {
steps { mvn("clean install") }
}
}
}
}
}
def mvn(param) {
withMaven(
mavenOpts: '-Xmx1536m -Xms512m',
mavenSettingsConfig: 'maven-settings',
maven: 'maven-3.6.0') {
sh "mvn -U -B -e ${param}"
}
}
Hier sind keine besonderen Einstellungen nötig, da alles über Maven und Testcontainer läuft. Unter Umständen kann es sein, dass der entsprechende Agent gewählt werden muss.
Testcontainer Desktop
Seit Kurzem gibt es auch eine Desktop-Anwendung für Testcontainer. Damit wird die lokale Entwicklung mit realen Abhängigkeiten weiter vereinfacht. Voraussetzungen sind Java 17, eine IDE (Intellij IDEA, Eclipse, NetBeans, VS Code) und eine docker-API-kompatible Container Runtime. Wir verwenden hier aktuell die Docker-Desktop-Anwendung. Über die Docker-API werden die Container erstellt und verwaltet.
Mit der Anwendung kann einfach auf eine andere Docker-Laufzeitumgebung gewechselt oder das Test-Setup in der Testcontainer-Cloud als Dienstleistung ausgeführt werden. Praktisch, da die Ressourcen des eigenen Laptops keine große Rolle spielen und auch eine lokale Docker-Laufzeitumgebung nicht nötig ist.
Damit die Testcontainer nicht in Konflikte mit lokalen Containern kommen, verwendet Testcontainer dynamische Ports. Da ist es beim Debuggen z. B. von Datenbankcontainern mit Datenbank IDEs oder SQL-Tools notwendig, den Port ständig anzupassen. Docker Desktop bringt dazu Konfigurationsvorlagen mit, die mit jedem Texteditor geändert werden können.
In dem geöffneten Verzeichnis sollte sich eine Datei postgres.toml.example befinden. Einfach die Datei in postgres.toml umbenennen, dann sollte sie die folgende Konfiguration enthalten:
ports = [
{local-port = 5432, container-port = 5432},
]
selector.image-names = ["postgres"]
Hier wählt man dann einen entsprechend hohen Port, falls noch eigene Datenbank-Container mit Testdaten vorhanden sind.
Ein weiteres Feature ist das Einfrieren der Container, damit sie nicht heruntergefahren und gelöscht werden. Somit bleibt noch Zeit, die Daten in einer Datenbank zu analysieren.
Mit "Freeze containers shutdown" kann man das Herunterfahren der Testcontainer verhindern, sodass der Testcontainer auf unbestimmte Zeit weiterläuft. Sobald die Untersuchung abgeschlossen ist, ist die Einstellung zu deaktivieren, damit die Testausführung fortgesetzt werden kann. Außerdem ist das Löschen des Containers nach dem Beenden der Tests angebracht.
Das Einfrieren von Containern befindet sich aktuell in der Beta-Phase. Derzeit werden nur Container mit einem verwalteten Lebenszyklus unterstützt.
Mit der Einstellung "Enable reusable containers" werden wiederverwendbare Container bei der Entwicklung genutzt. Da die Container nicht neu gebaut werden, unterstützt das eine schnellere Entwicklung. Diese Funktion ist standardmäßig aktiviert und wird wie folgt verwendet:
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:15.2-alpine")
.withReuse(true);
}
Fazit
Testcontainer sind eine sehr gute Möglichkeit, um Integrationstests zu schreiben. Die Tests können schnell eine vollständige Testumgebung aufbauen und werden so aussagekräftig. Die Integration in CI/CD-Pipelines ist einfach und die Tests können auch in der Cloud ausgeführt werden. Mit einer lokalen Docker-Installation können die Testcontainer auch bei der lokalen Entwicklung verwendet werden. Gerade bei umfangreichen Änderungen von Datenbanken oder Drittsystemen können die Testcontainer eine stabile Umgebung bereitstellen, um die eigene Software zu testen.
Also: Happy Coding, happy Testing!