Spring Boot 4 Microservices Project: Home Energy Tracker - Testcontainers & Integration Testing
1. Why Do We Need This? (The Problem with In-Memory Databases)
Testing is a critical pillar of the software development lifecycle. A robust Spring Boot application typically follows the testing pyramid structure, encompassing unit tests, slice tests, integration tests, and end-to-end (E2E) tests. While unit tests isolate individual components, integration tests verify that multiple moving parts—such as application logic, transactional boundaries, and persistence layers—work in harmony.
Historically, developers relied on H2 (an in-memory relational database) to execute integration tests quickly. However, H2 introduces a major architectural mismatch:
- Behavioral Divergence: H2 behaves differently than production engines like MySQL or PostgreSQL regarding dialect nuances, data types, constraints, and query execution plans.
- The "Works on My Machine" Trap: Passing tests against an H2 in-memory store provide a false sense of security, often resulting in silent failures or broken constraints when deployed against a production MySQL instance.
2. Core Mental Model: Real-World Analogy & Testcontainers
Real-Life Analogy: The Disposable Construction Blueprint Studio
Imagine building a skyscraper. Testing your plumbing blueprints against a toy plastic model (H2) saves time, but it won't reveal if industrial-grade steel pipes fit into concrete walls. Instead, you want a disposable, fully functional mini-factory simulation that replicates the exact metallurgical properties of your final materials. Once your structural tests pass, the mini-factory is instantly dismantled and recycled, leaving no trace behind.
What is Testcontainers?
Testcontainers is a powerful Java library that allows developers to spin up real, containerized Docker instances (such as MySQL, PostgreSQL, or Kafka) automatically during test execution.
- Database Parity: Guaranteed compatibility by running the exact same database engine (e.g., MySQL 8.4) used in production.
- Zero Manual Setup: No need to manually configure local databases, run Docker compose scripts, or flush state between test runs.
- Disposable Lifecycle: Containers start up on demand and wipe themselves clean immediately after tests complete.
3. Project Starting State & Prerequisites
If you have followed the series up to this point, you can align your workspace with the instructor's baseline by checking out the correct Git tag:
git checkout V13-observability
Open your project in IntelliJ IDEA. For this demonstration, integration tests will be added to the User Service, which interacts directly with a MySQL database. Once established, this exact pattern can be replicated across remaining microservices.
4. Configuring Maven Dependencies (pom.xml)
To enable Testcontainers and MySQL support within your test suite, open the pom.xml file of your target service (e.g., user-service) and add the required test-scoped dependencies:
<dependencies>
<!-- Spring Boot Testcontainers Support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- JUnit Jupiter Testing Engine -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers MySQL Driver -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
5. Step-by-Step Architecture of Test Support Classes
To keep our test suites clean, modular, and maintainable, we will establish a dedicated test support structure under src/test/java:
Building the Base Container Class (MySQLTestContainer.java)
Create an abstract base class inside the testsupport package. This class spins up a static MySQL container shared across test executions.
package com.energy.tracker.userservice.testsupport;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
@Testcontainers
public abstract class MySQLTestContainer {
@Container
@ServiceConnection
protected static final MySQLContainer<?> MYSQL_CONTAINER =
new MySQLContainer<>("mysql:8.4")
.withDatabaseName("home_energy_tracker")
.withUsername("root")
.withPassword("password");
}
Note: The
@ServiceConnectionannotation automatically configures Spring Boot's datasource properties (spring.datasource.url,username,password) to point directly to the dynamically assigned host and random port of the running Testcontainers instance.
6. Data Transfer Object (DTO) Preparation
Before writing REST integration tests, ensure your incoming request payloads can be properly serialized and deserialized by adding standard Lombok annotations to your DTOs:
package com.energy.tracker.userservice.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDTO {
private Long id;
private String email;
private String address;
private boolean alerting;
private int energyAlertingThreshold;
}
7. Writing the Integration Test Suite (UserServiceIntegrationTest.java)
Create the integration test class inside the integration package. This class extends our base container configuration and wires up a TestRestTemplate to execute HTTP calls against a randomized embedded web server port.
package com.energy.tracker.userservice.integration;
import com.energy.tracker.userservice.dto.UserDTO;
import com.energy.tracker.userservice.repository.UserRepository;
import com.energy.tracker.userservice.testsupport.MySQLTestContainer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class UserServiceIntegrationTest extends MySQLTestContainer {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@Test
void createAndRetrieveUser_viaRestApi_persistsAndReturnsUser() {
// Arrange: Build request payload
UserDTO request = UserDTO.builder()
.email("leadjourney@gmail.com")
.address("123 Coding Street")
.alerting(true)
.energyAlertingThreshold(2000)
.build();
// Act 1: Send POST request to create user
ResponseEntity<UserDTO> createResponse = restTemplate.postForEntity(
"/api/v1/user",
request,
UserDTO.class
);
// Assert 1: Verify creation response
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(createResponse.getBody()).isNotNull();
assertThat(createResponse.getBody().getId()).isNotNull();
assertThat(createResponse.getBody().getEmail()).isEqualTo("leadjourney@gmail.com");
assertThat(createResponse.getBody().isAlerting()).isTrue();
assertThat(createResponse.getBody().getEnergyAlertingThreshold()).isEqualTo(2000);
Long createdId = createResponse.getBody().getId();
// Act 2: Send GET request to verify persistence
ResponseEntity<UserDTO> getResponse = restTemplate.getForEntity(
"/api/v1/user/" + createdId,
UserDTO.class
);
// Assert 2: Verify retrieved entity matches database state
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody()).isNotNull();
assertThat(getResponse.getBody().getEmail()).isEqualTo("leadjourney@gmail.com");
}
@Test
void saveUser_viaRepository_roundTripsThroughMySQL() {
// Direct repository verification ensuring JPA mapping & constraints function correctly
var user = new com.energy.tracker.userservice.model.User();
user.setEmail("repo.test@gmail.com");
user.setAddress("456 Database Ave");
user.setAlerting(false);
user.setEnergyAlertingThreshold(1500);
var savedUser = userRepository.save(user);
assertThat(savedUser.getId()).isNotNull();
var foundUser = userRepository.findById(savedUser.getId());
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getEmail()).isEqualTo("repo.test@gmail.com");
}
}
8. Industry Reality & Debugging Workflows
When developing and running Testcontainers in enterprise or local environments, developers frequently encounter specific operational hurdles.
Common Pitfalls & Solutions
Debugging with Database Clients
Because Testcontainers spins up a real MySQL container on a randomized host port (e.g., localhost:64370), you can connect directly to the containerized database during an active test debug session (using breakpoints):
This allows you to inspect tables (user_table), run ad-hoc SQL validation queries, and verify transaction rollbacks in real time.
9. Comprehensive Summary Table of Testing Layers
To maintain production resilience, combine the following testing strategies across your microservices architecture:
10. Summary and Key Takeaways
- Testing Pyramid Integration: A healthy microservices architecture relies on a balanced combination of unit tests, slice tests, integration tests, and end-to-end tests.
- The Pitfalls of H2: In-memory databases like H2 fail to catch production-specific dialect and constraint discrepancies, resulting in the dreaded "works on my machine" failure mode.
- Testcontainers Paradigm: Testcontainers eliminates environment drift by spinning up ephemeral, disposable Docker containers matching your exact production database version.
- Zero Manual Configuration: The
@ServiceConnectionannotation in Spring Boot automatically maps dynamic container ports and credentials to application datasource properties. - Realistic Verification: Using
TestRestTemplatealongside Testcontainers allows end-to-end simulation of HTTP requests, JSON serialization, transaction boundaries, and database persistence. - Debugging Power: Active test containers can be inspected mid-execution via external database tools using their dynamically assigned ports.
- Extensibility: The integration testing pattern demonstrated in the User Service can be seamlessly extended across remaining services, including those utilizing event-driven brokers like Kafka.