Introduction to Test Containers — Part 1

Anji Boddupally
6 min readFeb 1, 2024

Knowledge of Java programming is required to understand some of the contents of this article

Problem Statement:

Let’s envision a scenario where we’ve developed several end-to-end (E2E) API tests for our “Credit Card” service. These tests operate within a shared environment, where our “Amazing” service relies on various dependencies, including a database hosted in our on-premise infrastructure, among other services.

One day, a developer named XYZ introduces changes and deploys the “Amazing” service. Consequently, our API E2E tests encounter failures due to connectivity issues with the database. This situation presents immediate challenges:

  1. Running all E2E tests in a shared environment poses risks, as any downtime in our dependencies affects the entire test suite.
  2. The critical concern is the delayed feedback from tests. Deploying the service in a shared environment merely to obtain a certain level of feedback becomes unnecessary and impractical.

Solution:

To tackle the aforementioned challenges, envision having an isolated environment where all dependencies are readily accessible for a specific duration. In this setup, we can execute our tests in this environment and then effortlessly discard it once the testing is complete. The beauty of this approach lies in its flexibility — this environment could be a developer’s machine or a virtual machine (VM) utilized in our testing pipeline. The primary focus here is on early testing, aligning with a shift-left approach.

To address the database issue mentioned earlier, a practical solution involves creating the database on our local machine, potentially within a Docker container. When initiating the container, we can pre-load test data, eliminating the need to invoke APIs to generate the required data. Once the test data is set up for our specific scenario, we can initiate our application on the local machine and guide it to connect to a local database instead of the actual production database.

However, manually initiating Docker containers through command lines before launching our test suite can be cumbersome. This is where the utilization of Testcontainers comes into play, streamlining the process and enhancing efficiency.

Test Containers is a library, provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. In simple words, it is a library developed on top of Docker, available in various languages. Like some cloud services offer infrastructure as code, test containers offer dependencies as code.

To demonstrate with a real example, I will be using a spring boot micro service application. This application stores the student marks in a database to calculate the Grades.

Below is the E2E test that runs against a shared environment and we will be converting this E2E to an Integration Test with help of a Test Container.

package com.anji.mock.wiremockdemo.student;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;

import com.anji.mock.wiremockdemo.entity.Student;

import io.restassured.response.Response;

public class StudentGradeE2ETest {

private static final String STUDENT_APP_BASE_URI = "https://www.somecompany.com/student/v1/";

private static final StudentTestClient studentTestClient = new StudentTestClient(STUDENT_APP_BASE_URI);

private final Student anji;

@BeforeAll
public static void setUp() {

// usually, we send Student Object instead of a plain json String
String student = "{\n"
+ " \"rollNumber\": 1,\n"
+ " \"name\": \"Anji\"\n"
+ "}";


// create a student
anji = studentTestClient.createStudent(student);

String anjiMarks = String.format("{\n"
+ " \"student\": {\n"
+ " \"studentId\": %s\n"
+ " },\n"
+ " \"maths\": 80,\n"
+ " \"science\": 70,\n"
+ " \"english\": 65\n"
+ "}", anji.getStudentId());

// add some marks
studentTestClient.addMarks(anjiMarks);

}

@Test
public void testGrade() {

Response gradeResponse = studentTestClient.getGrade(anji.getStudentId());
Assertions.assertThat(gradeResponse).isEqualTo(200);
Assertions.assertThat(gradeResponse.asPrettyString()).isEqualTo("{\n"
+ " \"grade\": \"B\"\n"
+ "}");
}

@AfterAll
public static void cleanUp() {
// delete student
}
}

This test runs fine always as long as all the dependencies work well in a shared environment. Now what we will do is, we convert the same test to an Integration Test with help of Test Containers. Integration test is a test to verify the behaviour of our system when it integrates with other systems like a database or a messaging system or an external rest/soap service.

For this particular test, we have a dependency on external database. So I will be using a MySql Test Container to mock the database dependency, start the MySql Container along with some test data before we begin to run our test and also, I will direct the application to use local database instead of production database.

package com.anji.mock.wiremockdemo;

import static java.lang.String.valueOf;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;

import io.restassured.response.Response;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class StudentGradeIntegrationTest {

private static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:latest")
.withInitScript("testdata/student_test_data.sql");

private static final String HOST = "http://localhost:";

@LocalServerPort
private int port;

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {

registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", mysqlContainer::getUsername);
registry.add("spring.datasource.password", mysqlContainer::getPassword);
}

// start container
@BeforeAll
static void beforeAll() {
mysqlContainer.start();
}

// stop container
@AfterAll
static void afterAll() {
mysqlContainer.stop();
}

@Test
void testGrade() {

StudentTestClient studentTestClient = new StudentTestClient(HOST + valueOf(port));
Response gradeResponse = studentTestClient.getGrade(1L);
Assertions.assertThat(gradeResponse.statusCode()).isEqualTo(200);
Assertions.assertThat(gradeResponse.asPrettyString()).isEqualTo("{\n" + " \"grade\": \"B\"\n" + "}");
}
}

Some important details of the above test:

1. @SpringBootTest: This annotation loads all the configuration, creates the spring boot application context to run integration tests. We are creating this spring boot application on random port.
2. @LocalServerPort: Since we are using dynamic port, @LocalServerPort returns the dynamic port value by reading the property ``local.server.port``. We could also use ``@Value(“${local.server.port}”)`` instead of @LocalServerPort
3. @DynamicPropertySource:
Is to add or modify the application properties during application context initialisation. We are using this to direct the application to use the database created by test container.
4. Creating container:

private static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:latest")
.withInitScript("testdata/student_test_data.sql");

5. There are various options available to configure the container, based on our requirement.
For an example, sometimes it may take a while to start the container, it may lead us to timeout errors. To overcome this scenario, we can add waiting time configuration to the container as below

private static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:latest")
.withStartupTimeout(Duration.ofMinutes(1));

6. For more configuration options, please browse test containers java library.

7. In above test, we took the responsibility of starting the container, hence it is our responsibility to clean up the resources. TestContainers provides a Junit5 extension — TestcontainersExtension which starts the container before starting the test and clean up the resources after the execution.

We need to annotate our test class with @Testcontainers and the container with @Container annotation.

package com.anji.mock.wiremockdemo;

import static java.lang.String.valueOf;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import io.restassured.response.Response;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public class StudentGradeIntegrationTest {

@Container
private static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:latest")
.withInitScript("testdata/student_test_data.sql");

private static final String HOST = "http://localhost:";

@Value("${local.server.port}")
private int port;

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {

registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", mysqlContainer::getUsername);
registry.add("spring.datasource.password", mysqlContainer::getPassword);
}

@Test
void testGrade() {

StudentTestClient studentTestClient = new StudentTestClient(HOST + valueOf(port));
Response gradeResponse = studentTestClient.getGrade(1L);
Assertions.assertThat(gradeResponse.statusCode()).isEqualTo(200);
Assertions.assertThat(gradeResponse.asPrettyString()).isEqualTo("{\n" + " \"grade\": \"B\"\n" + "}");
}
}

8. In order to get above dependencies, please below maven dependency to your pom.xml file

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.4</version>
<scope>test</scope>
</dependency>

Summary:

  1. Utilizing test containers, we have successfully substituted an existing end-to-end (E2E) test with an integration test capable of running in any isolated environment equipped with a Docker engine.
  2. Our ability to preload all necessary test data into the database during application startup has led to a significant reduction in the need to make additional calls for creating test data.
  3. With developers making changes, these integration tests are automatically triggered, providing immediate issue reports.

Questions for Further Exploration:

  1. In scenarios requiring more than one dependency, can Test Containers accommodate such cases? Yes, it can.
  2. If a specific container is unavailable for a dependency, is it still possible to use Test Containers? Yes, GenericTestContainer allows for this, as long as there is a corresponding Docker image for the dependency.
  3. In cases involving a Docker Compose file with interconnected containers forming a network, can Test Containers simulate such scenarios? Absolutely, it is entirely possible.

I will explain above scenarios in details in Part 2.

Stay tuned and happy learning

Please access this link for complete source code.

--

--