Quarkus Testing with Test Containers

In my previous post, I covered how to test several Quarkus applications using internal Quarkus tooling. This approach works perfectly fine specially when we only want to cope with a single module. However, if we are working in a multi-modular project, there is an easier approach using Testcontainers and Docker. Let’s see how to do this!

Context

Let’s start with a multi-module project similar to:

├── Parent
│   ├── Quarkus App 1
│   ├── Quarkus App 2
│   └── Integration Tests

The goal is to have running Docker images at the Integration Tests stage, so we can use Testcontainers to start these images.

The Quarkus Container Image JIB extension

In order to configure Quarkus to build a docker image when compiling each Quarkus Apps project, we need to use the Quarkus Container Image JIB extension.

To enable this extension, we need to add it into the pom.xml:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-container-image-jib</artifactId>
</dependency>

And configure the Docker image attributes in the application.properties:

quarkus.container-image.build=true # This property will enable the extension to build the image into Docker
quarkus.container-image.group=my-group
quarkus.container-image.name=quarkus-app-one

Set Up the Integration Tests

We are writing integration tests, so we need to use the failsafe Maven plugin. The first thing we need to do is to ensure we have built our Quarkus Apps modules by configuring the failsafe plugin dependencies in our pom.xml:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <dependencies>
                <!-- ensure modules were built, but not added in the 
                    classpath -->
                <dependency>
                    <groupId>org.sgitario.quarkus.examples</groupId>
                    <artifactId>my-app-one</artifactId>
                    <version>${project.version}</version>
                    <scope>runtime</scope>
                </dependency>
                <dependency>
                    <groupId>org.sgitario.quarkus.examples</groupId>
                    <artifactId>my-app-one</artifactId>
                    <version>${project.version}</version>
                    <scope>runtime</scope>
                </dependency>
            </dependencies>
            <executions>
                <execution>
                    <id>run-tests</id>
                    <phase>integration-test</phase>
                    <goals>
                        <goal>integration-test</goal>
                        <goal>verify</goal>
                    </goals>
                </execution>
            </executions>
            <configuration>
                <systemPropertyVariables>
                    <container.image.my-app-one>my-group/quarkus-app-one:${project.version}</container.image.my-app-one>
                    <container.image.my-app-two>my-group/quarkus-app-two:${project.version}</container.image.my-app-two>
                </systemPropertyVariables>
                <classesDirectory>${project.build.outputDirectory}</classesDirectory>
            </configuration>
        </plugin>
    </plugins>
  </build>
Note that this is not required when we have a multi module project, but better be safe.

Then we need to add the testcontainers dependencies:

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

Next, let’s configure our full deployment by writing a custom JUnit 5 extension:

public class MyFullDeploymentExtension implements BeforeAllCallback, AfterAllCallback {

    public static final String MY_FIRST_APP_ALIAS = "firstApp";

    private Network network;
    private MyCustomServiceContainer myFirstService;
    private MyCustomServiceContainer mySecondService;

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        network = Network.newNetwork();

        myFirstService = new MyCustomServiceContainer("one", 8081);
        myFirstService.withNetwork(network);
        myFirstService.withNetworkAliases(MY_FIRST_APP_ALIAS);
        myFirstService.start();

        mySecondService = new MyCustomServiceContainer("two", 8082);
        mySecondService.withNetwork(network);
        mySecondService.start();
    }

    @Override
    public void afterAll(ExtensionContext context) throws Exception {
        closeSilently(mySecondService);
        closeSilently(myFirstService);
    }

    public int getMyFirstServicePort() {
        return myFirstService.getMappedPort();
    }

    public int getMySecondServicePort() {
        return mySecondService.getMappedPort();
    }

    private static final void closeSilently(AutoCloseable object) {
        // ...
    }
}

We are configuring our services to be running in the same network, so each one can interact with each other. We should do the same if running another third services like databases or message brokers.

Where MyCustomServiceContainer is:

public class MyCustomServiceContainer extends GenericContainer<BaseServiceContainer> {

    private static final Logger LOGGER = LoggerFactory.getLogger(BaseServiceContainer.class);

    private static final String IMAGE = "container.image.";

    private final int port;

    public MyCustomServiceContainer(String name, int port) {
        super(System.getProperty(IMAGE + name, getDefaultImageValue(name)));
        this.port = port;

        withLogConsumer(new Slf4jLogConsumer(LOGGER));
        waitingFor(Wait.forLogMessage(".*Listening on:.*", 1));
        withExposedPorts(port);
    }

    public int getMappedPort() {
        return getMappedPort(port);
    }

    private static final String getDefaultImageValue(String name) {
        // This is to IDE compability only.
        return String.format("my-group/quarkus-app-%s:1.0.0-SNAPSHOT", name);
    }
}

Also, we can overwrite the default properties by using environment variables.

Let’s Write The Test

Let’s write a test to verify that the health endpoint returns OK for all our Quarkus apps:

@Testcontainers
public class HealthIT {

    private static final String HEALTH_PATH = "/health";

    @RegisterExtension
    static final MyFullDeploymentExtension deployment = new MyFullDeploymentExtension();

    @BeforeAll
    public static void beforeAll() {
        RestAssured.defaultParser = Parser.JSON;
    }

    @Test
    public void myFirstServiceHealthEndpointShouldBeOk() {
        givenMyFirstQuarkusAppEndpoint().get(HEALTH_PATH).then().statusCode(HttpStatus.SC_OK);
    }

    @Test
    public void mySecondServiceHealthEndpointShouldBeOk() {
        givenMySecondQuarkusAppEndpoint().get(HEALTH_PATH).then().statusCode(HttpStatus.SC_OK);
    }

    private RequestSpecification givenMyFirstQuarkusAppEndpoint() {
        return given().port(deployment.getMyFirstServicePort());
    }

    private RequestSpecification givenMySecondQuarkusAppEndpoint() {
        return given().contentType(ContentType.JSON).accept(ContentType.JSON).port(deployment.getMySecondServicePort());
    }
} 

Simply like this! :)

[ Quarkus ]