Supercharging Spring Boot Development with TestContainers

A quick guide to integerate testcontainers in your springboot application.

As a developer, I frequently test Spring Boot applications against various dependencies, such as databases, messaging queues, or other services. This process can become complex and cumbersome, especially when dealing with integration tests, as those dependencies have to be externally managed.

Enter TestContainers, a powerful Java library that allows us to spin up disposable containers for testing.

In this blog post, we’ll explore the seamless integration of TestContainers with SpringBoot, create custom container examples, and demonstrate how to leverage docker-compose.yaml for orchestrating multiple containers.

TestContainers during Development phase

With SpringBoot version (3.1 and later), we can quickly integrate TestContainers in our application to provide a complete dev environment. It allows the developers to remove external dependencies like database, cache, messaging systems etc.

First, let’s set up our Spring Boot project and integrate TestContainers. The high-level stack used in the example is:

  • SpringBoot-3.1.2
  • JDK-20
  • Spock for writing test cases
  • MariaDB for persistent storage

Dependency Management: In your Spring Boot project’s pom.xml (for Maven) or build.gradle (for Gradle), add the following dependencies with the test scope:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>

<!-- update this if you are using junit -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>spock</artifactId>
    <scope>test</scope>
</dependency>

<!-- for mariadb dependency -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mariadb</artifactId>
    <scope>test</scope>
</dependency>

To run the application, we depend on MariaDB instance so that the JPA connection properties can point to the same.

spring:
  datasource:
    # the hostname should match with the mariadb container name
    url: jdbc:mariadb://${DB_HOST:mariadb}:3306/${app.db.name}
    username: ${app.username}
    password: ${app.password}
    driver-class-name: org.mariadb.jdbc.Driver

But instead of starting a new instance of MariaDB, we can inject a bean annotated with @ServiceConnection which will provide a similar behavior:

@TestConfiguration(proxyBeanMethods = false)
public class TestContainersDemoApplication {
    @Bean
    @ServiceConnection(name = "mariadb")
    @RestartScope
    MariaDBContainer<?> mariaDbContainer() {
        try (MariaDBContainer<?> mariaDBContainer = new MariaDBContainer<>("mariadb:10.6.13")) {
            mariaDBContainer.withUsername("sbtc")
                    .withPassword("password")
                    .withDatabaseName("sbtc");
            return mariaDBContainer;
        }
    }
    public static void main(String[] args) {
        SpringApplication.from(TestContainersDemo::main)
            .with(TestContainersDemoApplication.class)
            .run(args);
    }

}

When running the application using the test-class mentioned above, in addition to the Spring application context, a MariaDB container is also managed by Spring, and the corresponding standard connection details are injected so that application.yml can refer to them.

Moreover, as the additional dependencies are marked with test scope, those are not bundled in the final artifact.

In the example mentioned above:

  1. @ServiceConnection: indicates a connection that the app can use. It replaces the original @DynamicPropertySource.
  2. @RestartScope: signals the application context not to restart the DB container, even if the application restarts. This annotation is from spring-boot-devtools.

This provides us with a quick disposable development setup that one can use to work on the actual functional requirements. The @ServiceConnection annotation is used to identify the type of container being used, and accordingly, a ConnectionDetails bean is injected - JdbcConnectionDetails in our case.

Test Container and Integration Tests

Let’s write a basic integration test using TestContainers. Consider a Student record that needs to be persisted. In the example mentioned below, we inject a MariaDBContainer annotated with @ServiceConnection, allowing our application to use it as an in-memory storage.

@SpringBootTest
@Testcontainers
class StudentRecordsPersistenceServiceSpec extends Specification{

    @Autowired
    StudentRecordsPersistenceService studentRecordsPersistenceService

    @ServiceConnection
    static MariaDBContainer mariaDBContainer = new MariaDBContainer<>("mariadb:10.6.13")

    def setupSpec(){
        mariaDBContainer.start()
    }

    def "test saving a new record"(){
        given: "application context is created and all beans are available"
        and: "a student record to be persisted"
        def student = new StudentRecordLogical(null, "jvm", "aware",
                      new AddressRecordLogical(null, 101, "jvmlane", "jvmverse"))

        when: "the record is saved"
        def savedStudent = studentRecordsPersistenceService.save(student)

        then: "the record should be correctly persisted and an id is assigned to it"
        null != savedStudent && null != savedStudent.id() && 1L == savedStudent.id()
        and: "address details are correctly stored and an id is assigned to address"
        null != savedStudent.address() && null != savedStudent.address().id() && 1L == savedStudent.address().id()
    }
}

We can verify the logs to match the expected queries when we run the test:

# schema setup
Hibernate: create table ADDRESS (ID bigint not null auto_increment, CITY varchar(255) not null, HOUSE_NO bigint not null, STREET varchar(255) not null, primary key (ID)) engine=InnoDB
Hibernate: create table STUDENT (ID bigint not null auto_increment, FIRST_NAME varchar(255) not null, LAST_NAME varchar(255) not null, ADDRESS_ID bigint, primary key (ID)) engine=InnoDB
Hibernate: alter table if exists STUDENT drop index if exists UK_gigyk3f8556oxi6keer24la5t
Hibernate: alter table if exists STUDENT add constraint UK_gigyk3f8556oxi6keer24la5t unique (ADDRESS_ID)
Hibernate: alter table if exists STUDENT add constraint FK_ADDRESS_ON_STUDENT foreign key (ADDRESS_ID) references ADDRESS (ID)

# inserting records
Hibernate: insert into ADDRESS (CITY,HOUSE_NO,STREET) values (?,?,?)
Hibernate: insert into STUDENT (ADDRESS_ID,FIRST_NAME,LAST_NAME) values (?,?,?)

Using TestContainers, to write integration tests helps us to remove a lock-in from any specific database provider. See this list for the available database containers. Additionally, we can extend the example mentioned above to write end-to-end tests.

Custom Containers

In addition to using pre-defined containers, TestContainers allows us to define custom containers to fit our testing needs. Let’s say you have another SpringBoot application containerized that you want to test as a client.

To define a custom container, we need to create a class that extends GenericContainer. Here we are creating two containers and mapping those to use the same network:

@Testcontainers
class CustomContainerSpec extends Specification{
    static GenericContainer API
    static MariaDBContainer DB
    static Network network
    static String apiHost
    static String apiPort

    ObjectMapper objectMapper = new ObjectMapper()

    def setupSpec() {
        network = Network.newNetwork()
        DB = new MariaDBContainer<>("mariadb:10.6.13")
                .withUsername("sbtc")
                .withPassword("password")
                .withDatabaseName("sbtc")
                .withNetworkAliases("mariadb")
                .withExposedPorts(3306)
                .waitingFor(Wait.forListeningPort())
                .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("mariadb")))
                .withNetwork(network)

        API = new GenericContainer<>(DockerImageName.parse("sbtc:latest"))
                .withExposedPorts(8080)
                .withNetwork(network)
                .withNetworkAliases("api")
                .waitingFor(Wait.forListeningPort())
                .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("api")))
                .dependsOn(DB)

        DB.start()
        API.start()

        apiHost = API.getHost()
        apiPort = API.getMappedPort(8080).toString()
    }
}

Now, let’s use this setup to post and fetch some records as a client:

def "test save student API contract"() {
    given: "context is loaded"

    when: "request is triggered"
    RestTemplate restTemplate = new RestTemplate()
    ResponseEntity<JsonNode> response = restTemplate.exchange("http://"+ apiHost +":" + apiPort + "/student/",
            HttpMethod.POST,
            httpEntity(),
            JsonNode.class)

    then: "correct response is returned"
    response.getStatusCode() == HttpStatus.CREATED
    def jsonNode = response.getBody()
    jsonNode.get("id").asInt() > 0
    jsonNode.get("firstName").asText() == "jvm"
    jsonNode.get("lastName").asText() == "aware"

    jsonNode.get("address").get("house").asLong() == 101L
    jsonNode.get("address").get("street").asText() == "jvmlane"
    jsonNode.get("address").get("city").asText() == "jvmverse"
}

This way, based on the apiHost, apiPort, and the endpoint details, we can test any setup without connecting to actual hosted services.

Ports Mapping

You must have noticed that we are explicitly fetching port details apiPort = API.getMappedPort(8080).toString() from the API container in the last section instead of relying on exposed port 8080. It is because this exposed port number is from the container’s perspective.

From the host’s perspective, Testcontainers exposes this on a random free port - a design choice to avoid port collisions that may arise with locally running software or between parallel test runs.

Multi-Container Setup

Sometimes, our Spring Boot application may require multiple containers to run simultaneously. One way to achieve the same is by setting up everything as we did in the previous example. Another option is to use orchestration tools like docker-compose.

A docker-compose.yml config for the setup mentioned above can be written as follows. Please note this is not a production-ready setup, and you should explore options for pushing config data like credentials to container runtime:

services:
  sbtc:
    image: sbtc
    platform: linux/amd64
    ports:
      - "8080:8080"
    networks:
      - sbtc
    depends_on:
      mariadb:
        condition: service_healthy
  mariadb:
    image: mariadb:10.6.13
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - "3306:3306"
    environment:
      - MARIADB_ROOT_PASSWORD=password
      - MARIADB_DATABASE=sbtc
      - MARIADB_USER=sbtc
      - MARIADB_PASSWORD=password
    networks:
      - sbtc
    healthcheck:
      test: ["CMD", 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', '-p${MARIADB_ROOT_PASSWORD}' ]
      interval: 10s
      timeout: 10s
      retries: 5
      start_period: 20s

networks:
  sbtc:

Now we can write an integration test that refers to this setup and provides an environment with the two containers mentioned in docker-compose.yml:

@Testcontainers
class CustomContainerDockerComposeSpec extends Specification{

    @Shared
    DockerComposeContainer environment = new DockerComposeContainer(new File("docker-compose.yml"))
            .withLocalCompose(true)
            .withExposedService("mariadb", 1,3306, Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(5)))
            .withLogConsumer("mariadb", new Slf4jLogConsumer(LoggerFactory.getLogger("mariadb")))
            .withExposedService("sbtc",1, 8080, Wait.forListeningPort())
            .withLogConsumer("sbtc", new Slf4jLogConsumer(LoggerFactory.getLogger("sbtc")))

}

Let’s write a quick test to validate the generated primary key for a new record after saving it:

def "test save student API contract"() {
    given: "context is loaded"
    def hostPort = "http://${environment.getServiceHost("sbtc", 8080)}:${environment.getServicePort("sbtc", 8080)}"
    
    when: "request is triggered"
    RestTemplate restTemplate = new RestTemplate()
    ResponseEntity<JsonNode> response = restTemplate.exchange(hostPort + "/student/",
            HttpMethod.POST,
            httpEntity(),
            JsonNode.class)

    then: "correct response is returned"
    response.getStatusCode() == HttpStatus.CREATED
    def jsonNode = response.getBody()
    jsonNode.get("id").asInt() == 1
    // other checks 
}

As the data is now present in the MariaDB container, we can validate the same by invoking a GET request against the endpoint:

def "test fetching student record"() {
    given: "context is loaded"
    def hostPort = "http://${environment.getServiceHost("sbtc", 8080)}:${environment.getServicePort("sbtc", 8080)}"

    when: "request is triggered"
    RestTemplate restTemplate = new RestTemplate()
    ResponseEntity<JsonNode> response = restTemplate.getForEntity(hostPort + "/student/1", JsonNode)

    then: "correct response is returned"
    response.getStatusCode() == HttpStatus.OK
    def jsonNode = response.getBody()
    jsonNode.get("id").asInt() == 1
    // other checks
}

A working example of this setup is available in GitHub

That is all for this post. If you want to share any feedback, please drop me an email, or contact me on any social platforms. I’ll try to respond at the earliest. Also, please consider subscribing feed for regular updates.

References

  1. Test Containers
  2. Test Containers - networking
  3. MariaDB
  4. SpringBoot and Test Containers
  5. Test Containers - databases

Be notified of new posts. Subscribe to the RSS feed.