Testing Strategies
Problem
Untested code leads to bugs, regressions, and fear of refactoring. Manual testing is slow, error-prone, and doesn’t scale. Poor test organization makes tests hard to maintain and understand.
// Problematic approach - no tests
public class UserService {
public User createUser(String username, String email) {
// Complex logic - no verification it works
// Bugs discovered in production
return new User(username, email);
}
}This guide shows practical techniques for implementing comprehensive testing strategies with JUnit 5 and Mockito.
Solution
1. JUnit 5 Basics
Write unit tests with JUnit 5 (Jupiter).
Setup (Maven dependencies):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>Basic test structure:
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeAll
static void setupAll() {
System.out.println("Run once before all tests");
}
@BeforeEach
void setup() {
calculator = new Calculator();
System.out.println("Run before each test");
}
@Test
void addShouldReturnSumOfTwoNumbers() {
// Arrange
int a = 5;
int b = 3;
// Act
int result = calculator.add(a, b);
// Assert
assertEquals(8, result, "5 + 3 should equal 8");
}
@Test
void divideShouldThrowExceptionWhenDividingByZero() {
assertThrows(ArithmeticException.class, () -> {
calculator.divide(10, 0);
});
}
@Test
@DisplayName("Multiply should handle negative numbers correctly")
void multiplyShouldHandleNegativeNumbers() {
assertEquals(-15, calculator.multiply(-5, 3));
assertEquals(-15, calculator.multiply(5, -3));
assertEquals(15, calculator.multiply(-5, -3));
}
@Test
@Disabled("Not implemented yet")
void testFutureFeature() {
// Skipped during test execution
}
@AfterEach
void tearDown() {
calculator = null;
System.out.println("Run after each test");
}
@AfterAll
static void tearDownAll() {
System.out.println("Run once after all tests");
}
}2. Parameterized Tests
Test multiple inputs efficiently with parameterized tests.
Parameterized test patterns:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
class ParameterizedTestExamples {
// ValueSource - simple values
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testPositiveNumbers(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void testBlankStrings(String input) {
assertTrue(input.isBlank());
}
// CsvSource - multiple parameters
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"5, 7, 12",
"100, 200, 300"
})
void testAddition(int a, int b, int expected) {
Calculator calc = new Calculator();
assertEquals(expected, calc.add(a, b));
}
// MethodSource - complex objects
@ParameterizedTest
@MethodSource("provideUsersForValidation")
void testUserValidation(User user, boolean expectedValid) {
UserValidator validator = new UserValidator();
assertEquals(expectedValid, validator.isValid(user));
}
static Stream<Arguments> provideUsersForValidation() {
return Stream.of(
Arguments.of(new User("john", "john@example.com"), true),
Arguments.of(new User("", "john@example.com"), false),
Arguments.of(new User("john", "invalid"), false),
Arguments.of(new User(null, "john@example.com"), false)
);
}
// EnumSource - test all enum values
@ParameterizedTest
@EnumSource(DayOfWeek.class)
void testAllDaysOfWeek(DayOfWeek day) {
assertNotNull(day);
assertTrue(day.toString().length() > 0);
}
// NullAndEmptySource
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void testNullEmptyAndBlank(String input) {
assertTrue(input == null || input.isBlank());
}
// Custom display names
@ParameterizedTest(name = "[{index}] {0} + {1} = {2}")
@CsvSource({"1,1,2", "2,3,5", "5,7,12"})
void testWithCustomDisplayName(int a, int b, int expected) {
assertEquals(expected, a + b);
}
}3. Mocking with Mockito
Isolate units under test by mocking dependencies.
Mockito basics:
import org.mockito.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void createUserShouldSaveAndSendEmail() {
// Arrange
User user = new User("john", "john@example.com");
when(userRepository.save(any(User.class))).thenReturn(user);
// Act
User result = userService.createUser("john", "john@example.com");
// Assert
assertNotNull(result);
verify(userRepository).save(any(User.class));
verify(emailService).sendWelcomeEmail(user);
}
@Test
void getUserShouldReturnUserFromRepository() {
// Arrange
Long userId = 1L;
User mockUser = new User("john", "john@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser));
// Act
User result = userService.getUser(userId);
// Assert
assertEquals(mockUser, result);
verify(userRepository).findById(userId);
}
@Test
void getUserShouldThrowExceptionWhenNotFound() {
// Arrange
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
userService.getUser(userId);
});
}
@Test
void testArgumentCaptor() {
// Arrange
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
User user = new User("john", "john@example.com");
when(userRepository.save(any(User.class))).thenReturn(user);
// Act
userService.createUser("john", "john@example.com");
// Assert - capture and verify saved user
verify(userRepository).save(userCaptor.capture());
User capturedUser = userCaptor.getValue();
assertEquals("john", capturedUser.getUsername());
assertEquals("john@example.com", capturedUser.getEmail());
}
@Test
void testMultipleInvocations() {
// Arrange
User user1 = new User("user1", "user1@example.com");
User user2 = new User("user2", "user2@example.com");
// Act
userService.processUsers(List.of(user1, user2));
// Assert
verify(emailService, times(2)).sendWelcomeEmail(any(User.class));
verify(emailService).sendWelcomeEmail(user1);
verify(emailService).sendWelcomeEmail(user2);
}
@Test
void testVoidMethod() {
// Arrange
doNothing().when(emailService).sendWelcomeEmail(any(User.class));
// Or verify exception thrown
doThrow(new EmailException("SMTP error"))
.when(emailService).sendWelcomeEmail(any(User.class));
// Act & Assert
assertThrows(EmailException.class, () -> {
emailService.sendWelcomeEmail(new User("john", "john@example.com"));
});
}
}4. Test Organization and Best Practices
Organize tests for maintainability and clarity.
Test organization strategies:
import org.junit.jupiter.api.*;
// Nested test classes for logical grouping
@DisplayName("User Service")
class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
@BeforeEach
void setup() {
userRepository = mock(UserRepository.class);
userService = new UserService(userRepository);
}
@Nested
@DisplayName("When creating user")
class CreateUserTests {
@Test
@DisplayName("should save user to repository")
void shouldSaveUserToRepository() {
User user = new User("john", "john@example.com");
when(userRepository.save(any(User.class))).thenReturn(user);
userService.createUser("john", "john@example.com");
verify(userRepository).save(any(User.class));
}
@Test
@DisplayName("should throw exception for blank username")
void shouldThrowExceptionForBlankUsername() {
assertThrows(ValidationException.class, () -> {
userService.createUser("", "john@example.com");
});
}
@Test
@DisplayName("should throw exception for invalid email")
void shouldThrowExceptionForInvalidEmail() {
assertThrows(ValidationException.class, () -> {
userService.createUser("john", "invalid-email");
});
}
}
@Nested
@DisplayName("When updating user")
class UpdateUserTests {
@Test
@DisplayName("should update existing user")
void shouldUpdateExistingUser() {
Long userId = 1L;
User existingUser = new User("john", "john@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(existingUser));
userService.updateUser(userId, "john_doe", "john.doe@example.com");
verify(userRepository).save(argThat(user ->
user.getUsername().equals("john_doe") &&
user.getEmail().equals("john.doe@example.com")
));
}
@Test
@DisplayName("should throw exception when user not found")
void shouldThrowExceptionWhenUserNotFound() {
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> {
userService.updateUser(userId, "john", "john@example.com");
});
}
}
@Nested
@DisplayName("When deleting user")
class DeleteUserTests {
@Test
@DisplayName("should delete existing user")
void shouldDeleteExistingUser() {
Long userId = 1L;
when(userRepository.existsById(userId)).thenReturn(true);
userService.deleteUser(userId);
verify(userRepository).deleteById(userId);
}
@Test
@DisplayName("should throw exception when user not found")
void shouldThrowExceptionWhenUserNotFound() {
Long userId = 999L;
when(userRepository.existsById(userId)).thenReturn(false);
assertThrows(ResourceNotFoundException.class, () -> {
userService.deleteUser(userId);
});
}
}
}5. Test-Driven Development (TDD)
Write tests before implementation.
TDD cycle (Red-Green-Refactor):
// Step 1: Write failing test (RED)
@Test
void calculateDiscountShouldApply10PercentForVipCustomers() {
PricingService pricing = new PricingService();
Customer vipCustomer = new Customer("VIP", CustomerType.VIP);
double price = pricing.calculateDiscount(100.0, vipCustomer);
assertEquals(90.0, price); // 10% discount
}
// Test fails - calculateDiscount doesn't exist yet
// Step 2: Write minimal code to pass (GREEN)
public class PricingService {
public double calculateDiscount(double price, Customer customer) {
if (customer.getType() == CustomerType.VIP) {
return price * 0.9; // 10% discount
}
return price;
}
}
// Test passes
// Step 3: Refactor (REFACTOR)
public class PricingService {
private static final double VIP_DISCOUNT = 0.10;
private static final double REGULAR_DISCOUNT = 0.0;
public double calculateDiscount(double price, Customer customer) {
double discountRate = getDiscountRate(customer.getType());
return price * (1 - discountRate);
}
private double getDiscountRate(CustomerType type) {
return switch (type) {
case VIP -> VIP_DISCOUNT;
case REGULAR -> REGULAR_DISCOUNT;
};
}
}
// Test still passes, code cleaner
// Step 4: Add more tests
@Test
void calculateDiscountShouldApplyNoDiscountForRegularCustomers() {
PricingService pricing = new PricingService();
Customer regularCustomer = new Customer("Regular", CustomerType.REGULAR);
double price = pricing.calculateDiscount(100.0, regularCustomer);
assertEquals(100.0, price); // No discount
}
// Continue TDD cycle for all requirementsHow It Works
Test Pyramid
graph TD
A[End-to-End Tests] --> B[Integration Tests]
B --> C[Unit Tests]
D[Few E2E] --> A
E[Some Integration] --> B
F[Many Unit Tests] --> C
G[Slow, Brittle] --> A
H[Moderate Speed] --> B
I[Fast, Reliable] --> C
style A fill:#0173B2,stroke:#000000,color:#FFFFFF
style B fill:#DE8F05,stroke:#000000,color:#FFFFFF
style C fill:#029E73,stroke:#000000,color:#FFFFFF
%% Color palette: Blue (#0173B2), Orange (#DE8F05), Teal (#029E73)
%% Blue = E2E, Orange = Integration, Teal = Unit
Key concepts:
- Unit Tests: Test individual methods/classes in isolation (70-80% of tests)
- Integration Tests: Test interactions between components (15-25% of tests)
- End-to-End Tests: Test complete user workflows (5-10% of tests)
- Test Pyramid: Many fast unit tests, fewer slow integration/E2E tests
- Arrange-Act-Assert: Structure tests clearly (setup, execute, verify)
Test Lifecycle
JUnit 5 test lifecycle hooks:
- @BeforeAll: Run once before all tests (static method)
- @BeforeEach: Run before each test method
- @Test: Individual test case
- @AfterEach: Run after each test method
- @AfterAll: Run once after all tests (static method)
Variations
Spring Boot Testing
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.beans.factory.annotation.Autowired;
@SpringBootTest
@ExtendWith(SpringExtension.class)
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void integrationTestWithRealDatabase() {
User user = userService.createUser("john", "john@example.com");
User found = userRepository.findById(user.getId()).orElseThrow();
assertEquals("john", found.getUsername());
}
}
// Web layer testing
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void getUserShouldReturnUser() throws Exception {
User user = new User(1L, "john", "john@example.com");
when(userService.getUser(1L)).thenReturn(user);
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("john"));
}
}AssertJ Fluent Assertions
import static org.assertj.core.api.Assertions.*;
@Test
void testWithAssertJ() {
User user = new User("john", "john@example.com");
assertThat(user.getUsername())
.isNotNull()
.isEqualTo("john")
.startsWith("jo")
.hasSize(4);
assertThat(user.getEmail())
.contains("@")
.endsWith(".com");
List<User> users = List.of(user);
assertThat(users)
.hasSize(1)
.contains(user)
.extracting(User::getUsername)
.contains("john");
}Test Containers
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class DatabaseIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Test
void testWithRealDatabase() {
// Test with real PostgreSQL container
String jdbcUrl = postgres.getJdbcUrl();
// Connect and test
}
}Common Pitfalls
Pitfall 1: Testing Implementation Details
Test behavior, not implementation:
// Bad: Testing implementation
@Test
void testInternalCachingLogic() {
userService.getUser(1L);
verify(userService.cache).put(1L, any()); // Testing internal cache
}
// Good: Testing behavior
@Test
void getUserShouldReturnSameUserOnSecondCall() {
User user1 = userService.getUser(1L);
User user2 = userService.getUser(1L);
assertSame(user1, user2); // Behavior: returns same instance
}Pitfall 2: Fragile Tests
Avoid tests that break on minor changes:
// Bad: Hard-coded values
@Test
void testToString() {
User user = new User("john", "john@example.com");
assertEquals("User[name=john, email=john@example.com]", user.toString());
// Breaks if toString format changes
}
// Good: Test essential properties
@Test
void testToString() {
User user = new User("john", "john@example.com");
String result = user.toString();
assertThat(result).contains("john", "john@example.com");
}Pitfall 3: Test Interdependence
Tests should be independent:
// Bad: Tests depend on execution order
static User globalUser;
@Test
void test1_createUser() {
globalUser = userService.createUser("john", "john@example.com");
}
@Test
void test2_updateUser() {
userService.updateUser(globalUser.getId(), "john_doe", "..."); // Depends on test1
}
// Good: Independent tests
@Test
void createUserShouldSaveToDatabase() {
User user = userService.createUser("john", "john@example.com");
assertNotNull(user.getId());
}
@Test
void updateUserShouldModifyExistingUser() {
User user = userService.createUser("john", "john@example.com");
userService.updateUser(user.getId(), "john_doe", "...");
// Each test self-contained
}Pitfall 4: Ignoring Test Failures
Never ignore or disable failing tests:
// Bad: Disabling failing test
@Test
@Disabled("Fails sometimes") // Technical debt!
void flakyTest() {
// Fix instead of disabling
}
// Good: Fix flaky tests
@Test
void reliableTest() {
// Deterministic test
// No timing dependencies
// No shared state
}Related Patterns
Related Tutorial: See Intermediate Tutorial - Testing for testing fundamentals and Advanced Tutorial - TDD for test-driven development.
Related How-To: See Write Effective Tests for test best practices and Dependency Injection with Spring for testable dependency management.
Related Cookbook: See Cookbook recipes “JUnit 5 Test Templates”, “Mockito Patterns”, and “Test Organization Strategies” for copy-paste ready testing implementations.
Related Explanation: See Best Practices - Testing for testing principles.
Further Reading
- JUnit 5 User Guide - Official JUnit 5 documentation
- Mockito Documentation - Mockito API reference
- Test-Driven Development - Kent Beck’s TDD book
- Spring Boot Testing - Spring Boot test support