DEV Community

Cover image for Integration Tests on Spring Boot with PostgreSQL and Testcontainers
Matheus Bernardes Spilari
Matheus Bernardes Spilari

Posted on

Integration Tests on Spring Boot with PostgreSQL and Testcontainers

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>

Enter fullscreen mode Exit fullscreen mode

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;
    }

}
Enter fullscreen mode Exit fullscreen mode

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> {

}
Enter fullscreen mode Exit fullscreen mode

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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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:

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.*;
Enter fullscreen mode Exit fullscreen mode

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);
        }
}

Enter fullscreen mode Exit fullscreen mode

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.
  • <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.

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.

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 provided userName.
    • The method:
    • Uses RestAssured to set ContentType as JSON.
    • Sends the payload (CreateUserDto) to create a user.
    • Extracts the id from the response after a successful 201 status.

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").
  • <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().
  • <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.
  • <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 and name fields in the response body match the created user's details.
  • <u>void shouldReturn500WhenSearchForNonExistentUser()</u>

    • This test attempts to fetch a user with a non-existent ID ("9999") using a GET request.
    • Verifies:
    • The status code is 500 (Internal Server Error), which indicates the system encountered an error (though 404 might be more appropriate for "Not Found").
  • <u>void shouldReturn500WhenDeletingNonExistentUser()</u>

    • This test attempts to delete a user with a non-existent ID ("9999") using a DELETE request.
    • Verifies:
    • The status code is 500 (Internal Server Error), indicating the system encountered an error while trying to delete a non-existent resource.

Summary of RestAssured Integration:

  • The given() method is part of the RestAssured DSL, used to define the HTTP request specifications like ContentType, 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

  1. Use of 404 Not Found:

    • Returning 500 for non-existent users is not ideal; you should modify your API to return 404 Not Found instead, which is more semantically correct.
  2. Parameterization of Tests:

    • You can make some tests parameterized (e.g., different usernames, different scenarios for invalid input).
  3. Error Handling Tests:

    • Expand the test coverage for invalid inputs or edge cases like duplicate user creation, empty names, etc.
  4. 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 !


📍 Reference

💻 Project Repository

👋 Talk to me

Top comments (0)