Writing JUnit 5 Tests with Mockito

Writing JUnit 5 Tests with Mockito

In modern Java development, writing high-quality unit tests is the cornerstone of ensuring software stability. As a next-generation testing framework, JUnit 5 offers a clearer API and a richer plugin ecosystem, while Mockito remains one of the most popular frameworks for handling object behavior verification in the industry. This article guides developers on integrating Mockito into JUnit 5 projects to build efficient and reliable test cases.

First, ensure your project dependencies are correctly configured. In a Maven project, you need to introduce both the JUnit 5 engine and the Mockito library. By adding the following dependencies to pom.xml, you can kickstart your testing journey:

<dependencies>
    <!-- Core JUnit 5 Library -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
    
    <!-- JUnit 5 Engine -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>

    <!-- Core Mockito Library -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.3.1</version>
        <scope>test</scope>
    </dependency>
    
    <!-- Mockito Extension Support (Optional, for static type checking) -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.3.1</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Once configured, writing a test class simply requires adhering to the standard JUnit 5 conventions. Test classes should inherit AbstractTest or AbstractExtensionTest (depending on the specific scenario), but it is more recommended to use an annotation-driven approach directly. Through annotations such as @Test, @BeforeEach, and @AfterEach, you can clearly organize test logic and lifecycle management.
The core objective in unit testing is to isolate dependencies of the component under test (UT), making its behavior predictable and controllable. Mockito provides powerful tools for creating these mock objects.

When your business class depends on other services, such as a UserService depending on an OrderService, you need to use the mock() method to create a virtual instance of OrderService. This instance does not execute real business logic but responds to calls based on rules set by you.

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.List;

import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    private final UserService userService = new UserService();
    private final OrderService orderServiceMock = mock(OrderService.class);

    @BeforeEach
    void setUp() {
        // Set Mock behavior: return a specific order when createOrder is called
        when(orderServiceMock.createOrder(any())).thenReturn(new Order("ORD-123", "John"));
        
        // Verify if the method was called (optional, for assertions)
        verify(orderServiceMock, times(1)).createOrder(any());
    }

    @Test
    void testCreateUserWithValidData() {
        String name = "Alice";
        int age = 25;

        // Execute test logic
        User user = userService.createUser(name, age);

        // Assert results
        assert user.getName().equals("Alice");
        assert user.getAge() == 25;
    }
}

In the code above, mock(OrderService.class) creates a fully controlled object. Through the when(...).thenReturn(...) pattern, we define the return value of this Mock object when faced with specific inputs. This mechanism allows tests to focus on the logic of the component under test (UserService) without interference from external system behaviors.

Verifying Method Calls and Interaction Behavior

Setting return values alone is insufficient; in complex business scenarios, we need to ensure that the code under test correctly calls expected dependency methods. Mockito provides the verify() method to check invocation counts, parameters, and call order of methods.

Assume your UserService must first create an order via OrderService before saving user information when processing a user registration. We can verify this flow's correctness as follows:

    @Test
    void testCreateUserTriggersOrderCreation() {
        String name = "Bob";
        int age = 30;

        // Execute operation
        userService.createUser(name, age);

        // Assert that OrderService.createOrder method was called once
        verify(orderServiceMock).createOrder(any());
        
        // Stricter assertion: ensure parameters are expected values
        verify(orderServiceMock).createOrder(eq("Bob"));
    }

Using verify() prevents unexpected dependency calls in the code or confirms whether necessary steps were omitted under specific conditions. This is crucial for exposing logical flaws; for example, if business rules require "creating an order first," but actual code skips this step, verify() will throw an exception, quickly revealing the issue.

Handling Complex Scenarios and Advanced Techniques

As project complexity increases, testing requirements become more diverse. Mockito offers various advanced features to handle these scenarios, such as verifying call sequences, simulating error behaviors, and using Spies for partial replacement.

1. Verifying Call Order

If business logic depends on a specific execution order, such as "login first, then create an order," we can enforce the sequence by combining verify(..., atLeastOnce()) with inOrder:

import static org.mockito.Mockito.inOrder;

@Test
void testSequentialExecution() {
    inOrder(orderServiceMock)
        .when(orderServiceMock).login(any());
    inOrder(orderServiceMock)
        .when(orderServiceMock).createOrder(any());
}

2. Simulating Exception Behaviors

When testing error handling logic, you can configure a Mock to throw an exception under specific conditions:

import static org.mockito.Mockito.*;

@Test
void testThrowExceptionOnInvalidInput() {
    when(orderServiceMock.createOrder(any())).thenThrow(new IllegalArgumentException("Invalid ID"));
    
    assertThrows(IllegalArgumentException.class, () -> userService.createUser("Bad", 0));
}

3. Using Spy for Partial Replacement

When you do not want to completely rewrite the behavior of a class but only modify a few methods within it, spy() can be used. A Spy retains the actual behavior of unmodified methods in the original object:

private final UserService spyUserService = spy(new UserService());
// Only mock createOrder; other methods still call real implementations
when(spyUserService.createOrder(any())).thenReturn("Mocked Order");

By flexibly utilizing these techniques, you can build test cases that are both strict and flexible. Remember, excellent unit tests do more than just verify if functionality works; they clearly expose logical defects and potential risks in the code. Combining JUnit 5's clear structure with Mockito's powerful mocking capabilities will significantly enhance development efficiency and code quality.