DEV Community

Dev Cookies
Dev Cookies

Posted on

Test Driven Development with Java & Spring Boot: The Complete Guide

Table of Contents

  1. Introduction to Test Driven Development
  2. TDD Fundamentals
  3. Setting Up Your Spring Boot Project
  4. Unit Testing with JUnit 5
  5. Integration Testing in Spring Boot
  6. Testing Web Layers
  7. Database Testing
  8. Testing Security
  9. Advanced Testing Patterns
  10. Best Practices and Common Pitfalls
  11. Real-World Example: Building a REST API with TDD

Introduction to Test Driven Development

Test Driven Development (TDD) is a software development methodology where you write tests before writing the actual code. This approach ensures that your code is thoroughly tested and meets the specified requirements from the very beginning.

Why TDD with Spring Boot?

Spring Boot provides excellent testing support with powerful annotations, test slices, and integration capabilities. Combined with TDD, it enables you to:

  • Build robust, maintainable applications
  • Catch bugs early in development
  • Improve code design through testability
  • Ensure comprehensive test coverage
  • Facilitate refactoring with confidence

TDD Fundamentals

The Red-Green-Refactor Cycle

TDD follows a simple three-step cycle:

  1. Red: Write a failing test
  2. Green: Write the minimum code to make the test pass
  3. Refactor: Improve the code while keeping tests green

TDD Principles

  • Write tests first, code second
  • Write only enough code to make tests pass
  • Keep tests simple and focused
  • Refactor continuously
  • Maintain fast feedback loops

Setting Up Your Spring Boot Project

Maven Dependencies

<dependencies>
    <!-- Spring Boot Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Boot Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Spring Boot Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- Testing Dependencies -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Test Containers -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- H2 Database for Testing -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Mockito -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

Project Structure

src/
├── main/
   └── java/
       └── com/example/app/
           ├── Application.java
           ├── controller/
           ├── service/
           ├── repository/
           └── model/
└── test/
    └── java/
        └── com/example/app/
            ├── controller/
            ├── service/
            ├── repository/
            └── integration/
Enter fullscreen mode Exit fullscreen mode

Unit Testing with JUnit 5

Basic Unit Test Structure

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    @DisplayName("Should create user successfully")
    void shouldCreateUserSuccessfully() {
        // Given
        User user = new User("[email protected]", "John Doe");
        when(userRepository.save(any(User.class))).thenReturn(user);

        // When
        User result = userService.createUser("[email protected]", "John Doe");

        // Then
        assertThat(result.getEmail()).isEqualTo("[email protected]");
        assertThat(result.getName()).isEqualTo("John Doe");
        verify(userRepository).save(any(User.class));
    }
}
Enter fullscreen mode Exit fullscreen mode

TDD Example: Building a User Service

Step 1: Red - Write the failing test

@Test
void shouldThroughExceptionWhenEmailAlreadyExists() {
    // Given
    String email = "[email protected]";
    when(userRepository.existsByEmail(email)).thenReturn(true);

    // When & Then
    assertThrows(UserAlreadyExistsException.class, 
        () -> userService.createUser(email, "John Doe"));
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Green - Make the test pass

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(String email, String name) {
        if (userRepository.existsByEmail(email)) {
            throw new UserAlreadyExistsException("User with email already exists");
        }

        User user = new User(email, name);
        return userRepository.save(user);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Refactor - Improve the code

@Service
@Transactional
public class UserService {

    private final UserRepository userRepository;
    private final EmailValidator emailValidator;

    public UserService(UserRepository userRepository, EmailValidator emailValidator) {
        this.userRepository = userRepository;
        this.emailValidator = emailValidator;
    }

    public User createUser(String email, String name) {
        validateInput(email, name);
        checkEmailUniqueness(email);

        User user = User.builder()
            .email(email)
            .name(name)
            .createdAt(LocalDateTime.now())
            .build();

        return userRepository.save(user);
    }

    private void validateInput(String email, String name) {
        if (!emailValidator.isValid(email)) {
            throw new InvalidEmailException("Invalid email format");
        }
        if (StringUtils.isBlank(name)) {
            throw new InvalidNameException("Name cannot be blank");
        }
    }

    private void checkEmailUniqueness(String email) {
        if (userRepository.existsByEmail(email)) {
            throw new UserAlreadyExistsException("User with email already exists");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Integration Testing in Spring Boot

@SpringBootTest for Full Integration Tests

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class TaskIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private TaskRepository taskRepository;

    @BeforeEach
    void setUp() {
        taskRepository.deleteAll();
    }

    @Test
    void shouldCreateAndCompleteTaskEndToEnd() {
        // Create task
        CreateTaskRequest createRequest = new CreateTaskRequest("Integration Test Task", "Description");

        ResponseEntity<TaskResponse> createResponse = restTemplate.postForEntity(
            "/api/tasks", createRequest, TaskResponse.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        TaskResponse createdTask = createResponse.getBody();
        assertThat(createdTask.getTitle()).isEqualTo("Integration Test Task");
        assertThat(createdTask.getStatus()).isEqualTo(TaskStatus.TODO);

        // Complete task
        ResponseEntity<TaskResponse> completeResponse = restTemplate.exchange(
            "/api/tasks/" + createdTask.getId() + "/complete",
            HttpMethod.PATCH,
            null,
            TaskResponse.class);

        assertThat(completeResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        TaskResponse completedTask = completeResponse.getBody();
        assertThat(completedTask.getStatus()).isEqualTo(TaskStatus.DONE);
        assertThat(completedTask.getCompletedAt()).isNotNull();

        // Verify in database
        Optional<Task> dbTask = taskRepository.findById(createdTask.getId());
        assertThat(dbTask).isPresent();
        assertThat(dbTask.get().getStatus()).isEqualTo(TaskStatus.DONE);
    }

    @Test
    void shouldFilterTasksByStatus() {
        // Create tasks with different statuses
        Task todoTask = taskRepository.save(new Task("Todo Task", "Description"));
        Task doneTask = new Task("Done Task", "Description");
        doneTask.setStatus(TaskStatus.DONE);
        doneTask.setCompletedAt(LocalDateTime.now());
        taskRepository.save(doneTask);

        // Get TODO tasks
        ResponseEntity<TaskResponse[]> todoResponse = restTemplate.getForEntity(
            "/api/tasks?status=TODO", TaskResponse[].class);

        assertThat(todoResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(todoResponse.getBody()).hasSize(1);
        assertThat(todoResponse.getBody()[0].getTitle()).isEqualTo("Todo Task");

        // Get DONE tasks
        ResponseEntity<TaskResponse[]> doneResponse = restTemplate.getForEntity(
            "/api/tasks?status=DONE", TaskResponse[].class);

        assertThat(doneResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(doneResponse.getBody()).hasSize(1);
        assertThat(doneResponse.getBody()[0].getTitle()).isEqualTo("Done Task");
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Performance Testing

@SpringBootTest
class TaskServicePerformanceTest {

    @Autowired
    private TaskService taskService;

    @Autowired
    private TaskRepository taskRepository;

    @Test
    void shouldHandleLargeNumberOfTasks() {
        // Create 1000 tasks
        List<Task> tasks = IntStream.range(0, 1000)
            .mapToObj(i -> new Task("Task " + i, "Description " + i))
            .collect(Collectors.toList());

        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        taskRepository.saveAll(tasks);

        stopWatch.stop();

        assertThat(stopWatch.getTotalTimeMillis()).isLessThan(5000); // 5 seconds
        assertThat(taskRepository.count()).isEqualTo(1000);
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Testing Techniques

Testing Async Operations

@Service
public class NotificationService {

    @Async
    public CompletableFuture<Void> sendTaskCompletionNotification(Task task) {
        // Simulate email sending
        try {
            Thread.sleep(1000);
            emailService.sendEmail(task.getAssignee().getEmail(), 
                "Task Completed", "Your task has been completed");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return CompletableFuture.completedFuture(null);
    }
}

// Testing async operations
@SpringBootTest
@EnableAsync
class NotificationServiceTest {

    @Autowired
    private NotificationService notificationService;

    @MockBean
    private EmailService emailService;

    @Test
    void shouldSendNotificationAsynchronously() throws Exception {
        // Given
        Task task = TaskTestDataBuilder.aTask()
            .withAssignee(new User("[email protected]", "User"))
            .build();

        // When
        CompletableFuture<Void> future = notificationService.sendTaskCompletionNotification(task);

        // Then
        assertThat(future).succeedsWithin(Duration.ofSeconds(2));

        verify(emailService, timeout(2000)).sendEmail(
            eq("[email protected]"),
            eq("Task Completed"),
            anyString()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Caching

@Service
public class TaskService {

    @Cacheable(value = "tasks", key = "#id")
    public Task findById(Long id) {
        return taskRepository.findById(id)
            .orElseThrow(() -> new TaskNotFoundException("Task not found"));
    }

    @CacheEvict(value = "tasks", key = "#task.id")
    public Task updateTask(Task task) {
        return taskRepository.save(task);
    }
}

// Testing caching
@SpringBootTest
@EnableCaching
class TaskServiceCacheTest {

    @Autowired
    private TaskService taskService;

    @MockBean
    private TaskRepository taskRepository;

    @Autowired
    private CacheManager cacheManager;

    @Test
    void shouldCacheTaskById() {
        // Given
        Task task = new Task("Cached Task", "Description");
        task.setId(1L);
        when(taskRepository.findById(1L)).thenReturn(Optional.of(task));

        // When - First call
        Task result1 = taskService.findById(1L);

        // When - Second call
        Task result2 = taskService.findById(1L);

        // Then
        assertThat(result1).isEqualTo(result2);
        verify(taskRepository, times(1)).findById(1L); // Repository called only once

        // Verify cache contains the task
        Cache cache = cacheManager.getCache("tasks");
        assertThat(cache.get(1L)).isNotNull();
    }

    @Test
    void shouldEvictCacheOnUpdate() {
        // Given
        Task task = new Task("Task", "Description");
        task.setId(1L);
        when(taskRepository.findById(1L)).thenReturn(Optional.of(task));
        when(taskRepository.save(any(Task.class))).thenReturn(task);

        // Prime the cache
        taskService.findById(1L);

        // When
        taskService.updateTask(task);

        // Then
        Cache cache = cacheManager.getCache("tasks");
        assertThat(cache.get(1L)).isNull();
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Event Publishing

@Service
public class TaskService {

    private final ApplicationEventPublisher eventPublisher;

    public Task completeTask(Long id) {
        Task task = findById(id);
        task.setStatus(TaskStatus.DONE);
        task.setCompletedAt(LocalDateTime.now());

        Task savedTask = taskRepository.save(task);

        // Publish event
        eventPublisher.publishEvent(new TaskCompletedEvent(savedTask));

        return savedTask;
    }
}

@Component
public class TaskEventListener {

    @EventListener
    public void handleTaskCompleted(TaskCompletedEvent event) {
        // Handle task completion logic
        Task task = event.getTask();
        // Update statistics, send notifications, etc.
    }
}

// Testing events
@SpringBootTest
class TaskEventTest {

    @Autowired
    private TaskService taskService;

    @MockBean
    private TaskRepository taskRepository;

    @MockBean
    private TaskEventListener eventListener;

    @Test
    void shouldPublishEventWhenTaskCompleted() {
        // Given
        Task task = new Task("Test Task", "Description");
        task.setId(1L);
        when(taskRepository.findById(1L)).thenReturn(Optional.of(task));
        when(taskRepository.save(any(Task.class))).thenAnswer(invocation -> {
            Task t = invocation.getArgument(0);
            t.setStatus(TaskStatus.DONE);
            return t;
        });

        // When
        taskService.completeTask(1L);

        // Then
        verify(eventListener, timeout(1000)).handleTaskCompleted(any(TaskCompletedEvent.class));
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Configuration and Profiles

Application Properties for Testing

# application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# Disable security for testing
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration

# Email configuration for testing
app.email.enabled=false
app.notification.async=false

logging.level.org.springframework.web=DEBUG
logging.level.org.hibernate.SQL=DEBUG
Enter fullscreen mode Exit fullscreen mode

Profile-specific Testing

@ActiveProfiles("test")
@SpringBootTest
class ProfileSpecificTest {

    @Value("${app.email.enabled}")
    private boolean emailEnabled;

    @Test
    void shouldDisableEmailInTestProfile() {
        assertThat(emailEnabled).isFalse();
    }
}
Enter fullscreen mode Exit fullscreen mode

Monitoring and Metrics Testing

@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final MeterRegistry meterRegistry;
    private final Counter taskCreationCounter;

    public TaskController(TaskService taskService, MeterRegistry meterRegistry) {
        this.taskService = taskService;
        this.meterRegistry = meterRegistry;
        this.taskCreationCounter = Counter.builder("tasks.created")
            .description("Number of tasks created")
            .register(meterRegistry);
    }

    @PostMapping
    public ResponseEntity<TaskResponse> createTask(@Valid @RequestBody CreateTaskRequest request) {
        Task task = taskService.createTask(request.getTitle(), request.getDescription());
        taskCreationCounter.increment();
        return ResponseEntity.status(HttpStatus.CREATED).body(TaskResponse.from(task));
    }
}

// Testing metrics
@SpringBootTest
@AutoConfigureMetrics
class TaskMetricsTest {

    @Autowired
    private TaskController taskController;

    @Autowired
    private MeterRegistry meterRegistry;

    @Test
    void shouldIncrementTaskCreationCounter() {
        // Given
        CreateTaskRequest request = new CreateTaskRequest("Test Task", "Description");

        // When
        taskController.createTask(request);

        // Then
        Counter counter = meterRegistry.find("tasks.created").counter();
        assertThat(counter).isNotNull();
        assertThat(counter.count()).isEqualTo(1.0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Error Handling and Exception Testing

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleTaskNotFound(TaskNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            "TASK_NOT_FOUND",
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(ValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
        ErrorResponse error = new ErrorResponse(
            "VALIDATION_ERROR",
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

// Testing exception handling
@WebMvcTest(TaskController.class)
class TaskControllerExceptionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private TaskService taskService;

    @Test
    void shouldReturnNotFoundWhenTaskDoesNotExist() throws Exception {
        // Given
        when(taskService.findById(999L)).thenThrow(new TaskNotFoundException("Task not found with id: 999"));

        // When & Then
        mockMvc.perform(get("/api/tasks/999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"))
                .andExpect(jsonPath("$.message").value("Task not found with id: 999"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Test Driven Development with Spring Boot empowers you to build robust, maintainable applications with confidence. By following TDD principles and leveraging Spring Boot's comprehensive testing features, you can:

  • Ensure high code quality and test coverage
  • Catch bugs early in the development cycle
  • Design better APIs through test-first thinking
  • Refactor code safely with comprehensive test suites
  • Build applications that are easier to maintain and extend

Key Takeaways

  1. Start with Tests: Always write tests before implementing functionality
  2. Use Spring Boot Test Slices: Leverage @WebMvcTest, @DataJpaTest, etc., for focused testing
  3. Mock External Dependencies: Use @MockBean and Mockito for isolation
  4. Test All Layers: Unit tests for business logic, integration tests for component interaction
  5. Follow Best Practices: Use descriptive test names, AAA pattern, and test data builders
  6. Avoid Common Pitfalls: Don't test implementation details, keep tests maintainable
  7. Embrace Continuous Refactoring: Use your test suite as a safety net for improvements

Remember, TDD is not just about testing—it's a design methodology that leads to better software architecture, cleaner code, and more maintainable applications. Start small, practice regularly, and gradually build your TDD skills with Spring Boot.

Additional Resources

Happy testing and may your code be bug-free and your deployments successful!RANDOM_PORT)

Top comments (0)