JUnit 5 + Mockito Testing Guide — From Unit to Integration

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 getUserFailureThrowsException should 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
  • @Spy is 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 @ParameterizedTest to reduce repetitive tests, and assertAll() to verify multiple conditions in a single test

Was this article helpful?