JUnit 5 Basic Structure
JUnit 5 consists of three modules: JUnit Platform (execution engine), JUnit Jupiter (test API), and JUnit Vintage (backward compatibility). Spring Boot’s spring-boot-starter-test includes both JUnit 5 and Mockito, so you can use them immediately without adding separate dependencies.
If we compare testing to cooking, unit tests are quality checks on individual ingredients, while integration tests verify the taste of the finished dish. Both are necessary.
JUnit 5 Annotations and Basic Tests
// CalculatorTest.java — JUnit 5 basic annotations
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
// Class under test
class Calculator {
int add(int a, int b) { return a + b; }
int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("Cannot divide by zero");
return a / b;
}
}
@DisplayName("Calculator Tests") // Specify test name
class CalculatorTest {
private Calculator calculator;
@BeforeEach // Runs before each test
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("Addition - positive numbers")
void addPositiveNumbers() {
// given
int a = 3, b = 5;
// when
int result = calculator.add(a, b);
// then
assertEquals(8, result, "3 + 5 should equal 8");
}
@Test
@DisplayName("Division - dividing by zero throws exception")
void divideByZeroThrowsException() {
ArithmeticException exception = assertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0)
);
assertEquals("Cannot divide by zero", exception.getMessage());
}
// Parameterized test — test multiple input values at once
@ParameterizedTest
@CsvSource({"1, 1, 2", "0, 0, 0", "-1, 1, 0", "100, 200, 300"})
@DisplayName("Addition - various inputs")
void addVariousInputs(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 5, 10})
@DisplayName("Division - dividing by positive numbers")
void divideByPositive(int divisor) {
assertDoesNotThrow(() -> calculator.divide(100, divisor));
}
// Multiple assertions — remaining assertions continue even if one fails
@Test
@DisplayName("Verify all basic operations")
void assertAllOperations() {
assertAll("Calculator basic operations",
() -> assertEquals(5, calculator.add(2, 3)),
() -> assertEquals(3, calculator.divide(9, 3)),
() -> assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0))
);
}
}
// Output:
// CalculatorTest > Addition - positive numbers PASSED
// CalculatorTest > Division - dividing by zero throws exception PASSED
// CalculatorTest > Addition - various inputs [1] 1, 1, 2 PASSED
// CalculatorTest > Addition - various inputs [2] 0, 0, 0 PASSED
// ... (all PASSED)
Mockito Mock and Spy
// UserServiceTest.java — Using Mockito Mock/Spy
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// Interface and class definitions
interface UserRepository {
Optional<User> findById(Long id);
User save(User user);
}
interface EmailService {
void sendWelcomeEmail(String email);
}
record User(Long id, String name, String email) {}
class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found: " + id));
}
User createUser(String name, String email) {
User user = userRepository.save(new User(null, name, email));
emailService.sendWelcomeEmail(email);
return user;
}
}
@ExtendWith(MockitoExtension.class) // Enable Mockito extension
class UserServiceTest {
@Mock // Fake object — all methods return defaults
UserRepository userRepository;
@Mock
EmailService emailService;
@InjectMocks // Auto-inject mocks to create test target
UserService userService;
@Test
void getUserSuccess() {
// given — Define mock behavior
User mockUser = new User(1L, "John Doe", "john@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
// when — Execute test target
User result = userService.getUser(1L);
// then — Verify result
assertEquals("John Doe", result.name());
assertEquals("john@example.com", result.email());
// Verify call count
verify(userRepository, times(1)).findById(1L);
}
@Test
void getUserFailureThrowsException() {
// given — Return empty Optional
when(userRepository.findById(999L)).thenReturn(Optional.empty());
// when & then
RuntimeException ex = assertThrows(
RuntimeException.class,
() -> userService.getUser(999L)
);
assertTrue(ex.getMessage().contains("999"));
}
@Test
void createUserSendsEmail() {
// given
User savedUser = new User(1L, "Kim Dev", "kim@example.com");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// when
User result = userService.createUser("Kim Dev", "kim@example.com");
// then — Verify return value
assertEquals("Kim Dev", result.name());
// Verify email service was called
verify(emailService).sendWelcomeEmail("kim@example.com");
// Verify call order
var inOrder = inOrder(userRepository, emailService);
inOrder.verify(userRepository).save(any());
inOrder.verify(emailService).sendWelcomeEmail(anyString());
}
}
// Output:
// UserServiceTest > getUserSuccess PASSED
// UserServiceTest > getUserFailureThrowsException PASSED
// UserServiceTest > createUserSendsEmail PASSED
ArgumentCaptor and Spy
// AdvancedMockitoTest.java — ArgumentCaptor, Spy usage
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// Notification service
class NotificationService {
void send(String recipient, String message) {
// In practice, sends email/SMS
System.out.println("Sent to " + recipient + ": " + message);
}
}
class OrderService {
private final NotificationService notificationService;
OrderService(NotificationService notificationService) {
this.notificationService = notificationService;
}
void placeOrder(String userId, String item) {
// Order processing logic ...
notificationService.send(userId, "Order completed: " + item);
}
}
@ExtendWith(MockitoExtension.class)
class AdvancedMockitoTest {
@Mock
NotificationService notificationService;
@InjectMocks
OrderService orderService;
// ArgumentCaptor — Capture arguments passed to a method for verification
@Captor
ArgumentCaptor<String> recipientCaptor;
@Captor
ArgumentCaptor<String> messageCaptor;
@Test
void verifyNotificationMessageOnOrder() {
// when
orderService.placeOrder("user-42", "Keyboard");
// then — Capture passed arguments
verify(notificationService).send(
recipientCaptor.capture(),
messageCaptor.capture()
);
assertEquals("user-42", recipientCaptor.getValue());
assertTrue(messageCaptor.getValue().contains("Keyboard"));
assertTrue(messageCaptor.getValue().contains("Order completed"));
}
// Spy — Wrap a real object and mock only some methods
@Test
void spyVerifiesRealMethodCalls() {
List<String> realList = new ArrayList<>();
List<String> spyList = spy(realList);
// Real methods are called
spyList.add("one");
spyList.add("two");
assertEquals(2, spyList.size()); // Calls real size()
// Stub only a specific method (rest uses real behavior)
doReturn(100).when(spyList).size();
assertEquals(100, spyList.size()); // Returns stubbed value
assertEquals("one", spyList.get(0)); // Returns real value
// Verify with verify
verify(spyList, times(2)).add(anyString());
}
}
// Output:
// AdvancedMockitoTest > verifyNotificationMessageOnOrder PASSED
// AdvancedMockitoTest > spyVerifiesRealMethodCalls PASSED
Spring Boot Integration Test
// TaskControllerIntegrationTest.java — @SpringBootTest integration test
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest // Load full application context
@AutoConfigureMockMvc // Auto-configure MockMvc
class TaskControllerIntegrationTest {
@Autowired
MockMvc mockMvc; // Simulate HTTP requests
@Test
void POST_createTask_returns201() throws Exception {
String requestBody = """
{
"title": "Learn JUnit",
"description": "JUnit 5 and Mockito summary"
}
""";
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated()) // Verify 201
.andExpect(jsonPath("$.title").value("Learn JUnit")) // Verify JSON field
.andExpect(jsonPath("$.completed").value(false));
}
@Test
void POST_noTitle_returns400() throws Exception {
String requestBody = """
{
"title": "",
"description": "Description only"
}
""";
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest()); // Verify 400
}
@Test
void GET_nonExistentTask_returns404() throws Exception {
mockMvc.perform(get("/api/tasks/99999"))
.andExpect(status().isNotFound());
}
}
// Output:
// TaskControllerIntegrationTest > POST_createTask_returns201 PASSED
// TaskControllerIntegrationTest > POST_noTitle_returns400 PASSED
// TaskControllerIntegrationTest > GET_nonExistentTask_returns404 PASSED
Practical Tips
Key principles for improving test code quality.
- Write test names clearly and descriptively. Names like
getUserFailureThrowsExceptionshould make it immediately clear what is being verified - Use the Given-When-Then pattern consistently. Follow the order: preparation (mock setup), execution (call test target), verification (check results)
- Use mocks only for external dependencies. Mocking the class under test itself creates meaningless tests
@Spyis useful for legacy code testing. Use it to stub only some methods of classes that are difficult to refactor- Write integration tests only for core scenarios. Covering every case with integration tests slows them down. Handle boundary values and exception cases with unit tests
- Use
@ParameterizedTestto reduce repetitive tests, andassertAll()to verify multiple conditions in a single test