Test Driven Development (TDD) in Java
Introduction
Test Driven Development (TDD) is a software engineering methodology where unit tests are written before the actual production code. While it sounds counter-intuitive to write tests for code that doesn't exist, TDD forces developers to think deeply about system design, edge cases, and API boundaries upfront.
In the Java ecosystem, TDD is primarily facilitated by frameworks like JUnit 5 (for testing) and Mockito (for mocking dependencies).
The Core Concept: The Red-Green-Refactor Cycle
TDD dictates a strict, iterative three-step cycle:
- Red: Write a failing test for a specific requirement or feature. Because the production code doesn't exist yet, the code might not even compile, or the test will fail.
- Green: Write the absolute minimum amount of production code required to make the test pass. Do not over-engineer; just solve the immediate problem.
- Refactor: Clean up the code. Optimize algorithms, remove duplication, and improve variable names. The safety net of the passing test ensures you don't break functionality.
Implementation Example: Building a Calculator
Let's walk through TDD by building a simple StringCalculator that adds numbers provided in a comma-separated string.
Step 1: Red (Write a Failing Test)
We use JUnit 5. The requirement is: "An empty string returns 0".
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class StringCalculatorTest {
@Test
void emptyStringShouldReturnZero() {
StringCalculator calculator = new StringCalculator();
int result = calculator.add("");
assertEquals(0, result);
}
}
Result: Fails to compile because StringCalculator doesn't exist.
Step 2: Green (Make it Pass)
Write just enough code to satisfy the compiler and pass the test.
public class StringCalculator {
public int add(String numbers) {
return 0; // The simplest thing that works!
}
}
Result: Test passes (Green!).
Step 3: Red (Next Requirement)
Requirement: "A single number string returns the number itself".
@Test
void singleNumberShouldReturnTheNumber() {
StringCalculator calculator = new StringCalculator();
assertEquals(1, calculator.add("1"));
}
Result: Fails, because our code hardcodes return 0.
Step 4: Green (Make it Pass)
Update production code minimally.
public class StringCalculator {
public int add(String numbers) {
if (numbers.isEmpty()) {
return 0;
}
return Integer.parseInt(numbers);
}
}
Step 5: Refactor
Look at the test class. new StringCalculator() is duplicated. Let's refactor the test.
class StringCalculatorTest {
private StringCalculator calculator = new StringCalculator(); // Refactored
@Test
void emptyStringShouldReturnZero() {
assertEquals(0, calculator.add(""));
}
@Test
void singleNumberShouldReturnTheNumber() {
assertEquals(1, calculator.add("1"));
}
}
(You would continue this cycle for comma-separated numbers, handling exceptions for negatives, etc.)
Advanced TDD: Mocking with Mockito
In enterprise Java applications (e.g., Spring Boot), classes rarely work in isolation. A UserService might depend on a UserRepository that talks to a database. In TDD, you must isolate the unit under test. We achieve this using Mockito.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository; // Mocked database dependency
@InjectMocks
private UserService userService; // The class we are testing
@Test
void shouldReturnUserWhenFound() {
// Arrange
User mockUser = new User(1L, "Alice");
Mockito.when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
// Act
User result = userService.getUserById(1L);
// Assert
assertEquals("Alice", result.getName());
verify(userRepository).findById(1L); // Verifies the mock was interacted with
}
}
Best Practices
- Test Behavior, Not Implementation: Tests should verify what the code does, not how it does it. This makes refactoring easier.
- Keep Tests Fast: Unit tests should execute in milliseconds. Avoid database hits, file I/O, or network calls in TDD unit tests (mock them instead).
- Naming Conventions: Use descriptive method names for tests. E.g.,
givenEmptyString_whenAdded_thenReturnsZero(). - One Assertion Concept Per Test: Keep tests focused. If a test fails, it should be immediately obvious what requirement is broken.
Summary
TDD requires discipline, but it yields codebases that are highly modular, thoroughly tested, and practically immune to regression bugs. By writing tests first, the tests act as the first consumer of your API, forcing you to design cleaner, more usable interfaces.