In modern software engineering, Test-Driven Development (TDD) is more than just a methodology—it’s a cultural shift that ensures reliability, maintainability, and faster delivery cycles. With Java’s rich ecosystem, JUnit 5 and Mockito stand as the go-to tools for implementing TDD. This tutorial will guide you through writing tests first, letting them fail, and then incrementally writing production code until the tests pass. Along the way, you’ll see how Mockito helps isolate dependencies and keep your unit tests clean and maintainable.
What is TDD?
Test-Driven Development (TDD) is a software development practice that follows the Red-Green-Refactor cycle:
- Red – Write a failing test that defines the desired behavior.
- Green – Write the minimum production code to make the test pass.
- Refactor – Improve the code structure while keeping tests green.
This approach ensures that your codebase grows alongside a reliable test suite, reducing bugs and simplifying maintenance.
Why Use JUnit 5 and Mockito for TDD?
- JUnit 5: Provides annotations (
@Test
,@BeforeEach
,@ParameterizedTest
) and powerful assertions. - Mockito: Allows mocking dependencies, stubbing behavior, and verifying interactions without requiring real implementations.
- Combined: They make TDD practical by ensuring tests remain isolated, fast, and easy to maintain.
Setting Up Your Project
Add dependencies in pom.xml
(Maven):
<dependencies>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<!-- Mockito JUnit 5 Integration -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Example: Building a Simple Service with TDD
Let’s walk through a UserService example.
Step 1: Write the Failing Test (Red)
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldReturnUserById() {
User mockUser = new User(1, "Alice");
when(userRepository.findById(1)).thenReturn(Optional.of(mockUser));
User result = userService.getUserById(1);
assertNotNull(result);
assertEquals("Alice", result.getName());
verify(userRepository).findById(1);
}
}
At this stage, the test fails because UserService
does not exist yet.
Step 2: Write Minimal Production Code (Green)
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(int id) {
return userRepository.findById(id).orElseThrow(() -> new RuntimeException("User not found"));
}
}
Now, rerun the test—it passes!
Step 3: Refactor
We can now clean up code, improve exception handling, or introduce DTOs while keeping tests green.
Parameterized Tests in TDD
JUnit 5 makes it easy to write multiple test scenarios using @ParameterizedTest
:
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void shouldHandleMultipleUserIds(int id) {
when(userRepository.findById(id)).thenReturn(Optional.of(new User(id, "TestUser")));
assertNotNull(userService.getUserById(id));
}
This reduces boilerplate and ensures broader coverage.
Integrating Testcontainers for TDD
For integration/system testing, Testcontainers helps simulate real databases or services:
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void shouldPersistUser() throws SQLException {
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
Statement stmt = conn.createStatement();
stmt.execute("CREATE TABLE users(id SERIAL PRIMARY KEY, name VARCHAR(50))");
stmt.execute("INSERT INTO users(name) VALUES ('Alice')");
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM users");
rs.next();
assertEquals(1, rs.getInt(1));
}
}
}
This allows true TDD in CI/CD pipelines by testing against production-like environments.
Best Practices for TDD with JUnit and Mockito
- Keep tests independent – each test should run in isolation.
- Name tests descriptively – e.g.,
shouldReturnUserWhenValidId
. - Avoid over-mocking – only mock external dependencies, not core domain logic.
- Refactor fearlessly – tests give confidence to improve production code.
- Leverage Testcontainers for integration – simulate databases, Kafka, or APIs reliably.
- Integrate with CI/CD – run tests in Jenkins, GitHub Actions, or GitLab pipelines.
Version Tracker
- JUnit 4 → JUnit 5: More flexible lifecycle and parameterized testing.
- Mockito: Support for static/final method mocking since v3.4+.
- Testcontainers: Expanding ecosystem with modules for databases, Kafka, RabbitMQ, LocalStack, and Docker Compose.
Conclusion
TDD with JUnit 5 and Mockito enables developers to build bug-resistant, maintainable, and production-ready applications. By writing tests first, verifying dependencies with Mockito, and integrating Testcontainers for real-world environments, you ensure that your system is both reliable and scalable.
Key Takeaways
- Follow the Red-Green-Refactor cycle.
- Use JUnit 5 for modern assertions and parameterized tests.
- Use Mockito to mock dependencies cleanly.
- Use Testcontainers to validate integration with external systems.
- Integrate into CI/CD pipelines for production confidence.
FAQ
1. What’s the difference between unit and integration tests?
Unit tests isolate logic, while integration tests verify multiple components or services working together.
2. How do I mock a static method in Mockito?
Since Mockito 3.4+, use mockStatic(MyClass.class)
inside a try-with-resources block.
3. How can Testcontainers help in CI/CD pipelines?
It spins up real services (databases, Kafka, APIs) dynamically in CI environments, ensuring production-like testing.
4. What is the difference between TDD and BDD?
TDD focuses on implementation correctness, while BDD emphasizes behavior and readability, often with Cucumber.
5. How do I fix flaky tests in Java?
Isolate external dependencies, avoid time-based assertions, and use Testcontainers’ reusable containers.
6. Can Mockito mock final classes?
Yes, since Mockito 2+, enable it with configuration or inline mocking.
7. What’s the role of JaCoCo in TDD?
JaCoCo measures code coverage, ensuring your TDD tests cover meaningful paths.
8. Should I write tests for legacy codebases?
Yes, start with characterization tests before refactoring.
9. Can TDD be used with microservices?
Yes, with Testcontainers you can test inter-service communication reliably.
10. Is TDD mandatory for agile teams?
Not mandatory, but highly recommended for teams that value fast delivery and long-term maintainability.