Test containers in action with Spring Boot
Let’s play with docker while writing tests
Test containers is a very popular library which is used to simulate docker based testing setup. It provides the ability to us to connect the applications to a test database without using the real database while we run functional/integration tests with actual database operations without mocking. It supports almost all DB modules available such as MySQL, MongoDB, PostgreSQL and etc. Not only databases, it has support for Kafka, Local Stack, RabbitMQ, Nginx and many more…
If you need to learn more about Java integrations with Test containers, please visit here: https://java.testcontainers.org/
I’m going to perform a MongoDB testing simulation for a REST API I have already created.
I will break this article into several parts for the ease of understanding. Let’s get ready to learn.😎
First thing would be arranging the application to support test containers setup. I will follow a approach which is bit different than you have seen. It’s not the traditional way you connect a Mongo database to a Spring Boot app. Mainly, I’m going to override the Repository implementation. Let’s see…
Imagine we have a REST API to manipulate Student records. I will brief details here.
Controller
==================
path = /api/v1/students
@GetMapping(path = "/students")
public ResponseEntity<List<StudentDAO>> getStudents() {
return new ResponseEntity<>(studentService.getAllStudents(), HttpStatus.OK);
}
Service
==================
public List<StudentDAO> getAllStudents() {
List<StudentDAO> students = studentRepository.findAll();
return students;
}
Model
==================
@Data
@Builder
@Jacksonized
@AllArgsConstructor
@NoArgsConstructor
@Document(collection = "students")
public class StudentDAO {
@Id
@Field(write = Field.Write.ALWAYS, name = "_id")
String id;
String name;
Integer age;
}
Repository layer needs to be explained in detail. That’s the change I have done deviating to traditional setup. So, let’s deep dive into it.
Note: These are the dependencies I’m using in this project.
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mongodb'
Mongo Integration
Here we need to setup our mongo configuration to support test container setup. I have already done it and explained here.
Please read it from here: https://salithachathuranga94.medium.com/implement-custom-mongo-client-integration-in-spring-boot-44e4f5ef685f
🔴 Reading this is a MUST!!!
⌛️ ⌛️ ⌛️ ⌛️ ⌛️……..
You should have a custom mongo client setup now!
Let’s move to setup Test Environment in a reusable way.
Setup Test Containers
So, we have a mongo config setup supporting tests right? Next step is preparing test environment through the code.
We are going to use “@SpringBootTest” annotation to initiate the application context. Since we have enabled test mode(following this article: https://salithachathuranga94.medium.com/implement-custom-mongo-client-integration-in-spring-boot-44e4f5ef685) inside test/resources/application.properties, it will connect to test DB. Still we have do some setup there. I’m going to extend the implementation for that under this section.
You will see later, how DB connection is created while running test.
1️⃣ Create util file to read properties file
test/utils/PropertyFileLoader.java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Objects;
import java.util.Properties;
public class PropertyFileLoader {
private static final Logger LOGGER = LoggerFactory.getLogger(PropertyFileLoader.class);
private static Properties properties;
private static void load() {
properties = new Properties();
try {
String propertiesPath = Objects.requireNonNull(Thread.currentThread()
.getContextClassLoader().getResource("application.properties")).getPath();
properties.load(new FileInputStream(propertiesPath));
} catch (IOException e) {
LOGGER.error("Could not load test properties", e);
throw new RuntimeException("Property file loading failed in tests", e);
}
}
public static String getProperty(String key) {
if (properties == null) {
PropertyFileLoader.load();
}
return properties.getProperty(key);
}
public static Properties getProperties() {
return properties;
}
}
This file will be used later to dynamically load some values to the application context.
2️⃣ Create Mongo test environment
We need to integrate Test containers…So, this is time for it. Let’s look at the code and understand.
test/utils/MongoTestEnvironment.java
@Testcontainers
@DataMongoTest(excludeAutoConfiguration = MongoAutoConfiguration.class)
public class MongoTestEnvironment {
public static MongoDBContainer mongoDBContainer = new MongoDBContainer(
DockerImageName.parse(PropertyFileLoader
.getProperty("mongo.db.docker.image"))).withReuse(true);
}
You may recall that I have used some env values in application properties for tests, while implementing that custom mongo client! The important config is: mongo.db.docker.image. This contains docker image with tag which needs to be used with test containers. Now we are using it! We have loaded it from our custom property file reader. Simply, I have created a static Docker container based on mongo. It was made as static to access it easily from the other classes.
3️⃣ Create common test environment
I need a common base test environment, in while I can perform actions which are related to test setting up.
Since we already have env for Mongo — Test containers integration, we can inherit the the functionality to this base class.
test/utils/TestEnvironment.java
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
public class TestEnvironment extends MongoTestEnvironment {
private static String mongoConnectionUrl;
public TestEnvironment() {
setupMongoContainer();
}
private void setupMongoContainer() {
mongoDBContainer.start();
mongoConnectionUrl = mongoDBContainer.getReplicaSetUrl();
}
public static class SpringPropertyInitializer implements
ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
TestPropertyValues.of("mongo.db.test.url=" + mongoConnectionUrl)
.applyTo(applicationContext.getEnvironment());
}
}
}
Since TestEnvironment is a sub class of MongoTestEnvironment, we have mongo container access. Within the constructor, Mongo DB test container is started.
SpringPropertyInitializer is the class which is going to be used in next section as a Configuration class to application context. Let’s see it there.
mongo.db.test.url => This was the dynamic URI config we set before.
In main/resources/application.properties file, we hard coded a dummy value since we only use this config while tests are running. I told that it will be overridden.
Now it’s time to set the correct expected value in test/resources/application.properties. The value will be the dynamic Mongo DB container URL. Here, the class SpringPropertyInitializer does it for us.
4️⃣ Create Test config
Let’s create a common reusable config for our functional tests. This is the place we have to use “SpringBootTest” annotation. Here, we plug our config class: SpringPropertyInitializer as a config for app context.
test/utils/FunctionalTest.java
@TestPropertySource(properties = {"spring.profiles.active=functional-test"})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers = TestEnvironment.SpringPropertyInitializer.class)
public abstract class FunctionalTest {
protected static final TestEnvironment TEST_ENVIRONMENT = new TestEnvironment();
}
Since we are creating a new TestEnvironment object, it will call its constructor. There we have containers starting logic. You remember right? I have already explained it in the previous step.
This class is reusable. Any Test class where we implement our functional test cases, can extend this class. And it will always use a common shared static Test environment since we have made TEST_ENVIRONMENT variable as static. We can use it in all sub classes of FunctionalTest class.
All done with setting up! ✅
Write Functional Tests
Let’ write some functional tests with our test setup. As I mentioned before, we can inherit the functional test behavior from FunctionalTest class we created above.
I’m going to write Mongo Repository layer functional test. I need to make sure that SAVE method is working fine with this test.
Let’s write it!
import com.rest.api.dao.StudentDAO;
import com.rest.api.repositories.StudentRepository;
import com.rest.api.utils.FunctionalTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Optional;
public class MongoFunctionalTest extends FunctionalTest {
private static final Logger LOGGER = LoggerFactory.getLogger(MongoFunctionalTest.class);
@Autowired
private StudentRepository studentRepository;
@Test
public void shouldSaveStudent() {
StudentDAO student = StudentDAO.builder().id("001").name("S001").age(18).build();
studentRepository.save(student);
try {
LOGGER.info("Waiting until DB operations are done");
Thread.sleep(3000);
} catch (Exception ex) {
LOGGER.error("Error occurred while waiting for DB: ", ex);
}
Optional<StudentDAO> studentSaved = studentRepository.findById("001");
Assertions.assertTrue(studentSaved.isPresent());
StudentDAO studentObject = studentSaved.get();
LOGGER.info("Student saved in Tests : {}", studentObject);
Assertions.assertNotNull(studentObject);
Assertions.assertEquals("S001", studentObject.getName());
}
@AfterEach
public void cleanup() {
studentRepository.deleteAll();
}
}
First, I saved one student to database. Then I retrieved the same student using his ID. Then I should be able to return the same student I inserted at the start of the test. Let’s run the test!
Observe the logs while tests are running….
See the below log. You can clearly see the log we have placed inside our custom Mongo Client — “Connecting to Mongo DB….”. It is now connecting to test database since test mode is enabled through configs. This URL is dynamic. Next time when you run the test, it will be having another random port.
Test passed. ✅
It has printed the saved Student object also. All assertions are passed! ✔️
Let’s add one more simple test. I need to make sure that FIND ALL method is working fine with this test.
@Test
public void shouldFetchStudent() {
StudentDAO student1 = StudentDAO.builder().id("001").name("S001").age(18).build();
StudentDAO student2 = StudentDAO.builder().id("002").name("S002").age(28).build();
studentRepository.save(student1);
studentRepository.save(student2);
try {
LOGGER.info("Waiting until DB operations are done");
Thread.sleep(3000);
} catch (Exception ex) {
LOGGER.error("Error occurred while waiting for DB: ", ex);
}
List<StudentDAO> students = studentRepository.findAll();
Assertions.assertNotNull(students);
LOGGER.info("Student saved in Tests : {}", students);
Assertions.assertEquals(2, students.size());
}
It should be passed. Try to run the test. I can see the below log which means we have successfully inserted two students and fetched all of them correctly.
Students saved in Tests : [StudentDAO(id=001, name=S001, age=18), StudentDAO(id=002, name=S002, age=28)]
Test passed. ✅
This test setup is very efficient and reusable. 😎
Let’s say you want to integrate Kafka containers into the tests. You can just update TestEnvironment class with relevant dynamic config overrides and container images. You may need to update application.properties also. It depends on the scenarios. Test containers is a prominent way to simulate test environments. It makes our lives easier.
Just try this and let me know! Let’s discuss more 😎
Bye all ❤️