The Java Testing Pyramid: JUnit, Mockito, and Testcontainers
Quality is Not an Accident
In professional Java development, your code is only as good as your tests. A robust test suite consists of fast Unit tests and reliable Integration tests. This guide explores the tools that make this possible and how to structure your testing “pyramid.”
Core Concepts
1. Unit Testing (JUnit 5 + Mockito)
Testing the smallest unit of code (a single method or class) in total isolation.
- JUnit 5: The standard test runner for Java.
- Mockito: A framework used to create “mocks” or “stubs” that simulate external dependencies like database repositories or external web clients.
2. Integration Testing (Spring Boot Test + Testcontainers)
Testing how different modules of your application interact with infrastructure.
- Testcontainers: A library that uses Docker to spin up real instances of PostgreSQL, Redis, or Kafka during your test execution. This ensures you are testing against “reality” rather than a simulated version of your infrastructure.
Practice Exercise: Testing a Service with Mocks vs. Reality
We will test a UserService that interacts with a database.
Step 1: Unit Test with Mockito (Fast & Isolated)
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository repo;
@InjectMocks
private UserService service;
@Test
void testGetUser_ShouldReturnUser_WhenExists() {
// Arrange
when(repo.findById(1L)).thenReturn(Optional.of(new User("Nhon")));
// Act
User result = service.getUser(1L);
// Assert
assertEquals("Nhon", result.getName());
verify(repo, times(1)).findById(1L);
}
}
Step 2: Integration Test with Testcontainers (Reliable & Realistic)
This spins up a REAL PostgreSQL instance in a Docker container.
@SpringBootTest
@Testcontainers
class UserIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository repo;
@Test
void testSave_ShouldPersistToRealDatabase() {
repo.save(new User("Truong Nhon"));
assertEquals(1, repo.count());
}
}
Why This Works
- Behavior Mapping: Mockito allows you to define exactly how a dependency should behave, making your unit tests lightning-fast and focused purely on business logic.
- Portability: Testcontainers solves the “works on my machine” problem. If you have Docker, your integration tests will run identically in your local IDE, on a colleague’s machine, and in the CI/CD pipeline.
Testing Tip: The “Spy”
Use @Spy when you want to call real methods of a class but mock only a specific one. Use this sparingly, as it can lead to fragile tests that depend on internal implementation details.
Summary
A balanced testing pyramid uses Mockito for logic-heavy classes and Testcontainers for infrastructure-heavy integrations. By mastering both, you ensure that your Java application is not only correct today but resilient to changes in the future.