In this article, we'll explore how to perform integration testing in a Spring Boot application using Testcontainers.
Testcontainers is a Java library that allows you to spin up lightweight, disposable containers for databases, message queues, or other services. These containers are then used in tests to provide a real, isolated environment that closely mimics production.
1. Intro
Testing is a critical part of software development, and as applications become more complex, ensuring that different components interact correctly becomes even more crucial. This is where integration tests come in. Unlike unit tests, which focus on testing individual components in isolation, integration tests verify how different parts of an application work together, including interactions with external systems like databases, message brokers, or third-party services.
By the end of this article, you’ll have a solid understanding of how to set up Testcontainers in a Spring Boot project and run integration tests in a reliable and reproducible manner.
2. Why Use Testcontainers?
When it comes to integration testing, one of the biggest challenges is ensuring that tests run in an environment as close to production as possible. This typically means testing against actual databases or services, rather than mocks or in-memory alternatives. However, maintaining consistency between development, testing, and production environments can be difficult, and traditional approaches like using shared staging databases can lead to flaky tests or environment conflicts.
Testcontainers to the Rescue
Testcontainers solves many of these issues by allowing you to run real services inside containers during your tests. Each test starts with a fresh containerized service, ensuring consistency and isolation between test runs. Here are some key benefits of using Testcontainers:
- Reproducibility: Each test gets a clean slate with its own container, ensuring that the state of one test does not interfere with another.
- Production-like environment: You can test against real databases, message queues, and other services (e.g., PostgreSQL, Kafka, Redis), which more accurately reflects the production setup than using in-memory databases like H2.
- Portability: Since the services run in Docker containers, tests will behave the same on any machine, whether it's a developer's laptop or a CI/CD pipeline.
- Easy setup: Testcontainers is designed to be easy to use with minimal configuration, making it a great choice even for teams with little experience using Docker.
Common Problems Solved by Testcontainers
Let’s compare some common approaches to integration testing and see how Testcontainers provides a better solution:
- Using in-memory databases (like H2): While convenient, in-memory databases often have differences in behavior and SQL support compared to production databases (e.g., PostgreSQL, MySQL). This can lead to tests passing in development but failing in production.
- Shared testing environments: Using shared staging databases for integration tests can cause data conflicts, inconsistent results, and non-repeatable tests. Testcontainers isolates each test with its own database instance.
- Manual setup of local services: Without Testcontainers, developers often need to manually install and configure local versions of databases or message brokers. Testcontainers automates this process by pulling the necessary Docker images and starting the services automatically.
By using Testcontainers in your Spring Boot application, you ensure your integration tests are reliable, isolated, and aligned with your production environment. In the next section, we’ll walk through how to set up Testcontainers with a practical example.
3. Configuring Spring Boot with Testcontainers
In Spring Initializr we're going to create a project using Maven, Java 21, Spring Boot 3.3.4 and Jar package.
Also add these dependencies:
- Spring Web
- Spring Data JPA
- PostgreSQL Driver
- Testcontainers
And add to your pom.xml the RestAssured dependency, before the junit-jupiter.
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
4. Code
Let's write some code.
4.1 Build the Model
package com.testcontainers.examples.models;
import java.util.UUID;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "tb_users")
public class UserModel {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String name;
public UserModel() {
}
public UserModel(String name) {
this.name = name;
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
4.2 Build the Repository
package com.testcontainers.examples.repositories;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.testcontainers.examples.models.UserModel;
@Repository
public interface UserRepository extends JpaRepository<UserModel, UUID> {
}
4.3 Build the service
package com.testcontainers.examples.services;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.testcontainers.examples.dto.CreateUserDto;
import com.testcontainers.examples.models.UserModel;
import com.testcontainers.examples.repositories.UserRepository;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public UserModel save(CreateUserDto user) {
var newUser = new UserModel(user.name());
return userRepository.save(newUser);
}
public List<UserModel> allUsers() {
return userRepository.findAll();
}
public UserModel findById(UUID id) {
return userRepository.findById(id).orElseThrow();
}
@Transactional
public void deleteById(UUID id) {
userRepository.deleteById(id);
}
}
4.4 Build the Controllers
package com.testcontainers.examples.controllers;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.testcontainers.examples.dto.CreateUserDto;
import com.testcontainers.examples.models.UserModel;
import com.testcontainers.examples.services.UserService;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/")
public ResponseEntity<List<UserModel>> getAllUsers() {
return ResponseEntity.ok().body(userService.allUsers());
}
@GetMapping("/{id}")
public ResponseEntity<UserModel> getUserById(@PathVariable String id) {
return ResponseEntity.ok().body(userService.findById(UUID.fromString(id)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUserById(@PathVariable String id) {
userService.deleteById(UUID.fromString(id));
return ResponseEntity.noContent().build();
}
@PostMapping("/")
public ResponseEntity<UserModel> createUser(@RequestBody CreateUserDto userInfo) {
var newUser = userService.save(userInfo);
return ResponseEntity.status(HttpStatus.CREATED).body(newUser);
}
}
Now, we have a simple CRUD operations with a Spring Boot application.
5. Writing our docker-compose file
Creating a container with a postgreSQL database.
services:
postgres:
image: postgres
container_name: example-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
volumes:
- example-postgres-data:/var/lib/postgresql/data
volumes:
example-postgres-data:
6. Update application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
Now, you can test your app manually, using Postman, Insomnia, Bruno.
7. Writing Integration tests
Inside the test directory create a file UserControllerTest.java
.
To use rest-assured import the dependencies like this:
import static io.restassured.RestAssured.*;
import static io.restassured.matcher.RestAssuredMatchers.*;
import static org.hamcrest.Matchers.*;
The test file will look like this:
package com.testcontainers.examples.controllers;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import com.testcontainers.examples.dto.CreateUserDto;
import io.restassured.http.ContentType;
import static io.restassured.RestAssured.*;
import static io.restassured.matcher.RestAssuredMatchers.*;
import static org.hamcrest.Matchers.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public class UserControllerTest {
@Container
@ServiceConnection
public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres");
@Value("${local.server.port}")
private int port;
private String createUserAndGetId(String userName) {
var newUser = new CreateUserDto(userName);
return given()
.contentType(ContentType.JSON)
.body(newUser)
.port(port)
.when()
.post("/users/")
.then()
.statusCode(201)
.extract()
.path("id");
}
@Test
void shouldCreateUser() {
var newUser = new CreateUserDto("Test user");
given()
.contentType(ContentType.JSON)
.body(newUser)
.port(port)
.when()
.post("/users/")
.then()
.statusCode(201)
.body("name", equalTo("Test user"));
}
@Test
void shouldDeleteUserById() {
String userId = createUserAndGetId("Test user");
given()
.port(port)
.when()
.delete("/users/{id}", userId)
.then()
.statusCode(204)
.body(emptyOrNullString());
}
@Test
void shouldGetAllUsers() {
createUserAndGetId("Test user");
given()
.port(port)
.when()
.get("/users/")
.then()
.statusCode(200)
.body("size()", greaterThan(0));
}
@Test
void shouldGetUserById() {
String userId = createUserAndGetId("Test user");
given()
.port(port)
.when()
.get("/users/{id}", userId)
.then()
.statusCode(200)
.body("id", equalTo(userId))
.body("name", equalTo("Test user"));
}
@Test
void shouldReturn500WhenSearchForNonExistentUser() {
String nonExistentUserId = "9999";
given()
.port(port)
.when()
.get("/users/{id}", nonExistentUserId)
.then()
.statusCode(500);
}
@Test
void shouldReturn500WhenDeletingNonExistentUser() {
String nonExistentUserId = "9999";
given()
.port(port)
.when()
.delete("/users/{id}", nonExistentUserId)
.then()
.statusCode(500);
}
}
Code explanation
Here's a detailed breakdown of the code:
Annotations
-
<u>@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)</u>
- This annotation is part of Spring Boot’s testing support and is used to load the application context for integration testing.
-
webEnvironment = RANDOM_PORT
instructs Spring Boot to start the application with an available random port, rather than the default port (8080). This is useful to avoid port conflicts in testing environments.
-
<u>@Testcontainers</u>
- This annotation is from the Testcontainers library and is used to manage container lifecycles automatically during the test lifecycle.
- It ensures that containers are started before any tests run and stopped when tests complete.
-
<u>@Container</u>
- A Testcontainers-specific annotation that designates a field as a container, making sure the container starts before running tests and stops afterward.
- In this case, it's used to manage a PostgreSQL container that emulates a database environment for testing.
-
<u>@ServiceConnection</u>
- This annotation is part of Spring Boot’s integration with Testcontainers. It allows automatic service discovery, helping Spring Boot use the
PostgreSQLContainer
to connect to the PostgreSQL instance.
- This annotation is part of Spring Boot’s integration with Testcontainers. It allows automatic service discovery, helping Spring Boot use the
-
<u>@Value("${local.server.port}")</u>
- This Spring annotation injects the randomly assigned port number into the
port
field. It fetches the port from Spring’s application properties.
- This Spring annotation injects the randomly assigned port number into the
Class Variables
-
<u>public static PostgreSQLContainer<?> postgreSQLContainer</u>
- This creates and configures a PostgreSQL container using Testcontainers, which simulates a running PostgreSQL instance for integration tests.
- It pulls the default PostgreSQL Docker image (
"postgres"
) and runs the container in the background, ensuring a fresh database environment for every test execution.
-
<u>private int port;</u>
- The
port
field holds the dynamically allocated server port, ensuring all HTTP requests in the test point to the correct port.
- The
Helper Method
-
<u>private String createUserAndGetId(String userName)</u>
- This is a utility method to create a user and return the generated user ID. It performs an HTTP
POST
request to the/users/
endpoint to create a user with the provideduserName
. - The method:
- Uses
RestAssured
to setContentType
as JSON. - Sends the payload (
CreateUserDto
) to create a user. - Extracts the
id
from the response after a successful201
status.
- This is a utility method to create a user and return the generated user ID. It performs an HTTP
Test Methods
-
<u>void shouldCreateUser()</u>
- This test ensures that a user can be created via a
POST
request to the/users/
endpoint. - Verifies:
- The status code is
201
(Created). - The name in the response body matches the name provided ("Test user").
- This test ensures that a user can be created via a
-
<u>void shouldDeleteUserById()</u>
- This test creates a user and then deletes that user using the extracted ID via a
DELETE
request to/users/{id}
. - Verifies:
- The response status code is
204
(No Content), indicating successful deletion. - The body is empty after the deletion, confirmed by
emptyOrNullString()
.
- This test creates a user and then deletes that user using the extracted ID via a
-
<u>void shouldGetAllUsers()</u>
- This test creates a user and retrieves all users using a
GET
request to/users/
. - Verifies:
- The status code is
200
(OK). - The response contains at least one user by checking
size()
is greater than 0.
- This test creates a user and retrieves all users using a
-
<u>void shouldGetUserById()</u>
- This test creates a user and then fetches that user by their ID using a
GET
request to/users/{id}
. - Verifies:
- The status code is
200
(OK). - The
id
andname
fields in the response body match the created user's details.
- This test creates a user and then fetches that user by their ID using a
-
<u>void shouldReturn500WhenSearchForNonExistentUser()</u>
- This test attempts to fetch a user with a non-existent ID (
"9999"
) using aGET
request. - Verifies:
- The status code is
500
(Internal Server Error), which indicates the system encountered an error (though404
might be more appropriate for "Not Found").
- This test attempts to fetch a user with a non-existent ID (
-
<u>void shouldReturn500WhenDeletingNonExistentUser()</u>
- This test attempts to delete a user with a non-existent ID (
"9999"
) using aDELETE
request. - Verifies:
- The status code is
500
(Internal Server Error), indicating the system encountered an error while trying to delete a non-existent resource.
- This test attempts to delete a user with a non-existent ID (
Summary of RestAssured Integration:
- The
given()
method is part of the RestAssured DSL, used to define the HTTP request specifications likeContentType
, body, port, etc. -
.when()
executes the HTTP request with the defined method (e.g.,post()
,get()
,delete()
). -
.then()
verifies the response, asserting conditions like status codes, response body content, and structure.
Improvements You Could Consider
-
Use of
404 Not Found
:- Returning
500
for non-existent users is not ideal; you should modify your API to return404 Not Found
instead, which is more semantically correct.
- Returning
-
Parameterization of Tests:
- You can make some tests parameterized (e.g., different usernames, different scenarios for invalid input).
-
Error Handling Tests:
- Expand the test coverage for invalid inputs or edge cases like duplicate user creation, empty names, etc.
-
DTO Validation:
- If there are specific validation rules (e.g., name length), you could write tests to cover those scenarios as well.
Conclusion
In this article, we've explored how to effectively perform integration testing in a Spring Boot application using Testcontainers. By integrating Testcontainers, we can ensure our tests are reliable, isolated, and closely mimic a production environment. We walked through setting up a Spring Boot project, creating a PostgreSQL container, and writing CRUD operations for a simple user management API. Additionally, we demonstrated how to use RestAssured for integration testing and ensured proper coverage for key scenarios such as creating, deleting, and retrieving users.
Testcontainers offers significant benefits by providing reproducible, production-like environments for testing. This approach addresses the challenges posed by in-memory databases and shared staging environments, ensuring that our tests are portable, consistent, and easy to manage. By following this guide, you'll have a solid foundation to implement robust integration testing in your own Spring Boot applications.
Thanks for reading !
Top comments (0)