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.

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));
    }
}

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>

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);

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");
}

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 showing a piece of text on Thoughtworks Technology Radar around integration testing: "We've had enough experience with Testcontainers that we think it's a useful default option for creating a reliable environment for running tests. It's a library, ported to multiple languages, that Dockerizes common test dependencies — including various types of databases, queuing technologies, cloud services and UI testing dependencies like web browsers — with the ability to run custom Dockerfiles when needed. It works well with test frameworks like JUnit, is flexible enough to let users manage the container lifecycle and advanced networking and quickly sets up an integrated test environment. Our teams have consistently found this library of programmable, lightweight and disposable containers to make functional tests more reliable."
Image 1. Thoughtworks Technology Radar (source: https://www.thoughtworks.com/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>
@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");
    }
}

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");
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 {
    ...
}

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.

About the author

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.

Related articles

Why every Java Developer should attend J-Fall

  • 25 January 2022
  • 4 min
By Ko Turk

The next innovation in Spring Boot: GraalVM Native Images

  • 9 February 2023
  • 5 min
By Erwin Manders

Moving from Java to Kotlin

  • 14 June 2022
  • 6 min
By Ali Meshkat