Test-Driven Development (TDD) with Java and Mockito: A Complete Guide

Kolluru PawanKolluru Pawan
4 min read

Introduction

Test-Driven Development (TDD) is a software development methodology where tests are written before the actual implementation. This ensures that the code is thoroughly tested, maintainable, and meets requirements from the start.

In this blog, we’ll explore TDD with Java and Mockito, covering:

  1. What is TDD?

  2. Setting Up the Project

  3. TDD Workflow (Red-Green-Refactor)

  4. Mockito for Mocking Dependencies

  5. End-to-End Example: Building a User Service

  6. Best Practices

1. What is TDD?

TDD follows a simple cycle:

  1. Red: Write a failing test.

  2. Green: Write minimal code to pass the test.

  3. Refactor: Improve the code while keeping tests passing.

Benefits of TDD:
✔ Fewer bugs
✔ Better design (loosely coupled, testable code)
✔ Documentation via tests
✔ Faster debugging


2. Setting Up the Project

We’ll use:

  • Java 11+

  • JUnit 5 (for testing)

  • Mockito (for mocking dependencies)

Maven Dependencies (pom.xml)

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>4.5.1</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>4.5.1</version>
        <scope>test</scope>
    </dependency>
</dependencies>

3. TDD Workflow (Red-Green-Refactor)

Let’s implement a simple Calculator class using TDD.

Step 1: Red (Write a Failing Test)

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    @Test
    void add_twoNumbers_returnsSum() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result); // Fails because Calculator doesn't exist
    }
}

Test fails (Red phase).

Step 2: Green (Implement Just Enough to Pass)

public class Calculator {
    public int add(int a, int b) {
        return a + b; // Minimal implementation
    }
}

Test passes (Green phase).

Step 3: Refactor (Improve Code Without Breaking Tests)

No refactoring needed here, but in complex cases, we might optimize or clean up.


4. Mockito for Mocking Dependencies

When testing classes with dependencies (e.g., databases, APIs), we use Mockito to mock them.

Example: Testing a UserService with UserRepository

Step 1: Define the Test (Red Phase)

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository; // Mocked dependency

    @InjectMocks
    private UserService userService; // Injects mocks into UserService

    @Test
    void getUserById_userExists_returnsUser() {
        // Arrange
        User expectedUser = new User(1, "john@example.com");
        when(userRepository.findById(1)).thenReturn(Optional.of(expectedUser));

        // Act
        User actualUser = userService.getUserById(1);

        // Assert
        assertEquals(expectedUser, actualUser);
        verify(userRepository).findById(1); // Verify interaction
    }

    @Test
    void getUserById_userNotFound_throwsException() {
        // Arrange
        when(userRepository.findById(1)).thenReturn(Optional.empty());

        // Act & Assert
        assertThrows(UserNotFoundException.class, () -> userService.getUserById(1));
    }
}

Fails because UserService doesn’t exist yet.

Step 2: Implement UserService (Green Phase)

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 UserNotFoundException("User not found"));
    }
}

Tests pass!

Step 3: Refactor (If Needed)

  • Improve exception handling

  • Optimize queries


5. End-to-End Example: Order Processing System

Let’s build an OrderService with:

  • InventoryService (checks stock)

  • PaymentService (processes payment)

  • ShippingService (schedules shipping)

Test Cases

@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {

    @Mock private InventoryService inventoryService;
    @Mock private PaymentService paymentService;
    @Mock private ShippingService shippingService;

    @InjectMocks private OrderService orderService;

    @Test
    void processOrder_successful_returnsSuccess() {
        Order order = new Order("123", "item1", 2, 100.0);
        when(inventoryService.checkStock("item1", 2)).thenReturn(true);
        when(paymentService.processPayment(100.0)).thenReturn(true);

        OrderResult result = orderService.processOrder(order);

        assertTrue(result.isSuccess());
        verify(shippingService).scheduleShipping("123");
    }

    @Test
    void processOrder_outOfStock_returnsFailure() {
        Order order = new Order("123", "item1", 2, 100.0);
        when(inventoryService.checkStock("item1", 2)).thenReturn(false);

        OrderResult result = orderService.processOrder(order);

        assertFalse(result.isSuccess());
        assertEquals("Out of stock", result.getMessage());
    }
}

Implementation

public class OrderService {
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ShippingService shippingService;

    public OrderService(InventoryService inventoryService, 
                       PaymentService paymentService,
                       ShippingService shippingService) {
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.shippingService = shippingService;
    }

    public OrderResult processOrder(Order order) {
        boolean inStock = inventoryService.checkStock(order.getItemId(), order.getQuantity());
        if (!inStock) {
            return new OrderResult(false, "Out of stock");
        }

        boolean paymentSuccess = paymentService.processPayment(order.getTotalAmount());
        if (!paymentSuccess) {
            return new OrderResult(false, "Payment failed");
        }

        shippingService.scheduleShipping(order.getOrderId());
        return new OrderResult(true, "Order processed");
    }
}

6. Best Practices

Test behavior, not implementation (avoid over-specifying mocks).
Keep tests small and focused (one assertion per test).
Use descriptive test names (e.g., getUserById_userNotFound_throwsException).
Refactor tests along with production code.
Avoid mocking everything (only mock external dependencies).


Conclusion

TDD with Java + Mockito leads to:
Reliable code (tested from the start)
Better design (forces modular, decoupled code)
Easier debugging (failures are caught early)

Start with simple tests, mock dependencies, and gradually build complex systems with confidence! 🚀

Next Steps:

  • Try TDD in your next Java project

  • Explore Mockito’s advanced features (argument captors, spies)

  • Learn integration testing with Spring Boot

0
Subscribe to my newsletter

Read articles from Kolluru Pawan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Kolluru Pawan
Kolluru Pawan