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.
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:
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:
@ServiceConnection
: indicates a connection that the app can use. It replaces the original @DynamicPropertySource
.@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.
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.
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.
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.
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.
Be notified of new posts. Subscribe to the RSS feed.