Neuigkeiten von trion.
Immer gut informiert.

Testcontainers mit JUnit 5 Jupiter

testcontainers (logo)

Regelmäßig kommt in unseren Docker Schulungen Verwunderung auf, wenn wir Beispiele zum Einsatz von Containern im Entwicklungsprozess aufzeigen. Denn Container bzw. Docker bringt gerade da auch immense Vorteile: Neben einer möglichen Parität zwischen Produktionsumgebung und Entwicklersystem ist es gerade die sehr einfache Möglichkeit, Umsysteme als Container bereitzustellen.

Das kann für einen Frontendentwickler das Backend sein, für einen Backendentwickler kann es die richtige Datenbank, Message-Queue oder ein anderer (Micro-)Service sein.
Verfügen aktuelle IDEs in der Regel über Docker-Integration oder wird docker-compose eingesetzt, stellt sich die Frage, wie in CI-Umgebungen Container für Integrationstests am besten eingesetzt werden können. Hier hat das Projekt Testcontainers eine Lösung ins Rennen geschickt: Durch eine gelungene Abstraktion lassen sich Container sehr leicht in Tests verwalten und zusammen mit den Tests orchestrieren. Container-Typen und Versionen werden gemeinsam mit dem Testcode versioniert, was die Wartung und Refactoring erleichtert. Auch ein häufiges Problem, nämlich auf den erfolgreichen Start eines Containers bzw. des damit bereitgestellten Dienstes zu warten, wird gut gelöst.

Testcontainers gibt es für verschiedene Programmiersprachen bzw. Plattformen. Wir schauen uns im folgenden einmal die Umsetzung für Java speziell im Kontext von JUnit 5 genauer an.

Testcontainers für Java wurde ursprünglich zu JUnit 4 Zeiten entwickelt. Die Verwendung von Test-Rules bietet sich hier an, stellen die Testcontainer ja eine Art Querschnittsfunktionalität dar.

Testcontainers Abhängigkeit in Maven POM
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.14.1</version>
    <scope>test</scope>
</dependency>

Im Zusammenhang mit dem Spring Framework bzw. Spring Boot ist in älteren Spring Versionen (vor 5.2.5) dabei etwas umständlich: Damit parallele Tests voneinander isoliert laufen können, werden die Container-Ports zur Laufzeit dynamisch gemappt. Spring benötigt diese Informationen jedoch, um die Datenquellen oder Endpunkte zu konfigurieren.
Umgesetzt wird das ganze mit einem eigenen ApplicationContextInitializer der die ermittelten Properties dann setzt.

Testcontainers mit JUnit 4 Beispiel (gekürzt)
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.testcontainers.containers.GenericContainer;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = FeedbackApplication.class, webEnvironment = WebEnvironment.RANDOM_PORT)
public class ContainerApiTest
{
    @ClassRule
    public static GenericContainer redis =
        new GenericContainer("redis:3.0.6")
            .withExposedPorts(6379);

    //spring container properties, spring < 5.2.5
    public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext>
    {
        @Override
        public void initialize(ConfigurableApplicationContext configurableApplicationContext)
        {
            TestPropertyValues values = TestPropertyValues.of(
                    "spring.redis.host=" + redis.getContainerIpAddress(),
                    "spring.redis.port=" + redis.getMappedPort(6379)
            );
            values.applyTo(configurableApplicationContext);
        }
    }

    @Test
    public void simpleTest()
    {
      // ...
    }
}

In neueren Spring Versionen gibt es DynamicPropertySource und erlaubt damit ein deutlich eleganteres Setup. Den Einsatz werden wir im nächsten Beispiel sehen.
Aktuell liegt JUnit in Version 5 vor. Natürlich lässt sich Testcontainers auch mit JUnit 5 nutzen. Leider gibt es da einen kleinen Wehrmutstropfen, denn Testcontainers hat eine zwingende Abhängigkeit auf JUnit 4, wobei Testcontainers 2.0 hier Abhilfe schaffen soll. (Die Problematik dahinter ist, dass auf diese Weise auch JUnit 4 Typen in der Code-Completion erscheinen, und schnell eine unbeabsichtigte Verwendung der JUnit 4 API erfolgt.)

Um Testcontainers mit JUnit 5 Jupiter API zu verwenden, ist zunächst die zusätzliche Testcontainers Jupiter API als Abhängigkeit aufzunehmen.

Testcontainers mit Jupiter API und JUnit 5 Jupiter Abhängigkeit
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.6.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.6.2</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.14.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.14.1</version>
    <scope>test</scope>
</dependency>

Das folgende, um einige import-Statements gekürzte Beispiel zeigt, wie eine Spring Boot Test mit Testcontainers und der @DynamicPropertySource aussehen kann.
Dabei wurde für den Redis-Container ein eigener Typ definiert, statt den GenericContainer direkt zu verwenden. Damit werden Compiler-Warnings vermieden, zum anderen kann so direkt auch die Wait-Strategy konfiguriert werden, so dass die Spring Anwendung erst dann startet, wenn der Redis-Container auch wirklich zur Nutzung bereit steht. Flaky-Tests aufgrund von Race-Conditions durch den asynchronen Start von Containern gehören damit der Vergangenheit an.

Dank der neuen @DynamicPropertySource lässt sich die Spring Anwendung sehr komfortabel dynamisch konfigurieren, so dass der Redis-Container tatsächlich verwendet wird. Für den eigentlichen Test wird von Spring MockMVC verwendet, so dass keine echte Webumgebung gestartet werden muss.

Streng genommen handelt es sich bei so einem Test um einen Integrationstest, daher wurde als Suffix für den Namen für die Testklasse *IT gewählt. Diese Test werden dann durch das Maven Failsafe-Plugin in der Integrationtestphase ausgeführt und nicht durch das Surefire-Plugin in der Testphase.

Testcontainers mit JUnit 5 API und @DynamicPropertySource als Spring Boot Test
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@EnableRedisRepositories
class SampleRedisIT
{
    @Container
    public static RedisContainer redis = new RedisContainer();

    @DynamicPropertySource
    static void dataSourceProperties(DynamicPropertyRegistry registry)
    {
        registry.add("spring.redis.port", redis::getFirstMappedPort);
        registry.add("spring.redis.host", redis::getContainerIpAddress);
        registry.add("spring.redis.password", () -> "");
    }

    @Autowired
    private MockMvc mockMvc;

    @Test
    void anonymousRequest() throws Exception
    {
        mockMvc.perform(
           post("/api/data")
              .contentType(APPLICATION_JSON)
              .content(EMPTY_REQUEST_SAMPLE))
           .andExpect(status().isOk());
    }

    private static class RedisContainer extends GenericContainer<RedisContainer>
    {
        private static final String REDIS_IMAGE = "redis";
        private static final String REDIS_VERSION = "5";
        private static final int REDIS_PORT = 6379;

        public RedisContainer()
        {
            this(REDIS_IMAGE + ":" + REDIS_VERSION);
        }

        public RedisContainer(String dockerImageName)
        {
            super(dockerImageName);
            withExposedPorts(REDIS_PORT);
            waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1));
        }
    }
}

Eine explizite Konfiguration des Failsafe-Plugins in der Maven POM ist im folgenden Beispiel zu sehen.

Maven Konfiguration für das Failsafe-Plugin
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <executions>
                <execution>
                    <goals>
                        <goal>integration-test</goal>
                        <goal>verify</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

In der Praxis hat es sich bewährt, beim Einsatz von JUnit 5 die unbeabsichtigte Verwendung von JUnit 4 Abhängigkeiten zu vermeiden. Das wird durch Testcontainers nun deutlich erschwert - bisher konnte einfach JUnit 4 von allen Abhängigkeit explizit exkludiert werden. Als ein Lösungsansatz hat sich hier der Einsatz von einem Maven Multi-Modul-Projekt bewährt.
Damit wird Testcontainers samt JUnit 4 in ein separates Modul ausgelagert, quasi der kleine Giftschrank. Der Trade-Off eines zusätzlichen Moduls fällt dabei um so mehr ins Gewicht, wenn das eigentliche Projekt lediglich aus einem Modul besteht, bzw. bestand.

Als Fazit bleibt festzuhalten, dass Testcontainers ein sehr wertvolles Element im Projektalltag darstellt. Dank Unterstützung von Spring ist die Integration sehr einfach, auch mit JUnit 5 kann ohne Einschränkung gearbeitet werden. Und mit Testcontainers 2 wird dank des Verzichts auf JUnit 4 noch angenehmer.




Zu den Themen Docker und Spring Boot bieten wir sowohl Beratung, Entwicklungsunterstützung als auch passende Schulungen an:

Auch für Ihren individuellen Bedarf können wir Workshops und Schulungen anbieten. Sprechen Sie uns gerne an.

Feedback oder Fragen zu einem Artikel - per Twitter @triondevelop oder E-Mail freuen wir uns auf eine Kontaktaufnahme!

Los geht's!

Bitte teilen Sie uns mit, wie wir Sie am besten erreichen können.