Running Integration Tests with Fabric8 docker-maven-plugin

One of the biggest challenges in moving from unit tests to integration tests is provisioning external dependencies.

Databases. Event brokers. LDAP. SMTP. Object storage. Other services that our application has to integrate with.

Unit tests usually avoid those dependencies. Integration tests should not. At some point, the test has to talk to something real enough to catch the problems that mocks and in-memory substitutes hide.

Classic solutions

Over the years, we have developed a whole collection of workarounds for external dependencies in tests.

One of the most common techniques is to use in-memory replacements: H2 instead of PostgreSQL, an embedded broker instead of Kafka, a fake SMTP server instead of a real one, and so on.

That approach has obvious benefits:

  • tests start quickly;
  • resource usage is low;
  • the test framework can usually manage the lifecycle;
  • the setup is simple enough for local development.

But there is a cost.

In-memory dependencies are often not representative of production. They may be real systems, but they are not the same systems your application will actually use.

H2 is the classic example. It is useful for simple persistence tests, but it quickly stops being useful once the application depends on database-specific behaviour: PostgreSQL-specific SQL, PL/SQL, T-SQL, extensions, migration scripts, locking behaviour, transaction isolation quirks, or query planner differences.

At that point, the test may still be green, but the signal is weak.

The uncomfortable version is this:

If production runs on PostgreSQL, testing against H2 is not really an integration test of your PostgreSQL integration.

It is better than nothing. But it is not the same thing.

Docker to the rescue

One practical way to make integration tests more realistic is to run the external dependencies as Docker containers.

For Java projects, the most common modern option is probably Testcontainers. It lets tests start disposable Docker containers directly from JUnit or the test framework.

But there is another useful approach when you want the environment to be managed by Maven itself: Fabric8 docker-maven-plugin.

The plugin integrates with the Maven lifecycle and can start dependency containers before integration tests run, then stop them after the tests finish.

It is not only for running containers, either. The same plugin can also build Docker images and push them to a registry. That is useful when your application itself is packaged as a Docker image, tested as an image, and then pushed only if the integration tests pass.

The basic idea is simple:

  • Maven starts the required containers.
  • Failsafe runs the integration tests.
  • Maven stops the containers.
  • The build fails only after cleanup has happened.

That last point matters.

Configuration

The official documentation covers the basic configuration well:

https://github.com/fabric8io/docker-maven-plugin/blob/master/doc/intro.md

This article focuses on the options that are useful once the simple case is already working.

Shared configuration

A typical shared configuration looks like this:

<plugin>
    <groupId>io.fabric8</groupId>
    <artifactId>docker-maven-plugin</artifactId>
    <version>${docker.maven.plugin.version}</version>

    <configuration>
        <autoCreateCustomNetworks>true</autoCreateCustomNetworks>
        <containerNamePattern>%a-%i</containerNamePattern>
        <startParallel>true</startParallel>
        <verbose>true</verbose>

        <images>
            <image>
                ...
            </image>
        </images>
    </configuration>
</plugin>

There are a few things worth setting up from the beginning.

Container naming

The containerNamePattern property lets you define a custom container naming convention.

In the example above, we use:

<containerNamePattern>%a-%i</containerNamePattern>

Here, %a is the image alias and %i is an auto-incremented index.

This matters when more than one build can run on the same Docker host. The default name can cause collisions between parallel CI jobs, especially when a previous run failed and left containers behind.

With %a-%i, you get names like:

postgres-1
kafka-1
ldap-1

If an old container still exists, the index can move forward instead of immediately colliding.

Automatic networks

With:

<autoCreateCustomNetworks>true</autoCreateCustomNetworks>

the plugin creates a dedicated Docker network for the configured image set.

That gives you two useful properties:

  • containers can reach each other by alias;
  • they are isolated from unrelated containers running on the same Docker host.

It also makes cleanup easier. When the build stops the containers, the temporary network can go away with them.

Parallel startup

With:

<startParallel>true</startParallel>

the plugin starts containers concurrently instead of one after another.

That is a real time saver when your integration test environment needs several dependencies, for example:

  • PostgreSQL;
  • Kafka;
  • LDAP;
  • Redis;
  • a local mock of an external HTTP service.

Combined with proper wait conditions, parallel startup is usually safe and reduces build time.

Verbose mode

With:

<verbose>true</verbose>

the plugin gives you much better diagnostics.

That is the difference between:

The build hung for 30 seconds and failed.

and:

The plugin tried to connect to PostgreSQL on port 5432 and got connection refused four times.

Verbose logs are worth keeping in CI. You can always trim log retention later. Debugging a silent container startup failure is a bad use of human life.

Per-image configuration

The real work happens inside each <image> block.

For a dependency container, the minimum useful configuration usually contains:

  • an alias;
  • the Docker image name;
  • port mappings;
  • environment variables;
  • a wait condition.

A PostgreSQL dependency for integration tests can look like this:

<image>
    <alias>postgres</alias>
    <name>postgres:13-alpine</name>

    <run>
        <ports>
            <port>+postgres.host:postgres.port:5432</port>
        </ports>

        <env>
            <POSTGRES_USER>app</POSTGRES_USER>
            <POSTGRES_PASSWORD>app</POSTGRES_PASSWORD>
            <POSTGRES_DB>app</POSTGRES_DB>
        </env>

        <wait>
            <log>database system is ready to accept connections</log>
            <time>30000</time>
        </wait>
    </run>
</image>

There are two important details here.

Dynamic ports

This port mapping:

<port>+postgres.host:postgres.port:5432</port>

tells Docker to pick a free host port instead of binding PostgreSQL directly to 5432.

That avoids collisions with:

  • a local PostgreSQL instance;
  • another CI job;
  • a stale container from a previous failed run.

The plugin then exposes the selected host and port as Maven properties:

postgres.host
postgres.port

Your integration tests can use those properties to build the JDBC URL.

For example:

jdbc.url=jdbc:postgresql://${postgres.host}:${postgres.port}/app

The exact wiring depends on your test framework. In a Spring Boot application, you might pass those values into the test profile. In a plain Maven project, you might expose them as system properties for Failsafe.

Wait conditions

The <wait> block prevents Maven from continuing before the dependency is actually ready.

For PostgreSQL, a log-based wait condition is usually enough:

<wait>
    <log>database system is ready to accept connections</log>
    <time>30000</time>
</wait>

The <time> value is the timeout. It should not be your readiness strategy.

Avoid fixed sleeps. A fixed sleep is both too long on fast machines and too short on slow CI agents. That is a rare achievement: inefficient and flaky at the same time.

Wait for something meaningful instead:

  • a log line;
  • an HTTP endpoint;
  • a TCP port;
  • a Docker healthcheck, if the image provides one.

For services with complex startup, such as Kafka, a simple "port is open" check may not be enough. The port can accept connections before the service is actually usable. In those cases, prefer a healthcheck or a log line that indicates the service has finished initialization.

Wiring to the Maven lifecycle

The main benefit of using this plugin is that nobody has to remember to start the dependencies manually.

Bind docker:start to pre-integration-test:

<execution>
    <id>start-images</id>
    <phase>pre-integration-test</phase>
    <goals>
        <goal>start</goal>
    </goals>
</execution>

Bind docker:stop to post-integration-test:

<execution>
    <id>stop-images</id>
    <phase>post-integration-test</phase>
    <goals>
        <goal>stop</goal>
    </goals>
</execution>

Together:

<executions>
    <execution>
        <id>start-images</id>
        <phase>pre-integration-test</phase>
        <goals>
            <goal>start</goal>
        </goals>
    </execution>

    <execution>
        <id>stop-images</id>
        <phase>post-integration-test</phase>
        <goals>
            <goal>stop</goal>
        </goals>
    </execution>
</executions>

Then run integration tests with:

mvn verify

Do not run:

mvn integration-test

That is a common trap.

When you run mvn verify, Maven executes the full lifecycle up to verify:

  1. pre-integration-test starts the containers.
  2. integration-test runs the integration tests.
  3. post-integration-test stops the containers.
  4. verify fails the build if the integration tests failed.

That sequence is exactly why the Maven Failsafe Plugin exists.

Failsafe separates running the tests from failing the build. The build failure happens in the verify phase, after post-integration-test had a chance to clean up.

If you run only integration-test, you can easily end up with failed tests and containers still running.

Building and pushing application images

The plugin can also build and push your application image.

A useful pattern is:

  • build the application image during package;
  • start dependency containers during pre-integration-test;
  • run integration tests against the packaged application;
  • stop containers during post-integration-test;
  • push the image during deploy, but only after tests pass.

Conceptually:

<execution>
    <id>build-image</id>
    <phase>package</phase>
    <goals>
        <goal>build</goal>
    </goals>
</execution>

<execution>
    <id>push-image</id>
    <phase>deploy</phase>
    <goals>
        <goal>push</goal>
    </goals>
</execution>

The same <images> configuration can contain both types of images:

  • images with a <build> section are built by the plugin;
  • images with a <run> section are started as dependencies.

That makes the Maven build describe both the application artifact and the integration test environment.

Gotchas worth knowing

A few things are worth learning before they waste your afternoon.

Port collisions in CI

Dynamic ports remove most collisions, but not all of them.

You can still lose against:

  • stale containers;
  • previous builds that did not clean up;
  • multiple jobs sharing the same Docker daemon;
  • agents under heavy parallel load.

A cleanup step helps:

mvn docker:stop || true
docker container prune -f

Use pruning carefully on shared agents. If other jobs use the same Docker daemon, a global prune can become someone else's bad day.

Image cache growth

Long-running CI agents accumulate Docker images.

Sooner or later, the disk fills up. It will happen at the worst possible moment, because disks are petty.

Schedule periodic cleanup:

docker image prune -af --filter "until=168h"

Tune the retention window for your environment. Seven days is a reasonable starting point, not a law of nature.

Stop versus kill

The plugin's stop goal asks containers to stop cleanly.

Some images take their time. Older Kafka images, for example, were not exactly famous for elegant shutdown behaviour.

If shutdown time becomes a problem, configure a bounded grace period for the container. The exact option depends on the plugin version, so verify it against the version used in your build.

The intent is simple:

<run>
    ...
    <kill>5000</kill>
</run>

Do not cargo-cult that snippet without checking the plugin version. This is build tooling. It enjoys punishing optimism.

Network isolation

With custom networks enabled, containers can reach each other by alias.

But they do not automatically see random services running on the host or on Docker's default bridge network.

That is usually good.

If your test container needs to reach a sidecar that you started manually, make the sidecar part of the same plugin-managed image set. Otherwise, you are mixing managed and unmanaged test infrastructure, which is where reproducibility goes to die.

Logs and debugging

When a wait condition times out, container logs are your first clue.

For painful startup issues, stream the logs during the build:

<showLogs>true</showLogs>

Again, verify the exact location of this option for your plugin version. In many configurations, it belongs inside the image's <run> section.

Use this in CI when diagnosing failures. Once the build is stable, you may want to turn it off or limit log retention.

When not to use this

docker-maven-plugin is a good fit when you want the integration test environment described at the Maven build level.

It is less attractive when:

  • tests need dynamic container setup per test class;
  • developers already use JUnit-based Testcontainers;
  • you need rich programmatic control over containers;
  • your CI platform already provides service containers cleanly;
  • the team is moving away from Maven-managed environments.

In those cases, Testcontainers may be the cleaner option.

The trade-off is simple:

  • docker-maven-plugin makes the environment part of the build lifecycle.
  • Testcontainers makes the environment part of the test code.

Both are valid. Pick the one that matches where your team expects this responsibility to live.

Wrapping up

That is most of what we use day to day.

Fabric8 docker-maven-plugin is useful because it sits in the Maven lifecycle and does a boring job reliably: start real dependencies before integration tests, stop them afterward, and keep the build reproducible.

A single shared configuration block plus one <image> block per dependency is usually enough to get integration tests talking to real PostgreSQL, real Kafka, real LDAP, or whatever else the application actually uses.

That does not make the setup perfect. Docker-based integration tests are slower than pure unit tests, and CI agents need proper cleanup.

But they catch a class of bugs that in-memory substitutes will miss.

And that is the whole point. The goal is not to make tests look realistic. The goal is to make them fail before production does.