Painless Java Spring Boot integration testing: Testcontainers

Bono Vidaković
Backend Developer
Development

Adopt modern DevOps practices

Oftentimes writing tests seem like just a nuisance you do after finishing a feature, before heading off to Confluence and JIRA to document your work. Sometimes (rarely, but still) you are right.

In a team of few co-located developers working on a tidy small to a medium-sized project, verbosely organized (multiple testing environments, in-house QA team, etc.) you can easily get away without writing any tests and live to tell about it.

Sure, you are probably aware of the fact that most of the time having meaningful unit tests would’ve made you faster long term, but you have real business value to add and it has to be finished by yesterday.

However, in a newly formed, constantly changing team of a few dozen people working on a massive project, located on 3 continents, spread across 12 time zones with different levels of experience and understanding of the task at hand yelling across the room, asking if someone knows should id be docID + “::” ID or just ID goes through the window.

At that point, unit tests and integration tests become as necessary as working code itself, if not more.

In the pre-Docker era, you would usually add in-memory database such as H2, write mocks of other dependent services and call it a day.

Thankfully today with Docker and Testcontainers things are much easier. To quote authors of the library themselves:

“Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.”

In order to start writing integration tests you just need to have Docker installed and write a few lines of code.

Testcontainers takes care of container lifecycle, pulling images, waiting for the container to be ready and much more. 

Java Spring Boot integration testing

Case Study

As a case study, I’ve chosen to demonstrate the use of Testcontainers for Couchbase integration testing. As in everything Java-related, first, we add a dependency, in this case, we are using Maven so we add:

<!-- Core Testcontainers library -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>1.12.3</version>
            <scope>test</scope>
        </dependency>

        <!-- Since we’ll be using JUnit5 we need to add integration library for it as well -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>1.12.3</version>
            <scope>test</scope>
        </dependency>

        <!-- Testcontainers Couchbase module -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>couchbase</artifactId>
            <version>1.12.3</version>
            <scope>test</scope>
        </dependency>
        

Since I’m using Spring Boot and all accompanying autoconfigured Spring solutions (such as Spring Data) my goal is to integrate Testcontainers with Spring without losing any of Spring’s automagical stuff.

To achieve this I’ve created abstract class AbstractCouchbaseTest that is in charge of instantiating Couchbase container and passing properties to Spring context. Full code of the class is here:

@SpringBootTest(
        classes = {
                App.class,
                TestCouchbaseConfig.class
        },
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers = {AbstractCouchbaseTest.CouchbaseInitializer.class})
@Testcontainers
public abstract class AbstractCouchbaseTest {

    static private final String couchbaseBucketName = "test-bucket";
    static private final String username = "user";
    static private final String password = "password";

    @Container
    final static CouchbaseContainer couchbaseContainer = new CouchbaseContainer()
            .withClusterAdmin(username, password)
            .withIndex(true)
            .withQuery(true)
            .withNewBucket(DefaultBucketSettings.builder()
                    .name(couchbaseBucketName)
                    .password(password)
                    .type(BucketType.COUCHBASE)
                    .build());

    static class CouchbaseInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
        @Override
        public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
            TestPropertyValues.of(
                    "spring.couchbase.bootstrap-hosts=" + couchbaseContainer.getContainerIpAddress(),
                    "spring.couchbase.username=" + username,
                    "spring.couchbase.password=" + password,
                    "spring.couchbase.bucket.name=" + couchbaseBucketName,
         
                    "spring.couchbase.bucket.password=" + password
            ).applyTo(configurableApplicationContext.getEnvironment());
        }
    }
}

Let’s dissect this code

App.class is a bootstrap class of my app under test.
TestCouchbaseConfig.class is where Beans for test couchbase environment are defined, we’ll get back to it later.

Since I’m using Testcontainers to set up the environment for Spring app it makes sense to dynamically initialize Spring context and that’s what CouchbaseInitializer class does. 

We could’ve used classpath resources to load properties from file, but I think this will suffice for the purpose of the demo.

Next, the meat of these tests, @Testcontainers annotation.

This annotation enables Junit Jupiter integration with Testcontainers by scanning all fields in the annotated class for @Container annotation and then calls their container lifecycle methods. 

If the field is static, the container will be reused for all tests in the class, while if the field is an instance field, the container will be recreated for each test method in the class.

That’s pretty much it for most of the use cases. Rather simple, right?

After that, all that’s left is to configure CouchbaseEnvironment Bean to work with our containerized database.

Since Testcontainers automatically find free ports for our database we need to find mapped ports and configure CouchbaseEnvironment to use them. Here’s how:

@TestConfiguration
public class TestCouchbaseConfig {

    @Bean(
            destroyMethod = "shutdown",
            name = {"couchbaseEnv"}
    )
    @Primary
    public CouchbaseEnvironment couchbaseEnvironment() {
        return DefaultCouchbaseEnvironment.builder()
.bootstrapHttpDirectPort(
AbstractCouchbaseTest.couchbaseContainer.getMappedPort(8091))
                	.build();
    }
}

Using this piece of code we’ve configured Spring Data Couchbase integration to use containerized Couchbase instance and we are ready to write integration tests. 

In each integration test, you should extend AbstractCouchbaseTest class and then proceed to use Spring’s repositories and all other automagically configured stuff like you always did.

Conclusion

As software engineers, our main mission is to produce desired software at minimal cost possible

Technologies such as Docker and Testcontainer significantly lower the amount of effort required to implement modern DevOps practices, increasing the quality of our products while lowering the cost of developing them, thereby making them a valuable addition to toolchain of each software engineer.

For a bigger picture here is a great overview of DevOps know-how.

I hope this post will help you with your journey of adopting modern DevOps practices. Feel free to contact us at business@decode.agency or jobs@decode.agency if you want to become part of our team.