Integration testing is critical for ensuring that your application interacts correctly with external systems. In this project, we demonstrate how to perform integration testing in a Spring Boot application using PostgreSQL as the database and Testcontainers to run a real PostgreSQL instance during tests. This approach helps create a production-like, isolated, and repeatable testing environment.
1. What We Are Doing
We have built a Spring Boot application that manages devices via a REST API. The application includes a controller, service, repository, DTO, entity, and a global exception handler. Our focus is on testing the complete workflow from the controller layer down to the database. We use Testcontainers to spin up a PostgreSQL container dynamically during tests so that our integration tests use a real PostgreSQL instance rather than an in-memory database.
2. Technologies Used
The project leverages the following key technologies:
- Spring Boot – For rapid application development and minimal configuration.
- Spring Data JPA – To simplify database interactions via repositories.
- Spring Web – For web controllers.
- PostgreSQL – A robust production-grade relational database.
- Testcontainers – A library to run Docker containers during tests (here, used for PostgreSQL).
- JUnit 5 – For writing and running integration tests.
The pom.xml file includes the necessary dependencies for Spring Boot, PostgreSQL, and Testcontainers.
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.3</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>tech.devblueprint</groupId> <artifactId>web-spring-boot-integration-testing-example</artifactId> <version>0.0.1-SNAPSHOT</version> <name>web-spring-boot-integration-testing-example</name> <description>Demo project for Spring Boot</description> <url/> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-testcontainers</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </path> </annotationProcessorPaths> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>
3. Project Implementation Overview
Application Layers
DeviceController:
package tech.devblueprint.web_spring_boot_integration_testing_example.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import tech.devblueprint.web_spring_boot_integration_testing_example.dto.DeviceDTO; import tech.devblueprint.web_spring_boot_integration_testing_example.service.DeviceService; import java.util.List; @RestController @RequestMapping("/api/devices") @RequiredArgsConstructor public class DeviceController { private final DeviceService deviceService; @PostMapping public ResponseEntity<DeviceDTO> createDevice(@Valid @RequestBody DeviceDTO deviceDTO) { DeviceDTO created = deviceService.createDevice(deviceDTO); return ResponseEntity.ok(created); } @GetMapping("/{id}") public ResponseEntity<DeviceDTO> getDeviceById(@PathVariable Long id) { DeviceDTO deviceDTO = deviceService.getDeviceById(id); return ResponseEntity.ok(deviceDTO); } @GetMapping public ResponseEntity<List<DeviceDTO>> getAllDevices() { List<DeviceDTO> devices = deviceService.getAllDevices(); return ResponseEntity.ok(devices); } @PutMapping("/{id}") public ResponseEntity<DeviceDTO> updateDevice(@PathVariable Long id, @RequestBody DeviceDTO deviceDTO) { DeviceDTO updated = deviceService.updateDevice(id, deviceDTO); return ResponseEntity.ok(updated); } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteDevice(@PathVariable Long id) { deviceService.deleteDevice(id); return ResponseEntity.noContent().build(); } }
Implements CRUD endpoints for devices. It uses standard HTTP methods (POST, GET, PUT, DELETE) to create, retrieve, update, and delete device records.
RestExceptionHandler :
package tech.devblueprint.web_spring_boot_integration_testing_example.controller; import jakarta.persistence.EntityNotFoundException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class RestExceptionHandler { @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity<String> handleEntityNotFound(EntityNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); } @ExceptionHandler(Exception.class) public ResponseEntity<String> handleGeneralException(Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body("An unexpected error occurred: " + ex.getMessage()); } // Handle validation errors and return a JSON response with field-specific messages @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, String>> handleValidationErrors(MethodArgumentNotValidException ex) { Map<String, String> errors = new HashMap<>(); for (FieldError error : ex.getBindingResult().getFieldErrors()) { errors.put(error.getField(), error.getDefaultMessage()); } return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); } }
Provides global exception handling to return meaningful HTTP responses when errors occur (e.g., validation errors, entity not found).
DeviceDTO :
package tech.devblueprint.web_spring_boot_integration_testing_example.dto; import jakarta.validation.constraints.NotBlank; import lombok.*; @Data @NoArgsConstructor @AllArgsConstructor @Builder public class DeviceDTO { private Long id; @NotBlank(message = "Device name is required") private String name; @NotBlank(message = "Device type is required") private String type; @NotBlank(message = "Device manufacturer is required") private String manufacturer; }
A Data Transfer Object that represents the device data exchanged between the client and the server. It includes validation annotations to ensure required fields are provided.
Device Entity :
package tech.devblueprint.web_spring_boot_integration_testing_example.entity; import jakarta.persistence.*; import lombok.*; @Entity @Data @NoArgsConstructor @AllArgsConstructor @Builder public class Device { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String type; private String manufacturer; }
A JPA entity representing the device. It is mapped to a database table and includes fields such as id, name, type, and manufacturer.
DeviceMapper :
package tech.devblueprint.web_spring_boot_integration_testing_example.mapper; import org.springframework.stereotype.Component; import tech.devblueprint.web_spring_boot_integration_testing_example.dto.DeviceDTO; import tech.devblueprint.web_spring_boot_integration_testing_example.entity.Device; @Component public class DeviceMapper { public DeviceDTO toDTO(Device device) { if (device == null) return null; return DeviceDTO.builder() .id(device.getId()) .name(device.getName()) .type(device.getType()) .manufacturer(device.getManufacturer()) .build(); } public Device toEntity(DeviceDTO deviceDTO) { if (deviceDTO == null) return null; return Device.builder() .id(deviceDTO.getId()) .name(deviceDTO.getName()) .type(deviceDTO.getType()) .manufacturer(deviceDTO.getManufacturer()) .build(); } }
A simple mapper component that converts between the Device entity and the DeviceDTO.
DeviceRepository :
package tech.devblueprint.web_spring_boot_integration_testing_example.repository; import org.springframework.data.jpa.repository.JpaRepository; import tech.devblueprint.web_spring_boot_integration_testing_example.entity.Device; public interface DeviceRepository extends JpaRepository<Device, Long> { }
A Spring Data JPA repository that provides CRUD operations on the Device entity.
DeviceService :
package tech.devblueprint.web_spring_boot_integration_testing_example.service; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import tech.devblueprint.web_spring_boot_integration_testing_example.dto.DeviceDTO; import tech.devblueprint.web_spring_boot_integration_testing_example.entity.Device; import tech.devblueprint.web_spring_boot_integration_testing_example.mapper.DeviceMapper; import tech.devblueprint.web_spring_boot_integration_testing_example.repository.DeviceRepository; import java.util.List; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class DeviceService { private final DeviceRepository deviceRepository; private final DeviceMapper deviceMapper; public DeviceDTO createDevice(DeviceDTO deviceDTO) { Device device = deviceMapper.toEntity(deviceDTO); Device saved = deviceRepository.save(device); return deviceMapper.toDTO(saved); } public DeviceDTO getDeviceById(Long id) { Device device = deviceRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Device not found with id: " + id)); return deviceMapper.toDTO(device); } public List<DeviceDTO> getAllDevices() { return deviceRepository.findAll() .stream() .map(deviceMapper::toDTO) .collect(Collectors.toList()); } public DeviceDTO updateDevice(Long id, DeviceDTO deviceDTO) { Device existing = deviceRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Device not found with id: " + id)); existing.setName(deviceDTO.getName()); existing.setType(deviceDTO.getType()); existing.setManufacturer(deviceDTO.getManufacturer()); Device updated = deviceRepository.save(existing); return deviceMapper.toDTO(updated); } public void deleteDevice(Long id) { if (!deviceRepository.existsById(id)) { throw new EntityNotFoundException("Device not found with id: " + id); } deviceRepository.deleteById(id); } }
Contains business logic for device management, calling the repository and mapper to perform CRUD operations.
4. Integration Testing with Testcontainers
The integration tests are defined in the DeviceControllerIntegrationTest class.
package tech.devblueprint.web_spring_boot_integration_testing_example.controller; import com.fasterxml.jackson.databind.ObjectMapper; import tech.devblueprint.web_spring_boot_integration_testing_example.dto.DeviceDTO; import tech.devblueprint.web_spring_boot_integration_testing_example.repository.DeviceRepository; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @Testcontainers @SpringBootTest @AutoConfigureMockMvc @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class DeviceControllerIntegrationTest { @Container public static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:15") .withDatabaseName("testdb") .withUsername("postgres") .withPassword("postgres"); @DynamicPropertySource static void setDatasourceProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); registry.add("spring.datasource.username", postgresContainer::getUsername); registry.add("spring.datasource.password", postgresContainer::getPassword); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); } @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Autowired private DeviceRepository deviceRepository; // Clean DB before each test run @BeforeEach public void setup() { deviceRepository.deleteAll(); } // Test POST /api/devices - success scenario @Test @Order(1) public void testCreateDeviceSuccess() throws Exception { DeviceDTO deviceDTO = DeviceDTO.builder() .name("Device A") .type("Laptop") .manufacturer("Brand X") .build(); mockMvc.perform(post("/api/devices") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(deviceDTO))) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").exists()) .andExpect(jsonPath("$.name").value("Device A")); } // Test POST /api/devices - failure scenario (simulate invalid request) @Test @Order(2) public void testCreateDeviceFailure() throws Exception { // Sending an empty JSON should trigger validation errors mockMvc.perform(post("/api/devices") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.name").value("Device name is required")) .andExpect(jsonPath("$.type").value("Device type is required")) .andExpect(jsonPath("$.manufacturer").value("Device manufacturer is required")); } // Test GET /api/devices/{id} - success scenario @Test @Order(3) public void testGetDeviceByIdSuccess() throws Exception { // Create a device first DeviceDTO deviceDTO = DeviceDTO.builder() .name("Device B") .type("Phone") .manufacturer("Brand Y") .build(); String content = mockMvc.perform(post("/api/devices") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(deviceDTO))) .andReturn().getResponse().getContentAsString(); DeviceDTO created = objectMapper.readValue(content, DeviceDTO.class); mockMvc.perform(get("/api/devices/{id}", created.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(created.getId())) .andExpect(jsonPath("$.name").value("Device B")); } // Test GET /api/devices/{id} - failure scenario (non-existent id) @Test @Order(4) public void testGetDeviceByIdFailure() throws Exception { mockMvc.perform(get("/api/devices/{id}", 999)) .andExpect(status().isNotFound()) .andExpect(content().string("Device not found with id: 999")); } // Test GET /api/devices - success scenario @Test @Order(5) public void testGetAllDevices() throws Exception { // Create a couple of devices DeviceDTO device1 = DeviceDTO.builder() .name("Device C") .type("Tablet") .manufacturer("Brand Z") .build(); DeviceDTO device2 = DeviceDTO.builder() .name("Device D") .type("Laptop") .manufacturer("Brand X") .build(); mockMvc.perform(post("/api/devices") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(device1))); mockMvc.perform(post("/api/devices") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(device2))); mockMvc.perform(get("/api/devices")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(2)); } // Test PUT /api/devices/{id} - success scenario @Test @Order(6) public void testUpdateDeviceSuccess() throws Exception { DeviceDTO deviceDTO = DeviceDTO.builder() .name("Device E") .type("Phone") .manufacturer("Brand Y") .build(); String content = mockMvc.perform(post("/api/devices") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(deviceDTO))) .andReturn().getResponse().getContentAsString(); DeviceDTO created = objectMapper.readValue(content, DeviceDTO.class); created.setName("Device E Updated"); mockMvc.perform(put("/api/devices/{id}", created.getId()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(created))) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("Device E Updated")); } // Test PUT /api/devices/{id} - failure scenario (non-existent id) @Test @Order(7) public void testUpdateDeviceFailure() throws Exception { DeviceDTO deviceDTO = DeviceDTO.builder() .name("NonExistent Device") .type("Unknown") .manufacturer("No Brand") .build(); mockMvc.perform(put("/api/devices/{id}", 999) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(deviceDTO))) .andExpect(status().isNotFound()) .andExpect(content().string("Device not found with id: 999")); } // Test DELETE /api/devices/{id} - success scenario @Test @Order(8) public void testDeleteDeviceSuccess() throws Exception { DeviceDTO deviceDTO = DeviceDTO.builder() .name("Device F") .type("Laptop") .manufacturer("Brand X") .build(); String content = mockMvc.perform(post("/api/devices") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(deviceDTO))) .andReturn().getResponse().getContentAsString(); DeviceDTO created = objectMapper.readValue(content, DeviceDTO.class); mockMvc.perform(delete("/api/devices/{id}", created.getId())) .andExpect(status().isNoContent()); } // Test DELETE /api/devices/{id} - failure scenario (non-existent id) @Test @Order(9) public void testDeleteDeviceFailure() throws Exception { mockMvc.perform(delete("/api/devices/{id}", 999)) .andExpect(status().isNotFound()) .andExpect(content().string("Device not found with id: 999")); } }
Key Annotations and Setup
- @Testcontainers:
Enables automatic startup and shutdown of Docker containers during tests. - @SpringBootTest:
Boots up the entire Spring application context so that all layers (controller, service, repository) are available. - @AutoConfigureMockMvc:
Configures MockMvc for simulating HTTP requests to the REST API. - @TestMethodOrder(MethodOrderer.OrderAnnotation.class):
Specifies that tests should be run in a specific order, useful for scenarios where the database state is expected to change over successive tests.
@DynamicPropertySource static void setDatasourceProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); registry.add("spring.datasource.username", postgresContainer::getUsername); registry.add("spring.datasource.password", postgresContainer::getPassword); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); }
This method dynamically sets the datasource properties for the Spring application context to connect to the PostgreSQL container. It ensures that the application uses the container’s JDBC URL, username, and password.
@Container public static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:15") .withDatabaseName("testdb") .withUsername("postgres") .withPassword("postgres");
Declares a Testcontainers-managed PostgreSQL container that will be started before any tests run and stopped afterward.
Conclusion
This project demonstrates a comprehensive approach to integration testing in a Spring Boot application using PostgreSQL and Testcontainers. We covered the application’s core components—from controller and service layers to entity mapping and exception handling.