Table of Contents
- Introduction to Test Driven Development
- TDD Fundamentals
- Setting Up Your Spring Boot Project
- Unit Testing with JUnit 5
- Integration Testing in Spring Boot
- Testing Web Layers
- Database Testing
- Testing Security
- Advanced Testing Patterns
- Best Practices and Common Pitfalls
- 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:
- Red: Write a failing test
- Green: Write the minimum code to make the test pass
- 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>
Project Structure
src/
├── main/
│ └── java/
│ └── com/example/app/
│ ├── Application.java
│ ├── controller/
│ ├── service/
│ ├── repository/
│ └── model/
└── test/
└── java/
└── com/example/app/
├── controller/
├── service/
├── repository/
└── integration/
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));
}
}
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"));
}
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);
}
}
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");
}
}
}
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");
}
}
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);
}
}
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()
);
}
}
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();
}
}
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));
}
}
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
Profile-specific Testing
@ActiveProfiles("test")
@SpringBootTest
class ProfileSpecificTest {
@Value("${app.email.enabled}")
private boolean emailEnabled;
@Test
void shouldDisableEmailInTestProfile() {
assertThat(emailEnabled).isFalse();
}
}
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);
}
}
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"));
}
}
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
- Start with Tests: Always write tests before implementing functionality
-
Use Spring Boot Test Slices: Leverage
@WebMvcTest
,@DataJpaTest
, etc., for focused testing -
Mock External Dependencies: Use
@MockBean
and Mockito for isolation - Test All Layers: Unit tests for business logic, integration tests for component interaction
- Follow Best Practices: Use descriptive test names, AAA pattern, and test data builders
- Avoid Common Pitfalls: Don't test implementation details, keep tests maintainable
- 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
- Spring Boot Testing Documentation
- JUnit 5 User Guide
- Mockito Documentation
- TestContainers Documentation
- AssertJ Documentation
Happy testing and may your code be bug-free and your deployments successful!RANDOM_PORT)
Top comments (0)