How to customize a docker image with Testcontainers
Testcontainers is a great testing tool which enables you to rely on Docker containers to execute your tests. You can use it to get a service (Redis, Postgresql, ....) or directly run your application from a container. Lastly, it is well integrated to JUnit 5 so you get all the testing goodness of that environment :).
However there is a small pitall - not inherent to the library but more a testing need: it does not support out of the box container caching.
Let me take my example to detail that use case. I need to test a Maven plugin in a container because it runs a native command - GraalVM native-image build to be very explicit. Therefore I need to ensure I'm running on a supported architecture and not on the host one to avoid a complex test setup. I don't want to use a custom image but only "official" ones so I picked maven:3.6.2-jdk-8-slim one as base. However, this image is not sufficient for GraalVM since it misses gcc and libc-dev for example. The fix is trivial, run apt update && apt install -y gcc libc6-dev zlib1g-dev. However, this command is quick slow and is executed each time the maven container is created.
The obvious fix is to create a local Dockerfile with this RUN command, build it and replace the official maven image by my custom one. But then, I can't share the project anymore with my project mates.
So, is the only option to push the image on a public repository and not respect the will to not rely on unofficial images which can be corrupted easily?
Not realy. Testcontainers hides an awesome utility in its toolbox: ImageFromDockerfile.
This class enables to build an image from an in memory docker file (or not) and optionally to clean the image once the JVM exists if desired. This means we can create an image on the fly if it does not exists and let the user select if he wants to cache it or delete it once the tests are done. In other words: for people not desiring to keep the cache it will behave almost as before and for others it will avoid the initialization cost of the image :).
Now the concept is clear, let see how to do it. There are two main steps:
- Check the image exists
- Build the image if missing
Check the image was already created
To do that, we can reuse the docker client testcontainers comes with. The only small trick here is to ensure to tag the image properly and not just use latest which will not evolve properly in time if you start to get branches.
Personally I decided to inherit from the base image tag, for instance I would use company/project/maven-test:3.6.2-jdk-8-slim because I use as base image maven:3.6.2-jdk-8-slim. Now if the base image changes in a branch I still have the right cache an the build stays as reproducible as possible - for a docker based one - and will not depend on the developer cache state.
Then the check of the image existance is built-in in the docker client image and is as simple as:
// 1
final String fromImage = System.getProperty("project.container.baseimage", "maven:3.6.2-jdk-8-slim");
final String tag = fromImage.split(":")[1];
final String targetImage = "apache/geronimo/arthur/maven-test-base:" + tag;
final DockerClient client = DockerClientFactory.instance().client(); // 2
try {
if (!client.listImagesCmd().withImageNameFilter(targetImage).exec().isEmpty()) { // 3
log.info("Found '{}' image, reusing it", targetImage);
return targetImage;
}
// 4: todo
} catch (final InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IllegalStateException(ie);
} catch (final ExecutionException e) {
throw new IllegalStateException(e);
}
- We create the tag name based on the reference image,
- We create a default docker client
- We use the docker client to check if the image is already built
- There we will create the image if it does not already exist.
Create the test image and cache it
To create an image you have multiple options, the raw idea here, since we have docker client, is to create a client buildImageCmd. However, testcontainers provides a nice wrapper to that command allowing to use an in memory docker file with a great builder DSL.
Therefore, the todo of previous snippet (point 4) can easily be implemented using ImageFromDockerfile class:
return new ImageFromDockerfile( // 1
targetImage,
Boolean.getBoolean("company.project.container.deleteOnExit")) // 2
.withDockerfileFromBuilder(builder ->
builder.from(fromImage) // 3
.run("apt update && apt install -y gcc libc6-dev zlib1g-dev") // 4
.label("company.project.environment", "integration-tests") // 5
.get(); // 6
- We create an ImageFromDockerfile instance specifying our target image,
- We ensure user will be able to configure if the image is cached or not (don't forget to wire the system property in surefire or your test executor),
- In the Dockerfile builder we specify our base image (official maven one here),
- We add to the builder the customizations we want (here the gcc installation),
- Don't forget to mark the image for a simpler management (here a label marking the environment),
- And finally we request the image to be built, the returned value is the imageId so fits perfectly our previous logic which returns the image to use.
Use it with testcontainers
Integrating with testcontainers is doable through multiple flavors but the simplest will be to create a custom container. Here is a class doing it:
public class MavenContainer extends GenericContainer<MavenContainer> {
public MavenContainer() {
super(findImage()); // 1
setWorkingDirectory("/opt/company/project/integration-test"); // 2
setCommand("sleep", "infinity"); // 3
withFileSystemBind(System.getProperty("company.project.m2.repository"), "/root/.m2/repository"); // 4
setNetworkMode(System.getProperty("company.project.container.maven.network", "host")); // 5
}
private static String findImage() { // 6
return Optional.of(System.getProperty("company.project.container.maven.image", "auto"))
.filter(it -> !"auto".equals(it))
.orElseGet(MavenContainer::getOrCreateAutoBaseImage);
}
private static String getOrCreateAutoBaseImage() { // 7
// ...
}
}
- We create a container from a base image,
- We ensure the working directory is known, which enables tests to copy files in the directory in a deterministic way,
- We set a default command which does nothing - will enable the container to be used on demand, ie tests will call container.execInContainer(command),
- We mount our local repository in the image container to ensure maven reuses our cache and avoids to download plugins and dependencies at each run,
- We set the network mode to host to ensure tests can start mock servers usable from the container (like a SSH or FTP server for example),
- We let the base image be configurable through a system property which only triggers our custom image creation by default, this enables to test custom images very easily by just switching a system property,
- the getOrCreateAutoBaseImage will host the logic we saw in the two previous part.
Now we have our container, we just need to write our test:
@Testcontainers // 1
class MavenTest {
@Container // 2
private static final MavenContainer MVN = new MavenContainer();
@Test
void doTest() {
MVN.copyFileToContainer( // 3
MountableFile.forHostPath(Paths.get("..../pom.xml"),
Paths.get(MVN.getWorkingDirectory())resolve("pom.xml"), target);
// 4
final ExecResult build = mvn.execInContainer("mvn", "-e", "package");
assertEquals(0, build.getExitCode());
// 5
MVN.execInContainer("rm", "-rf", "pom.xml", "target");
}
}
- We mark our test as being a testcontainers test,
- We define our container and let - through the annotation - testcontainers managing its lifecycle,
- We copy some pom into the container for this particular test,
- We execute the tested maven command and assert its output (exit code here but stdout/stderr are available in ExecResult too),
- We clean up the container (which enables us to reuse the instance without restarting it for this test.
And here we are, we have a custom container, prepared for our test but not recomputed each time we run the test and we can test native behavior or the portability of our execution.
From the same author:
In the same category: