Master Test-Driven Development (TDD) in Java
Test-Driven Development (TDD) in Java
Test-Driven Development (TDD) is a software development methodology where tests are written before the actual code. This approach ensures that code is thoroughly tested, well-designed, and meets requirements from the start.
Table of Contents
- TDD Fundamentals
- The TDD Cycle
- Red-Green-Refactor
- TDD in Practice
- Advanced TDD Techniques
- TDD with Spring Boot
- Benefits and Challenges
- Best Practices
TDD Fundamentals
What is TDD?
Test-Driven Development follows a simple mantra: "Red, Green, Refactor"
- Red: Write a failing test
- Green: Write minimal code to make the test pass
- Refactor: Improve code while keeping tests green
Core Principles
- Test First: Always write tests before implementation
- Incremental: Small steps, one test at a time
- Refactor: Continuously improve code design
- Fast Feedback: Quick test execution and validation
TDD vs Traditional Development
// Traditional Development
// 1. Write code
// 2. Write tests (maybe)
// 3. Debug issues
// 4. Refactor (sometimes)
// TDD Approach
// 1. Write failing test
// 2. Write minimal code to pass
// 3. Refactor while tests remain green
// 4. Repeat
The TDD Cycle
Step-by-Step Process
// Example: Building a Calculator class using TDD
// STEP 1: RED - Write a failing test
@Test
void shouldAddTwoNumbers() {
Calculator calculator = new Calculator(); // This doesn't exist yet
int result = calculator.add(2, 3);
assertEquals(5, result);
}
// STEP 2: GREEN - Write minimal code to pass
public class Calculator {
public int add(int a, int b) {
return 5; // Hardcoded to make test pass
}
}
// STEP 3: RED - Write another failing test
@Test
void shouldAddDifferentNumbers() {
Calculator calculator = new Calculator();
assertEquals(7, calculator.add(3, 4));
assertEquals(0, calculator.add(-2, 2));
}
// STEP 4: GREEN - Implement proper logic
public class Calculator {
public int add(int a, int b) {
return a + b; // Real implementation
}
}
// STEP 5: REFACTOR - Improve if needed (code is already simple)
Detailed TDD Workflow
class CalculatorTDDTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
// Iteration 1: Basic addition
@Test
void shouldAddTwoPositiveNumbers() {
assertEquals(5, calculator.add(2, 3));
}
// Iteration 2: Handle zero
@Test
void shouldAddWithZero() {
assertEquals(2, calculator.add(2, 0));
assertEquals(3, calculator.add(0, 3));
}
// Iteration 3: Handle negative numbers
@Test
void shouldAddNegativeNumbers() {
assertEquals(-1, calculator.add(-2, 1));
assertEquals(-5, calculator.add(-2, -3));
}
// Iteration 4: Subtraction
@Test
void shouldSubtractNumbers() {
assertEquals(2, calculator.subtract(5, 3));
assertEquals(-2, calculator.subtract(3, 5));
}
// Iteration 5: Division with error handling
@Test
void shouldDivideNumbers() {
assertEquals(2.5, calculator.divide(5, 2), 0.001);
}
@Test
void shouldThrowExceptionWhenDividingByZero() {
assertThrows(IllegalArgumentException.class,
() -> calculator.divide(5, 0));
}
}
Red-Green-Refactor
Red Phase: Write Failing Test
// Start with a clear requirement and write the test first
class BankAccountTDDTest {
@Test
void shouldCreateAccountWithInitialBalance() {
// This test will fail because BankAccount doesn't exist
BankAccount account = new BankAccount(100.0);
assertEquals(100.0, account.getBalance(), 0.01);
}
@Test
void shouldDepositMoney() {
BankAccount account = new BankAccount(100.0);
account.deposit(50.0);
assertEquals(150.0, account.getBalance(), 0.01);
}
@Test
void shouldWithdrawMoney() {
BankAccount account = new BankAccount(100.0);
boolean result = account.withdraw(30.0);
assertTrue(result);
assertEquals(70.0, account.getBalance(), 0.01);
}
@Test
void shouldNotAllowOverdraft() {
BankAccount account = new BankAccount(50.0);
boolean result = account.withdraw(100.0);
assertFalse(result);
assertEquals(50.0, account.getBalance(), 0.01);
}
}
Green Phase: Make Tests Pass
// Minimal implementation to make tests pass
public class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public double getBalance() {
return balance;
}
public void deposit(double amount) {
balance += amount;
}
public boolean withdraw(double amount) {
if (amount <= balance) {
balance -= amount;
return true;
}
return false;
}
}
Refactor Phase: Improve Design
// Refactored version with better validation and design
public class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
validatePositiveAmount(initialBalance, "Initial balance");
this.balance = initialBalance;
}
public double getBalance() {
return balance;
}
public void deposit(double amount) {
validatePositiveAmount(amount, "Deposit amount");
balance += amount;
}
public boolean withdraw(double amount) {
validatePositiveAmount(amount, "Withdrawal amount");
if (hasSufficientFunds(amount)) {
balance -= amount;
return true;
}
return false;
}
private void validatePositiveAmount(double amount, String operation) {
if (amount <= 0) {
throw new IllegalArgumentException(operation + " must be positive");
}
}
private boolean hasSufficientFunds(double amount) {
return amount <= balance;
}
}
// Add tests for the new validation
@Test
void shouldThrowExceptionForNegativeDeposit() {
BankAccount account = new BankAccount(100.0);
assertThrows(IllegalArgumentException.class,
() -> account.deposit(-10.0));
}
@Test
void shouldThrowExceptionForNegativeWithdrawal() {
BankAccount account = new BankAccount(100.0);
assertThrows(IllegalArgumentException.class,
() -> account.withdraw(-10.0));
}
TDD in Practice
Building a Shopping Cart
// Requirements:
// - Add items to cart
// - Remove items from cart
// - Calculate total
// - Apply discounts
class ShoppingCartTDDTest {
private ShoppingCart cart;
@BeforeEach
void setUp() {
cart = new ShoppingCart();
}
// RED: First failing test
@Test
void shouldStartWithEmptyCart() {
assertTrue(cart.isEmpty());
assertEquals(0, cart.getItemCount());
}
// GREEN: Minimal implementation
// public class ShoppingCart {
// public boolean isEmpty() { return true; }
// public int getItemCount() { return 0; }
// }
@Test
void shouldAddItemToCart() {
Item item = new Item("Book", 29.99);
cart.addItem(item);
assertFalse(cart.isEmpty());
assertEquals(1, cart.getItemCount());
assertTrue(cart.contains(item));
}
@Test
void shouldCalculateTotalForSingleItem() {
Item item = new Item("Book", 29.99);
cart.addItem(item);
assertEquals(29.99, cart.getTotal(), 0.01);
}
@Test
void shouldCalculateTotalForMultipleItems() {
cart.addItem(new Item("Book", 29.99));
cart.addItem(new Item("Pen", 5.99));
assertEquals(35.98, cart.getTotal(), 0.01);
}
@Test
void shouldRemoveItemFromCart() {
Item book = new Item("Book", 29.99);
Item pen = new Item("Pen", 5.99);
cart.addItem(book);
cart.addItem(pen);
cart.removeItem(book);
assertEquals(1, cart.getItemCount());
assertFalse(cart.contains(book));
assertTrue(cart.contains(pen));
assertEquals(5.99, cart.getTotal(), 0.01);
}
@Test
void shouldHandleQuantities() {
Item book = new Item("Book", 29.99);
cart.addItem(book, 3);
assertEquals(1, cart.getItemCount()); // 1 unique item
assertEquals(3, cart.getQuantity(book));
assertEquals(89.97, cart.getTotal(), 0.01);
}
@Test
void shouldApplyPercentageDiscount() {
cart.addItem(new Item("Book", 100.0));
cart.applyDiscount(new PercentageDiscount(10)); // 10% off
assertEquals(90.0, cart.getTotal(), 0.01);
}
}
// Evolved implementation after multiple TDD cycles
public class ShoppingCart {
private Map<Item, Integer> items = new HashMap<>();
private List<Discount> discounts = new ArrayList<>();
public boolean isEmpty() {
return items.isEmpty();
}
public int getItemCount() {
return items.size();
}
public void addItem(Item item) {
addItem(item, 1);
}
public void addItem(Item item, int quantity) {
items.merge(item, quantity, Integer::sum);
}
public boolean contains(Item item) {
return items.containsKey(item);
}
public void removeItem(Item item) {
items.remove(item);
}
public int getQuantity(Item item) {
return items.getOrDefault(item, 0);
}
public double getTotal() {
double subtotal = items.entrySet().stream()
.mapToDouble(entry -> entry.getKey().getPrice() * entry.getValue())
.sum();
return applyDiscounts(subtotal);
}
public void applyDiscount(Discount discount) {
discounts.add(discount);
}
private double applyDiscounts(double amount) {
return discounts.stream()
.reduce(amount, (acc, discount) -> discount.apply(acc), Double::sum);
}
}
Building User Authentication Service
class UserAuthenticationServiceTDDTest {
private UserAuthenticationService authService;
private UserRepository userRepository;
private PasswordEncoder passwordEncoder;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
passwordEncoder = mock(PasswordEncoder.class);
authService = new UserAuthenticationService(userRepository, passwordEncoder);
}
@Test
void shouldRegisterNewUser() {
String username = "john";
String password = "password123";
String encodedPassword = "encoded_password";
when(userRepository.existsByUsername(username)).thenReturn(false);
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
boolean result = authService.register(username, password);
assertTrue(result);
verify(userRepository).save(argThat(user ->
user.getUsername().equals(username) &&
user.getPassword().equals(encodedPassword)
));
}
@Test
void shouldNotRegisterExistingUser() {
String username = "existing";
when(userRepository.existsByUsername(username)).thenReturn(true);
boolean result = authService.register(username, "password");
assertFalse(result);
verify(userRepository, never()).save(any());
}
@Test
void shouldAuthenticateValidUser() {
String username = "john";
String password = "password123";
String encodedPassword = "encoded_password";
User user = new User(username, encodedPassword);
when(userRepository.findByUsername(username)).thenReturn(user);
when(passwordEncoder.matches(password, encodedPassword)).thenReturn(true);
boolean result = authService.authenticate(username, password);
assertTrue(result);
}
@Test
void shouldNotAuthenticateInvalidPassword() {
String username = "john";
String password = "wrong_password";
String encodedPassword = "encoded_password";
User user = new User(username, encodedPassword);
when(userRepository.findByUsername(username)).thenReturn(user);
when(passwordEncoder.matches(password, encodedPassword)).thenReturn(false);
boolean result = authService.authenticate(username, password);
assertFalse(result);
}
@Test
void shouldNotAuthenticateNonExistentUser() {
when(userRepository.findByUsername("nonexistent")).thenReturn(null);
boolean result = authService.authenticate("nonexistent", "password");
assertFalse(result);
}
}
// Implementation driven by tests
public class UserAuthenticationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserAuthenticationService(UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public boolean register(String username, String password) {
if (userRepository.existsByUsername(username)) {
return false;
}
String encodedPassword = passwordEncoder.encode(password);
User user = new User(username, encodedPassword);
userRepository.save(user);
return true;
}
public boolean authenticate(String username, String password) {
User user = userRepository.findByUsername(username);
if (user == null) {
return false;
}
return passwordEncoder.matches(password, user.getPassword());
}
}
Advanced TDD Techniques
Outside-In TDD
// Start with acceptance test (outside)
@Test
void shouldCompleteOrderProcessingWorkflow() {
// Given
Customer customer = new Customer("[email protected]");
Product product = new Product("Book", BigDecimal.valueOf(29.99));
// When
OrderProcessor processor = new OrderProcessor();
OrderResult result = processor.processOrder(customer, product, 1);
// Then
assertThat(result.isSuccessful()).isTrue();
assertThat(result.getOrder().getStatus()).isEqualTo(OrderStatus.CONFIRMED);
assertThat(result.getOrder().getTotal()).isEqualTo(BigDecimal.valueOf(29.99));
}
// Then work inward, defining collaborators through tests
class OrderProcessorTest {
@Mock private InventoryService inventoryService;
@Mock private PaymentService paymentService;
@Mock private EmailService emailService;
@InjectMocks private OrderProcessor orderProcessor;
@Test
void shouldProcessOrderWhenInventoryAvailable() {
// Test drives the design of OrderProcessor and its collaborators
Customer customer = new Customer("[email protected]");
Product product = new Product("Book", BigDecimal.valueOf(29.99));
when(inventoryService.isAvailable(product, 1)).thenReturn(true);
when(paymentService.charge(customer, BigDecimal.valueOf(29.99)))
.thenReturn(new PaymentResult(true, "txn-123"));
OrderResult result = orderProcessor.processOrder(customer, product, 1);
assertThat(result.isSuccessful()).isTrue();
verify(emailService).sendOrderConfirmation(any(Order.class));
}
}
Triangulation in TDD
// Use multiple examples to drive toward general solution
class StringCalculatorTest {
private StringCalculator calculator = new StringCalculator();
@Test
void shouldReturnZeroForEmptyString() {
assertEquals(0, calculator.add(""));
}
@Test
void shouldReturnNumberForSingleNumber() {
assertEquals(1, calculator.add("1"));
assertEquals(5, calculator.add("5"));
}
@Test
void shouldAddTwoNumbers() {
assertEquals(3, calculator.add("1,2"));
assertEquals(7, calculator.add("3,4"));
assertEquals(15, calculator.add("7,8"));
}
@Test
void shouldHandleMultipleNumbers() {
assertEquals(6, calculator.add("1,2,3"));
assertEquals(10, calculator.add("1,2,3,4"));
}
@Test
void shouldHandleNewlineDelimiter() {
assertEquals(6, calculator.add("1\n2,3"));
assertEquals(10, calculator.add("1\n2\n3,4"));
}
}
// Implementation evolves through triangulation
public class StringCalculator {
public int add(String numbers) {
if (numbers.isEmpty()) {
return 0;
}
String[] parts = numbers.split("[,\n]");
return Arrays.stream(parts)
.mapToInt(Integer::parseInt)
.sum();
}
}
TDD with Spring Boot
Controller TDD
@WebMvcTest(UserController.class)
class UserControllerTDDTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldCreateUser() throws Exception {
CreateUserRequest request = new CreateUserRequest("john", "[email protected]");
User createdUser = new User(1L, "john", "[email protected]");
when(userService.createUser(any(CreateUserRequest.class)))
.thenReturn(createdUser);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "john",
"email": "[email protected]"
}
"""))
.andExpect(status().isCreated())
.andExpected(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.username").value("john"));
}
@Test
void shouldValidateUserInput() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.username").exists())
.andExpect(jsonPath("$.errors.email").exists());
}
}
// Controller implementation driven by tests
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
}
Service Layer TDD
@ExtendWith(MockitoExtension.class)
class UserServiceTDDTest {
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@InjectMocks private UserService userService;
@Test
void shouldCreateUserAndSendWelcomeEmail() {
CreateUserRequest request = new CreateUserRequest("john", "[email protected]");
User savedUser = new User(1L, "john", "[email protected]");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
User result = userService.createUser(request);
assertThat(result).isEqualTo(savedUser);
verify(emailService).sendWelcomeEmail("[email protected]", "john");
}
@Test
void shouldThrowExceptionWhenUsernameExists() {
CreateUserRequest request = new CreateUserRequest("existing", "[email protected]");
when(userRepository.existsByUsername("existing")).thenReturn(true);
assertThrows(UserAlreadyExistsException.class,
() -> userService.createUser(request));
verify(userRepository, never()).save(any());
}
}
Benefits and Challenges
Benefits of TDD
// 1. Better Test Coverage - Every line of code has a test
class BenefitsExampleTest {
@Test
void demonstratesBetterDesign() {
// TDD forces you to think about API design first
PaymentProcessor processor = new PaymentProcessor();
// Clear, testable interface
PaymentResult result = processor.processPayment(
new PaymentRequest("4111111111111111", BigDecimal.valueOf(100))
);
assertThat(result.isSuccessful()).isTrue();
}
@Test
void demonstratesDocumentation() {
// Tests serve as living documentation
Calculator calc = new Calculator();
// Business rule: division by zero should throw exception
assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
// Business rule: negative numbers allowed
assertEquals(-5, calc.add(-2, -3));
}
}
// 2. Faster Feedback Loop
// - Tests fail immediately when you break something
// - No need to manually test through UI
// - Regression detection is automatic
// 3. Refactoring Confidence
public class RefactoringExample {
// Original implementation
public double calculateTax_v1(double amount, String state) {
if (state.equals("CA")) {
return amount * 0.08;
} else if (state.equals("NY")) {
return amount * 0.07;
}
return amount * 0.05;
}
// Refactored with tests providing safety net
private static final Map<String, Double> TAX_RATES = Map.of(
"CA", 0.08,
"NY", 0.07
);
public double calculateTax_v2(double amount, String state) {
double rate = TAX_RATES.getOrDefault(state, 0.05);
return amount * rate;
}
}
Common Challenges
class TDDChallengesTest {
// Challenge 1: Learning curve - need to think differently
@Test
void challengeThinkingTestFirst() {
// Old habit: implement then test
// New habit: test then implement
// It takes practice to write good tests first
Calculator calc = new Calculator();
assertThat(calc.multiply(3, 4)).isEqualTo(12);
}
// Challenge 2: Slower initial development
@Test
void challengeInitialSpeed() {
// TDD might seem slower at first
// But prevents bugs and rework later
// Overall development speed increases
}
// Challenge 3: Testing external dependencies
@Test
void challengeExternalDependencies() {
// Mock external systems in unit tests
ExternalService mockService = mock(ExternalService.class);
when(mockService.getData()).thenReturn("test data");
MyService service = new MyService(mockService);
String result = service.processData();
assertThat(result).contains("test data");
}
}
Best Practices
Effective TDD Workflow
class TDDBestPracticesTest {
// 1. Start with simplest test
@Test
void startSimple() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("1", fizzBuzz.convert(1));
}
// 2. Write failing test first
@Test
void alwaysStartWithFailingTest() {
// This test should fail when first written
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("Fizz", fizzBuzz.convert(3));
}
// 3. Write minimal code to pass
public class FizzBuzz {
public String convert(int number) {
if (number % 3 == 0) return "Fizz";
return String.valueOf(number);
}
}
// 4. Add more specific tests
@Test
void shouldReturnBuzzForMultiplesOfFive() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("Buzz", fizzBuzz.convert(5));
assertEquals("Buzz", fizzBuzz.convert(10));
}
@Test
void shouldReturnFizzBuzzForMultiplesOfBoth() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("FizzBuzz", fizzBuzz.convert(15));
assertEquals("FizzBuzz", fizzBuzz.convert(30));
}
// 5. Refactor when tests are green
public class FizzBuzzRefactored {
public String convert(int number) {
StringBuilder result = new StringBuilder();
if (number % 3 == 0) result.append("Fizz");
if (number % 5 == 0) result.append("Buzz");
return result.length() > 0 ? result.toString() : String.valueOf(number);
}
}
}
class TDDGuidelines {
// DO: Keep tests simple and focused
@Test
void goodTestExample() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3));
}
// DON'T: Test multiple things in one test
@Test
void badTestExample() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3)); // Addition
assertEquals(1, calc.subtract(3, 2)); // Subtraction
assertEquals(6, calc.multiply(2, 3)); // Multiplication
// Too many responsibilities in one test
}
// DO: Use descriptive test names
@Test
void shouldThrowExceptionWhenDividingByZero() {
Calculator calc = new Calculator();
assertThrows(ArithmeticException.class, () -> calc.divide(5, 0));
}
// DON'T: Use vague test names
@Test
void testDivision() {
// What aspect of division is being tested?
}
// DO: Follow the AAA pattern
@Test
void shouldCalculateOrderTotal() {
// Arrange
Order order = new Order();
order.addItem(new Item("Book", BigDecimal.valueOf(29.99)));
order.addItem(new Item("Pen", BigDecimal.valueOf(5.99)));
// Act
BigDecimal total = order.calculateTotal();
// Assert
assertEquals(BigDecimal.valueOf(35.98), total);
}
}
TDD Anti-Patterns to Avoid
class TDDAntiPatternsTest {
// ANTI-PATTERN: Writing tests after implementation
// DON'T: Write code first, then add tests
// ANTI-PATTERN: Testing implementation details
@Test
void badTestingImplementationDetails() {
UserService service = new UserService();
// Testing internal method calls instead of behavior
// verify(service.getValidator()).validate(any()); // Don't do this
}
// BETTER: Test behavior and outcomes
@Test
void goodTestingBehavior() {
UserService service = new UserService();
User user = service.createUser("john", "[email protected]");
assertThat(user.getUsername()).isEqualTo("john");
}
// ANTI-PATTERN: Overly complex tests
@Test
void overlyComplexTest() {
// Avoid complex setup and multiple assertions
// If test is complex, break it down
}
// ANTI-PATTERN: Ignoring refactoring phase
// DON'T: Skip refactoring after making tests pass
// DO: Always improve code structure when tests are green
}
Summary
Test-Driven Development is a powerful methodology that improves code quality and design:
Core Process:
- Red: Write a failing test first
- Green: Write minimal code to make it pass
- Refactor: Improve design while keeping tests green
Key Benefits:
- Better Design: Forces thinking about API and interfaces first
- High Test Coverage: Every line of code is tested
- Living Documentation: Tests explain how code should behave
- Refactoring Confidence: Safe to change code with comprehensive tests
- Faster Feedback: Immediate notification of regressions
TDD Techniques:
- Baby Steps: Small increments, one test at a time
- Triangulation: Use multiple examples to drive general solutions
- Outside-In: Start with acceptance tests, work inward
- Mock Dependencies: Isolate units under test
Best Practices:
- Write simplest failing test first
- Make it pass with minimal code
- Refactor fearlessly when tests are green
- Keep tests simple and focused
- Use descriptive test names
- Follow Arrange-Act-Assert pattern
Common Pitfalls:
- Writing tests after implementation
- Testing implementation details instead of behavior
- Overly complex test setup
- Skipping the refactoring phase
TDD requires discipline and practice but leads to more robust, maintainable, and well-designed Java applications.