Earlier this week, I met with my colleagues about JUnit 5 and Testcontainers. I should have written a blog post about it, but it didn't happen for various reasons. Still, the time has finally come, and it's about Testcontainers.
In this article, I will talk about theory and practice, and we will see how to set up integration tests with JUnit and Testcontainers.
Prerequisite
- Being familiar with Java 8
- Fundamentals of Maven
- Fundamentals of Docker
What are Testcontainers
Testcontainers was started as an open-source project for the Java platform(testcontainers-java) but has gradually received support for more languages such as Go, Rust, dot net (testcontainers-dotnet), node (testcontainers-node) and a few more.
These are projects of varying quality, but many of them can be used without problems in your applications.
If we refer to the official documentation of Testscontainers we can also say that :
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.
What problem does it solve
If we look at different test strategies, usually explained as a test pyramid, at the bottom we have unit tests
A unit test is a way of testing a unit, the smallest piece of code that can be logically isolated in a system.
It focuses on a single component and replaces all dependencies with Mocks or Test Doubles. Now, if we look above the Unit Test layer in the test pyramid, we will find the Integration Test layer (which is what we are here for).
This means that we will have more than one component to test, and sometimes we need to integrate with external resources like databases, message brokers, SMTP services and caching systems.
So, we can say that a backing service is any service that our application consumes over the network, and it’s now Testcontainers that come into action. Before practicing, let’s look at what problems we tend to solve ↓
The Problem
When I was working on projects, we (co-workers and I) tried to solve the problem of interacting with external resources differently, depending on the type of backing service.
So we have several ideas, such as whether we should use a virtual machine, a local process or an embedded in-memory service.
Every time we saw that each strategy has its pros and cons, today, an embedded resource is probably the most widely used method. It solves many problems, but unfortunately, it comes with some new ones.
For example, H2 In-memory database emulates the target resource but not the actual behaviour of a production database.
Why you should look at Testcontainers
One solution which works very well is Testcontainers.
With Testcontainers we can start and stop one or more docker containers with the same config and behaviour as we use in our production environment.
Later in this article, I will show you, how you can configure and use it for an integration test of a Spring Boot application.
Some Benefits
Software delivery and testing more predictable
- This means you can use the same environment (a container) to host your software whether you are building, testing or deploying software in production.
Also, containerization uses fewer resources than virtual machines, making it the go-to solution.What you test is what you get
- This means the containerization provides a consistent application environment for software testing and for deployment. You can be sure that the testing will accurately reflect how the application is in production (with a couple of exceptions) because the test and production environments are the same.Simpler test branches
- Software testers often test multiple versions of an application. They might have to test Windows and Linux versions, for example. The versions need to descend from the same codebase, but are tested and deployed separately.
Drawbacks
Containers are not hardware-agnostic
- There are few disadvantages of using containers to do software testing.
For example, a containerized application can behave in different ways depending on the GPU that his server host contains and whether the application interacts with the GPU.
Then, I used to think, is the role of QA Teams to test containerized apps on all the different hardware profiles that one uses in production.Testing microservices
- Personally I haven’t worked with containerized microservices, but some developers whose have worked with that, said that it's very challenging to test microservices with test containers because you need to configure automated tests to cover multiple microservices.
Tests containers with Springboot
For this article, we will use a simple Spring Boot app, which will expose 2 URLs, GET and POST for retrieving and saving data in our database. And we will send requests to those URLs with integration tests made by Testcontainers.
Setting up the app
As I said above in the prerequisite section, you will ensure that you have all the dependencies that we need to complete this tutorial.
Let’s start with the JPA Entity
@Entity
@Getter
@Setter
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "author")
private String author;
@Column(name = "title")
private String title;
@Column(name= "publication_year")
private int year;
}
Setting up our repository interface
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {}
Our service
@Service
@RequiredArgsConstructor
public class BookService {
public final BookRepository repository;
public Book saveBook(Book book) {
return this.repository.save(book);
}
public List<Book> getBooks() {
return this.repository.findAll();
}
}
Our controller
@RestController
@RequiredArgsConstructor
public class BookController {
private final BookService service;
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("/create/book")
public Book createTodo(@Valid @RequestBody Book book) {
return this.service.saveBook(book);
}
@ResponseStatus(HttpStatus.OK)
@GetMapping("/fetch/books")
public List<Book> createTodo() {
return this.service.getBooks();
}
}
Set up the database container
Once we have designed our basic “book” app, now we will configure our docker compose file, for our database service.
version: '3.8'
services:
postgres_db:
container_name: 'postgresdb'
image: postgres:14.2-alpine
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=book_db
ports:
- '5432:5432'
Next, we will configure our application.yml file, to initialize our database schema in our postgres_db
container.
spring:
datasource:
url: jdbc:postgresql://localhost:5432/book_db
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQL82Dialect
Now that we have everything in place, we can start our application to verify that our configuration is OK.
Let’s start by running our database container service, in the docker-compose file directory run this command
docker-compose up -d
Once the Postgres container started, run the Spring Boot app, with this command
mvn spring-boot run
To test that our application works and saves book entity(ies) in the Postgres container DB, we can run a curl command to perform a POST Request exposed by our controller
curl -d '{"author":"John Doe", "title":"Comic Code", "year":2008}' -H "Content-Type: application/json" -X POST <http://localhost:8080/create/book>
You can then check that the data has been inserted in the database.
Set up the Testcontainers
Once our application started, we can stop it, we don't really need the application running to make integration tests.
So, the convenient way to write integration tests with Testcontainers is to create an Abstract class, which his purpose is to set up one database container for all our test methods, using the singleton pattern.
public abstract class AbstractIntegrationTest {
private static final PostgreSQLContainer POSTGRES_SQL_CONTAINER;
static {
POSTGRES_SQL_CONTAINER = new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.2-alpine"));
POSTGRES_SQL_CONTAINER.start();
}
@DynamicPropertySource
static void overrideTestProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES_SQL_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES_SQL_CONTAINER::getUsername);
registry.add("spring.datasource.password", POSTGRES_SQL_CONTAINER::getPassword);
}
}
The DynamicPropertyRegistry overrides our application-test.properties in the resource folder, with value in the container static methods.
# POSTGRESQL Connection Properties for Testcontainers overrided by DynamicPropertyRegistry
spring.datasource.url=
spring.datasource.username=
spring.datasource.password=
Our integration test class
@Testcontainers
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
public class BookPostgresSQLTest extends AbstractIntegrationTest {
@Autowired
private BookRepository bookRepository;
@Autowired
private MockMvc mockMvc;
@BeforeEach
void setUp() {
bookRepository.deleteAll();
}
@Test
@Order(1)
void should_be_able_to_save_one_book() throws Exception {
// Given
final var book = new Book();
book.setAuthor("Alain de Botton");
book.setTitle("The school of life");
book.setYear(2012);
// When & Then
mockMvc.perform(post("/create/book")
.content(new ObjectMapper().writeValueAsString(book))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.author").value("Alain de Botton"))
.andExpect(jsonPath("$.title").value("The school of life"))
.andExpect(jsonPath("$.year").value("2012"));
}
@Test
@Order(2)
void should_be_able_to_retrieve_all_book() throws Exception {
// Given
bookRepository.saveAll(List.of(new Book(), new Book(), new Book()));
// When
mockMvc.perform(get("/fetch/books")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
// Then
assertThat(bookRepository.findAll()).hasSize(3);
}
}
Foremost, we have to clean our test container before each test method, then we test that we can save a book through our controller rest method, and secondly we test that we can retrieve all books from our Testcontainers database.
Running our Integration Tests
To perform our integration test, we can use the command below
$ mvn -Dtest=BookPostgresSQLTest test
Then, we can see that our two tests passed successfully.
I hope you enjoyed reading this, and I'm curious to hear if this tutorial helped you. Please let me know your thoughts or opinions below in the comments section.
You can have more of my content by following me on LinkedIn • GitHub or Medium
Wrap up
In this post, we have seen :
- The concept of Testcontainers
- How to create a Spring Boot app with a containerized database
- What are the advantages and Drawbacks of tests containers
- How to write integration tests with Testcontainers
The source code of this article can be found on my GitHub đź‘€.