Why Testing Matters
Automated testing is essential for maintaining code quality, catching bugs early, and enabling confident refactoring. Well-tested code is easier to maintain and extend.
- Bug Prevention - Catch issues before they reach production
- Documentation - Tests show how code should be used
- Refactoring Safety - Change code with confidence
- Design Feedback - Hard-to-test code often has design issues
- Regression Protection - Ensure fixes don't break existing features
Testing Pyramid
// Testing Pyramid (bottom to top)
/\
/ \ E2E Tests (few)
/----\ - Full system tests
/ \ - Slowest, most expensive
/--------\
/ \ Integration Tests (some)
/------------\ - Test component interactions
/ \ - Databases, APIs, services
/----------------\
/ \ Unit Tests (many)
- Test individual classes/methods
- Fastest, cheapest, most numerous
JUnit 5
JUnit 5 (Jupiter) is the current standard for Java unit testing. It provides powerful features for writing and organizing tests.
Maven Dependencies
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<!-- For Spring Boot projects -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Basic Test Structure
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("Should add two positive numbers")
void addPositiveNumbers() {
// Given
int a = 5;
int b = 3;
// When
int result = calculator.add(a, b);
// Then
assertEquals(8, result);
}
@Test
@DisplayName("Should throw exception when dividing by zero")
void divideByZeroThrowsException() {
assertThrows(ArithmeticException.class, () -> {
calculator.divide(10, 0);
});
}
@AfterEach
void tearDown() {
calculator = null;
}
}
Common Assertions
import static org.junit.jupiter.api.Assertions.*;
// Equality
assertEquals(expected, actual);
assertEquals(expected, actual, "Custom failure message");
assertNotEquals(unexpected, actual);
// Boolean
assertTrue(condition);
assertFalse(condition);
// Null checks
assertNull(object);
assertNotNull(object);
// Same reference
assertSame(expected, actual);
assertNotSame(unexpected, actual);
// Arrays
assertArrayEquals(expectedArray, actualArray);
// Exceptions
assertThrows(IllegalArgumentException.class, () -> service.process(null));
Exception ex = assertThrows(CustomException.class, () -> service.run());
assertEquals("Expected message", ex.getMessage());
// Timeout
assertTimeout(Duration.ofSeconds(2), () -> slowOperation());
// Multiple assertions (all run, reports all failures)
assertAll("User properties",
() -> assertEquals("John", user.getName()),
() -> assertEquals(30, user.getAge()),
() -> assertNotNull(user.getEmail())
);
Test Lifecycle Annotations
class LifecycleTest {
@BeforeAll
static void initAll() {
// Runs once before all tests
// Must be static (unless using @TestInstance(PER_CLASS))
}
@BeforeEach
void init() {
// Runs before each test
}
@Test
void testMethod() {
// Test code
}
@AfterEach
void tearDown() {
// Runs after each test
}
@AfterAll
static void tearDownAll() {
// Runs once after all tests
}
@Test
@Disabled("Not implemented yet")
void skippedTest() {
// This test is skipped
}
}
Parameterized Tests
Run the same test with different inputs to reduce duplication and improve coverage.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ParameterizedTests {
// Value source - simple values
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testPositiveNumbers(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@ValueSource(strings = {"hello", "world", "junit"})
void testStringsNotEmpty(String str) {
assertFalse(str.isEmpty());
}
// Null and empty sources
@ParameterizedTest
@NullSource
@EmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void testNullEmptyAndBlank(String text) {
assertTrue(text == null || text.isBlank());
}
// CSV source - multiple arguments
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"5, 5, 10",
"-1, 1, 0"
})
void testAddition(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
// Method source - complex objects
@ParameterizedTest
@MethodSource("provideUsers")
void testUserValidation(User user, boolean expected) {
assertEquals(expected, validator.isValid(user));
}
static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("John", 25), true),
Arguments.of(new User("", 25), false),
Arguments.of(new User("Jane", -1), false)
);
}
// Enum source
@ParameterizedTest
@EnumSource(DayOfWeek.class)
void testAllDaysOfWeek(DayOfWeek day) {
assertNotNull(day);
}
}
Mockito
Mockito is the most popular mocking framework for Java. It allows you to create mock objects and verify interactions in unit tests.
Maven Dependency
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5 integration -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
Creating Mocks
import org.mockito.Mock;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void findUserById() {
// Given - define mock behavior
User mockUser = new User(1L, "John");
when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
// When - call the method under test
User result = userService.findById(1L);
// Then - verify the result
assertEquals("John", result.getName());
verify(userRepository).findById(1L);
}
}
Stubbing Methods
// Return a value
when(repository.findById(1L)).thenReturn(Optional.of(user));
// Return different values on consecutive calls
when(service.getNext())
.thenReturn("first")
.thenReturn("second")
.thenReturn("third");
// Throw an exception
when(service.process(null)).thenThrow(new IllegalArgumentException());
// Use argument matchers
when(repository.findByName(anyString())).thenReturn(user);
when(repository.findByAge(eq(25))).thenReturn(users);
// Answer - dynamic response based on input
when(repository.save(any(User.class))).thenAnswer(invocation -> {
User u = invocation.getArgument(0);
u.setId(100L);
return u;
});
// Void methods
doNothing().when(emailService).sendEmail(anyString());
doThrow(new RuntimeException()).when(service).dangerousMethod();
Verification
// Verify method was called
verify(repository).save(user);
// Verify with specific arguments
verify(repository).findById(1L);
// Verify call count
verify(repository, times(2)).findAll();
verify(repository, never()).delete(any());
verify(repository, atLeast(1)).findById(anyLong());
verify(repository, atMost(3)).save(any());
// Verify order of calls
InOrder inOrder = inOrder(repository, emailService);
inOrder.verify(repository).save(user);
inOrder.verify(emailService).sendWelcomeEmail(user);
// Verify no more interactions
verifyNoMoreInteractions(repository);
// Argument captor - capture and inspect arguments
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(repository).save(captor.capture());
User savedUser = captor.getValue();
assertEquals("John", savedUser.getName());
Testing Best Practices
- Follow AAA pattern - Arrange, Act, Assert
- Test one thing per test - Single assertion focus
- Use descriptive names - should_returnNull_when_userNotFound
- Keep tests independent - No shared state between tests
- Don't test implementation - Test behavior, not internals
- Make tests fast - Unit tests should run in milliseconds
Test Naming Conventions
// Option 1: methodName_stateUnderTest_expectedBehavior
void findById_userExists_returnsUser() { }
void findById_userNotFound_throwsException() { }
// Option 2: should_expectedBehavior_when_stateUnderTest
void should_returnUser_when_userExists() { }
void should_throwException_when_userNotFound() { }
// Option 3: Given-When-Then with @DisplayName
@Test
@DisplayName("Given existing user, when findById called, then return user")
void testFindById() { }
Test Structure Example
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@Mock
private InventoryService inventoryService;
@InjectMocks
private OrderService orderService;
@Nested
@DisplayName("When creating order")
class CreateOrder {
@Test
@DisplayName("should create order successfully")
void createOrderSuccess() {
// Arrange
OrderRequest request = new OrderRequest("item-1", 2);
when(inventoryService.checkAvailability("item-1", 2)).thenReturn(true);
when(paymentService.process(any())).thenReturn(PaymentResult.success());
// Act
Order result = orderService.createOrder(request);
// Assert
assertNotNull(result);
assertEquals(OrderStatus.CREATED, result.getStatus());
verify(orderRepository).save(any(Order.class));
}
@Test
@DisplayName("should throw exception when item not available")
void createOrderItemNotAvailable() {
// Arrange
OrderRequest request = new OrderRequest("item-1", 100);
when(inventoryService.checkAvailability("item-1", 100)).thenReturn(false);
// Act & Assert
assertThrows(InsufficientInventoryException.class,
() -> orderService.createOrder(request));
verify(paymentService, never()).process(any());
}
}
}
Integration Testing
Spring Boot Integration Tests
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void createUser_validRequest_returnsCreated() throws Exception {
UserDTO user = new UserDTO("John", "john@example.com");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("John"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
@Test
void getUser_notFound_returns404() throws Exception {
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound());
}
}
Database Integration Tests
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findByEmail_userExists_returnsUser() {
// Given
User user = new User("John", "john@test.com");
entityManager.persistAndFlush(user);
// When
Optional<User> found = userRepository.findByEmail("john@test.com");
// Then
assertTrue(found.isPresent());
assertEquals("John", found.get().getName());
}
}
Test Coverage
JaCoCo is the standard tool for measuring test coverage in Java projects.
Maven JaCoCo Configuration
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
High coverage doesn't guarantee good tests. Focus on testing important behavior, edge cases, and error conditions rather than chasing a coverage percentage.