Testcontainers is an open source framework for provisioning throwaway, on-demand containers for development and testing use cases. Testcontainers make it easy to work with databases, message brokers, web browsers, or just about anything that can run in a Docker container.
In addition to testing, you can also use Testcontainers libraries for local development as well. Testcontainers libraries combined with Testcontainers Desktop provide a pleasant local development and testing experience. Testcontainers libraries are available for most of the popular languages like Java, Go, .NET, Node.js, Python, Ruby, Rust, Clojure, and Haskell.
In this article, let us explore some Do’s and Don’ts while using Testcontainers libraries. We are going to show code snippets in Java, but the concepts are applicable for other languages as well.
Don’t rely on fixed ports for tests
If you are just getting started with Testcontainers or converting your existing test setup to use Testcontainers, you might think of using fixed ports for the containers, just as you are used to using Docker or Docker Compose directly.
For example, you might have a current testing setup where a PostgreSQL test database is installed and running on port 5432, and your tests talk to that database. When you try to leverage Testcontainers for running PostgreSQL database instead of using a manually installed database, you might think of starting the PostgreSQL containers and exposing it on the fixed port 5432 on the host.
But using fixed ports for containers while running tests is not a good idea for the following reasons:
- You, or your team members, might have another process running on the same port and if that is the case the tests will fail.
- While running tests on a Continuous Integration (CI) environment, there can be multiple pipelines running in parallel. The pipelines might try to start multiple containers of the same type on the same fixed port which will cause port collisions.
- You want to parallelize your test suite locally as well and hence you are ending up with multiple container instances of the same container running simultaneously.
So, it is advised to use Testcontainers built-in dynamic port mapping capabilities to avoid the above-mentioned issues altogether.
// Example 1: GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:5.0.3-alpine")) .withExposedPorts(6379); int mappedPort = redis.getMappedPort(6379); // if there is only one port exposed then you can use redis.getFirstMappedPort() // Example 2: PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")); int mappedPort = postgres.getMappedPort(5432); String jdbcUrl = postgres.getJdbcUrl();
While using a fixed port for tests is highly discouraged, you might prefer to use a fixed port for local development so that you can connect to those services on a fixed port, for example with other tools such as database inspectors. By using Testcontainers Desktop you can easily connect to those services on a fixed port.
Don’t hardcode the hostname
While using Testcontainers for your tests, you should always dynamically configure the host and port values. For example, here is how a typical Spring Boot test using Redis container looks like:
@SpringBootTest(webEnvironment = RANDOM_PORT) @Testcontainers class MyControllerTest { @Container static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:5.0.3-alpine")) .withExposedPorts(6379); @DynamicPropertySource static void overrideProperties(DynamicPropertyRegistry registry) { registry.add("spring.redis.host", () -> "localhost"); registry.add("spring.redis.port", () -> redis.getMappedPort(6379)); } @Test void someTest() { .... } }
As a keen observer, you might have noticed that we have hardcoded the redis host as localhost. If you run the test, it will work fine, and it will run fine on your CI also as long as you are using a local Docker daemon that is configured in such a way that the mapped ports of the containers are accessible through localhost.
But if you configure your environment to use a Remote Docker daemon then your tests will fail because those containers are not running on localhost anymore. So, the best practice to make your tests fully portable is to use redis.getHost() instead of a hardcoded localhost as follows:
@DynamicPropertySource static void overrideProperties(DynamicPropertyRegistry registry) { registry.add("spring.redis.host", () -> redis.getHost()); registry.add("spring.redis.port", () -> redis.getMappedPort(6379)); }
Don’t hardcode the container name
You might think of giving a name to the containers using withCreateContainerCmdModifier(..)
as follows:
PostgreSQLContainer<?> postgres= new PostgreSQLContainer<>("postgres:16-alpine") .withCreateContainerCmdModifier(cmd -> cmd.withName("postgres"));
But giving a fixed/hard coded name to containers will cause problems when trying to run multiple containers with the same name. This will most likely cause problems in CI environments while running multiple pipelines in parallel.
As a rule of thumb, if a certain generic Docker feature (such as container names) is not available in the Testcontainers API, this tends to be an opinionated decision that fosters using integration testing best practices. The withCreateContainerCmdModifier()
is available as an advanced feature for experienced users that have very specific use cases, but should not be used to work around the Testcontainers design decisions.
Copy files into containers instead of mounting them
While configuring the containers for your tests, you might want to copy some local files into a specific location inside the container. A typical example would be copying database initialization SQL scripts into some location inside the database container.
You can configure this by mounting a local file into container as follows:
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")) .withFileSystemBind( "src/test/resources/schema.sql", "/docker-entrypoint-initdb.d/01-schema.sql", BindMode.READ_ONLY);
This might work locally, but if you are using a Remote Docker daemon or Testcontainers Cloud then those files won’t be found in the remote docker host and tests will fail.
Instead of mounting local files, you should use File copying as follows:
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")) .withCopyFileToContainer( MountableFile.forClasspathResource("schema.sql"), "/docker-entrypoint-initdb.d/01-schema.sql");
This approach works fine even while using Remote Docker daemon or Testcontainers Cloud, allowing tests to be portable.
Use the same container versions as in production
While specifying the container tag, don’t use latest, as it can introduce flakiness in your tests when a new version of the image is released. Instead, use the same version that you use in production to ensure you can trust the outcome of your tests.
For example, if you are using PostgreSQL 15.2 version in the production environment then use postgres:15.2 Docker image for testing and local development as well.
// DON'T DO THIS PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest")); // INSTEAD, DO THIS PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.2"));
Use proper container lifecycle strategy
Typically the same container(s) will be used for all the tests in a class as follows:
@SpringBootTest(webEnvironment = RANDOM_PORT) @Testcontainers class MyControllerTest { @Container static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:5.0.3-alpine")) .withExposedPorts(6379); @DynamicPropertySource static void overrideProperties(DynamicPropertyRegistry registry) { registry.add("spring.redis.host", () -> "localhost"); registry.add("spring.redis.port", () -> redis.getMappedPort(6379)); } @Test void firstTest() { .... } @Test void secondTest() { .... } }
When you run MyControllerTest
only one Redis container will be started and used for executing both the tests because we make the Redis container a static field. If it is not a static field, then two Redis instances will be used for running the two tests, which might not be what you want and might even fail if you aren’t recreating the Spring Context. While using separate containers for each test is possible, it will be resource intensive and may slow down the test execution.
Also, sometimes developers who are not familiar with Testcontainers lifecycle, use JUnit 5 Extension annotations @Testcontainers
and @Container
and also manually start/stop the container by calling container.start()
and container.stop()
methods. Please read Testcontainers container lifecycle management using JUnit 5 guide to thoroughly understand Testcontainers lifecycle methods.
Another common approach to speed up the test execution is using Singleton Containers Pattern.
Leverage your framework’s integration for Testcontainers
Some frameworks such as Spring Boot, Quarkus and Micronaut provide out-of-the-box integration for Testcontainers. While building the applications using any of these frameworks, it is recommended to use frameworks Testcontainers integration support.
- Spring Boot support for Testcontainers
- Quarkus DevServices using Testcontainers
- Micronaut TestResources using Testcontainers
User preconfigured technology-specific modules when possible
Testcontainers provide technology specific modules for most of the popular technologies such as SQL databases, NoSQL datastores, message brokers, search engines, etc. These modules provide technology specific API that makes it easy to retrieve the container’s information such as getting JDBC URL from a SQL database container, bootstrapServers URL from Kafka container, etc. and most importantly, take care of all necessary bootstrapping work, for running an application easily in a container and interacting with it from your Java code.
For example, using GenericContainer
to create a PostgreSQL container looks as follows:
GenericContainer<?> postgres = new GenericContainer<>("postgres:16-alpine") .withExposedPorts(5432) .withEnv("POSTGRES_USER", "test") .withEnv("POSTGRES_PASSWORD", "test") .withEnv("POSTGRES_DB", "test") .waitingFor( new LogMessageWaitStrategy() .withRegEx(".*database system is ready to accept connections.*\\s") .withTimes(2).withStartupTimeout(Duration.of(60L, ChronoUnit.SECONDS))); postgres.start(); String jdbcUrl = String.format( "jdbc:postgresql://%s:%d/test", postgres.getHost(), postgres.getFirstMappedPort());
By using Testcontainers PostgreSQL module, you can create an instance of PostgreSQL container simply as follows:
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); String jdbcUrl = postgres.getJdbcUrl();
The PostgreSQL module implementation already applies sensible defaults and also provides convenient methods to get container information.
So, instead of using GenericContainer
, first, check if there is a module already available in the Modules Catalog for your desired technology.
On the other hand, if you are missing an important module from the catalog, chances are good that by using GenericContainer
directly (or by writing your own custom class extending GenericContainer) you can get the technology working in a straightforward manner.
Use WaitStrategies to check the container is ready
If you are using GenericContainer
or creating your own module, then use appropriate WaitStrategy to check whether the container is fully initialised and is ready to use instead of using sleep for some (milli)seconds.
//DON'T DO THIS GenericContainer<?> container = new GenericContainer<>("image:tag") .withExposedPorts(9090); container.start(); Thread.sleep(2 * 1000); //waiting for container to be ready container.getHost(); container.getFirstMappedPort(); //DO THIS GenericContainer<?> container = new GenericContainer<>("image:tag") .withExposedPorts(9090) .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)); container.start(); container.getHost(); container.getFirstMappedPort();
Check the Testcontainers language specific documentation to see what are the available WaitStrategies out of the box and you can also implement your own if need be.
Please note that if you don’t configure any WaitStrategy, Testcontainers will setup a default WaitStrategy that will check for connectivity of all exposed ports from the host.
Summary
We have explored some of the do’s and don’ts when using Testcontainers libraries and provided better alternatives. Head over to https://testcontainers.com to find more resources to learn how to use Testcontainers effectively.
The post Testcontainers Best Practices appeared first on AtomicJar.