Testcontainers logo — testcontainers.org

Integration testing with Docker and Testcontainers

Philips
Philips Technology Blog
8 min readOct 21, 2021

--

A test-friendly way of instrumenting Docker containers

By Joana Deluca Kleis

We, as good developers, create unit tests for every piece of code we write, so we can be confident to iterate on the code. Unit tests are fast, reliable and relatively easy to write and maintain. Integration testing on the other hand, is not a solved issue. I know this from personal experience.

My team and I had been building a dashboard and some backend services for it. But as we started to shape our testing pyramid, we realized that most of our code is integration code: we didn’t have our own database (at least, not yet) and we got our data from a third-party service. We developed tons of tests that emulated the responses of that service, but we also needed to ensure our services would get the right data when talking to the real service, especially because we tell it how to query the database.

Nowadays, there are many more ways to build applications than there used to be in the past. Applications are now made of micro services that speak with each other and there are a lot of integration points to be tested. Shaping our testing pyramid has become quite challenging because we don’t want our build to take forever to run. We want fast and reliable feedback and we also don’t want a complex setup. But at the same time, we need to ensure our services will work when integrating with other services.

Docker

This is where Docker steps in. Docker is a free software for launching applications in software containers. Containers are a standardized unit of software that allows developers to isolate their app from its environment, solving the “it works on my machine” headache.

You can run anything in a Docker container, which makes them very ideal for integration testing. Using Docker containers, you can create throwaway instances of anything you need, including your external dependencies.

Throughout this post, I will use Redis as the “external dependency” for all the examples. Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker.

Docker CLI

Assuming you have Docker installed, to run the Redis container via Docker cli you can simply open a terminal and run:

docker run -p 6379:6379 redis

This will expose and publish Redis on port 6379 on your machine and, because you know what port Redis will be running on, you can reach it from your tests.

So something like this would be possible:

$ docker run -p 6379:6379 redis
$ run-integration-tests

That’s easy right? Why would you need any more than that?

Here are some things you need to be aware of:

  • Orchestration — how do you know when Redis will be ready to receive traffic after you start it? You’d need some kind of health check and waiting strategy.
  • Parallelism — Since port 6379 will be allocated, you cannot run multiple tests in parallel. You could choose not to expose that specific port and the Docker daemon would automatically bind it to a random free port on your machine, but then you wouldn’t know what port to connect to from your tests to interact with Redis.
  • Test lifecycle — normally you would want to recreate the container in between tests to have an isolated and clean environment for each test scenario.
  • Cleanup — You need to stop and remove the container after your tests run.

Docker API

Docker has an API and several clients. You can connect to the API with the language of your choice and use the corresponding client to do everything you want with Docker (just like you do via cli, but from code).

This makes it easy to automate the Docker setup and deployment and integrate it with test code to create an easy setup of the dev environment, uniform build and test environments (self-contained and portable). A bonus is that it requires no installation of external software, except Docker of course.

That’s very convenient. But an even better news is that someone already had the idea of interacting with Docker containers from test code and created Testcontainers.

Testcontainers

Testcontainers is a library for instrumenting Docker containers to use them as part of your tests. It uses the Docker API to provide lightweight, throwaway instances of common databases, selenium web drivers, or anything that you can run in a Docker container. It’s test-friendly, it’s open-source and it’s available in multiple languages.

Here is an example of how easy it is to get a container up and running and ready to run your tests using testcontainers-java:

GenericContainer redis = new GenericContainer("redis:5.0.8-alpine3.11").withExposedPorts(6379);redis.start();// run your testsredis.stop();

This is just a simple example of creating an instance of a container that you can interact with, but there are already some interesting things going on here:

Testcontainers will automatically pull the Redis image if necessary.

The start command is a blocking command, which means that it will wait until the application inside the container is ready. By default, it will wait for the container’s mapped network port to start listening. Of course, readiness can mean different things in different applications, that’s why there are other specific wait strategies that can be used with Testcontainers, but the default behavior should be already enough for most applications.

The stop command will shut down and delete the container after the test.

Port binding and API

We usually don’t want to publish to a specific port on the host, to avoid port collisions with locally running software or in between parallel test runs, so we let the Docker decide on which port to publish.

Since we don’t know the port Docker will pick, Testcontainers has additional APIs to get the actual mapped port after the container starts, so we can inject it into our tests and use it.

This can be done using the getMappedPort method, which takes the original (container) port as an argument, so for the Redis generic container example, you would do the following:

Integer mappedPort = container.getMappedPort(6379);

When running with a local Docker daemon, exposed ports will usually be reachable on localhost. However, if you ever need to obtain the container address, you can do:

String ipAddress = container.getHost();

Normally you’d want the host and the port together when constructing addresses:

String address = container.getHost() + ":" + container.getMappedPort(6379);

Throwaway instances

You can grab a new instance of the Redis container before each test scenario. For instance, if you use Junit, you can add the following code to your setUp method to have Testcontainers recreate the Redis container before each test:

private RedisBackedCache underTest;    @Container
public GenericContainer redis = new GenericContainer("redis:5.0.8-alpine3.11") .withExposedPorts(6379);
@BeforeEach
public void setUp() {
String address = redis.getHost();
Integer port = redis.getFirstMappedPort();
underTest = new RedisBackedCache(address, port);
}

The benefit of throwaway instances is that your tests become more reliable as they will always start in a known state, without any contamination between test runs.

Cleanup

One of the benefits of Testcontainers is the automatic cleanup of the test environment. Testcontainers automatically spawns a Ryuk container, which is responsible for container removal and automatic cleanup of dead containers. This means that it will stop and remove all containers after your test completes.

Other Testcontainers features

  • Specialized containers (databases, selenium, kakfa): There are some specialized containers available for use with Testcontainers, like the Selenium web driver, that you can start a chrome web driver just by doing:
public BrowserWebDriverContainer chrome = new BrowserWebDriverContainer()
.withCapabilities(new ChromeOptions());
chrome.start();
  • Dockerfile: In situations where there is no pre-existing Docker image, Testcontainers can create a new temporary image on-the-fly from a Dockerfile.
  • Docker-compose: Similar to generic containers support, it’s also possible to run a set of services specified in a docker-compose.yml file. This is intended to be useful on projects where Docker Compose is already used in dev or other environments to define services that an application may be dependent upon.
  • And much more…

Some last recommendations

I mentioned in the beginning of this post that my team and I were looking for a fast and reliable test suite and you can definitely achieve that with Testcontainers. But the truth is that the speed of your tests will depend on the startup time of the container you are using, because you need to wait for it to be ready before you can start running your tests.

Redis starts up pretty fast (less than 2ms), so it’s fine to have a new instance of it for each test scenario. But that is not always the case. Sometimes you’ll have to choose between speed and isolation. If the container you are using takes too long to start, you’ll probably want to consider reusing one instance of the container across multiple tests.

Choose isolation to avoid contamination between tests when you can. Only reuse the same container instance across tests if you don’t depend on any state or if you can easily manage the state. Otherwise, your tests will become unreliable.

One thing that is worth mentioning about reliability, is that you also shouldn’t depend on latest tags. You should always pin a specific image version so your tests will be more predictable.

Environment Setup

Testcontainers is a great choice for having an easy setup for your integration tests, because you don’t need to install anything else other than Docker and you can run the tests from your IDE just like you do with unit tests. It will start and stop your dependencies in Docker containers and cleanup everything afterwards.

Extra — The Docker Socket

A good choice for CI is to run your tests in a Docker container, so they become even more portable.

Let’s say that you are testing a java application and you manage your dependencies with gradle, you can create a Docker image based on gradle and run your tests inside this container, so you won’t have to install Java or Gradle in your CI agents, the only thing you need is Docker.

Testcontainers will spawn some containers during your tests, so you need your container to have access to the Docker daemon running on your machine. That can be achieved with mounting the Docker socket:

docker run -v /var/run/docker.sock:/var/run/docker.sock test-runner

Now this container will have access to the Docker socket, and will therefore be able to start containers.

Testcontainers inside containers open up a whole new range of opportunities, but that might be best left for a separate post. The point is, Testcontainers — and containers in general — are an important component in the toolbox of testing modern software, which is becoming increasingly dependent on finding the right configurations and connections between independent services.

Curious about working in tech at Philips? Find out more here

--

--

Philips
Philips Technology Blog

All about Philips’ innovation, health technology and our people. Learn more about our tech and engineering teams.