Integration Testing with Testcontainers

Integration testing is normally a complex process, because these tests normally depend on the presence of external dependencies (e.g., database, message broker, webdriver). In this blog, I’m going to present the Testcontainers for Java library and show you some examples of how it facilitates the execution of integration tests, by running them against real production-like external dependencies.

Rodrigo Lopes
Rodrigo Lopes
DevOps Engineer
5 minutes
March 14, 2023

Mocks and in-memory services are frequently used to replace external dependencies and reduce the complexity, but this gives the wrong impression since the tests don’t run against real dependencies. Complexity is also rarely reduced, and is simply replaced by the configuration of the mocked services and making sure those are in sync with the real third-party integration.

In-memory services may not provide the same features as the real production dependency. Consider a common scenario where an application relies on database vendor-specific features that are not available in the H2 database used in the tests. How are those features going to be verified by the integration tests?

Testcontainers for Java is a library that supports Junit tests and allows testing against real dependencies as disposable Docker containers. The library provides many specialized containers, including: databases, messaging systems, caching services, web servers and so on. But anything that can run in a Docker container can be used. Testcontainers libraries are available for other languages like .NET, Go, Rust, Node.js and Python; and integrate with other testing libraries.

Using Testcontainers for Integration Testing

Consider that we have a component in our application that uses a Redis cache service, and we want to write a test for this component:

public class RedisBackedCacheTest {

    private static RedisBackedCache underTest;

    @BeforeAll
    public static void init() {

        Jedis jedis = new Jedis("localhost",6379);
        underTest = new RedisBackedCache(jedis, "cacheName");
    }

    @Test
    public void testSimplePutAndGet() {

        underTest.put("test", "example");
        Optional<String> retrieved = underTest.get("test");
        assertTrue(retrieved.isPresent());
        retrieved.ifPresent(value -> assertEquals("example", value));
    }
}
JAVA

Note that it is assumed that a Redis instance is running locally on port 6379. That is a risk for the reliability of the test. What if the instance is not running? How to should you handle that in a CICD server? Let’s improve our integration tests by using Testcontainers.

First, we need a compatible Docker environment available. Make sure you are able to run Docker containers in your test environment. Then, we add the dependencies to our project. For Maven it is:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>
JAVA

Now we start a container in our tests, using a Redis Docker image:

static GenericContainer<?> redis

= new GenericContainer<>(DockerImageName.parse("redis:7.0.8-alpine")).withExposedPorts(6379);
JAVA

And now we make sure our tests use the Redis instance that is running in the Docker container. We use Junit’s lifecycle hooks to start the container.

@BeforeAll
public static void init() {
    redis.start();
    Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379));
    underTest = new RedisBackedCache(jedis, "cache");
}
JAVA

And run the tests!

We will see logs showing us that Testcontainers:

  • was activated before our test method ran
  • discovered and quickly tested our local Docker setup
  • pulled the image if necessary
  • started a new container and waited for it to be ready
  • shut down and deleted the container after the test
Image 1. Thoughtworks Technology Radar
Image 1. Thoughtworks Technology Radar

Junit5 Integration

Testcontainers provides an API that is based on the Junit5/Jupiter extension model. This extension is provided by means of the @Testcontainers annotation. Annotating your test class with this annotation will make it find all fields annotated @Container and call their container lifecycle methods. That way you don’t need to worry about starting or stopping your containers.

Containers declared as static fields will be shared between test methods. They will be started only once before any test method is executed, and stopped after the last test method has executed. Containers declared as instance fields will be started and stopped for every test method. In order to use this extension, the following dependency is required:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>
JAVA
@Testcontainers
public class RedisBackedCacheTest_1 {

    private static RedisBackedCache underTest;

    @Container  
    static GenericContainer<?> redis
        = new GenericContainer<>(DockerImageName.parse("redis:7.0.8-alpine"))
              .withExposedPorts(6379);

    @BeforeAll
    public static void init() {

        Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379));
        underTest = new RedisBackedCache(jedis, "cache");
    }
}
JAVA

Modules

In the previous example, we used the GenericContainer class to create a Redis test container. Testcontainers also provides a growing ecosystem of modules. These are libraries with pre-defined abstractions with an API to help you to create and start a container without the need of really knowing the details of how to do that.

@Container
public static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres");
JAVA
Image 2. Growing ecosystem of modules.
Image 2. Growing ecosystem of modules.

JDBC Support

Testcontainers provides a special jdbc URL format that will automatically spin up a database container. The only requirements are a suitable JDBC driver and the Testcontainers library in the runtime classpath. The specially formatted URL should include tc: after jdbc: in the original. For example:
Original URL: jdbc:mysql://localhost:3306/databasename
Modified URL: jdbc:tc:mysql:5.7.34:///databasename

Note that hostname and port number are not required, since they will be defined by the container.

@SpringBootTest(
    classes = DemoApplication.class,
    webEnvironment = WebEnvironment.RANDOM_PORT,
    properties = { "spring.datasource.url=jdbc:tc:postgresql:15.2-alpine:///databasename" }
)
@ActiveProfiles("test")
public class DemoControllerTest {
    ...
}
JAVA

Testcontainers give more confidence for Integration Testing

We saw the importance of using real dependencies in integration tests, and how Testcontainers facilitate this by running these external services as lightweight Docker containers. At this point you know how Testcontainers integrates with JUnit to manage the lifecycle of the container, so you don't have to worry with starting or stopping the containers at the necessary moments. There's an extensive offer of services that can be executed as containers in the tests, but you learned that anything that can run as a Docker container can be used with the Testcontainers library. Finally, I showed you some examples of how to use (a/the) library, including the special support for JDBC. We can conclude that Testcontainers is a great alternative to using mocks or in-memory services; it therefore gives us more confidence in our integration testing.

Discover more articles

About the author

Rodrigo Lopes
Rodrigo Lopes
DevOps Engineer
Rodrigo is a DevOps engineer in the Wholesale & Rural Tech domain for the last 15 years. Rodrigo has been working with Java since version 1.0, with 20+ experience in the Java ecosystem. He has also gained more experience in the Azure world over the past few years.