Mockito Unit Testing: A Complete Guide to Testing Java Service Layers

This article explains unit testing fundamentals, compares unit and integration tests, introduces Mockito's core annotations and APIs, walks through a full order‑resend example with detailed test cases, and covers best‑practice methodologies, advanced techniques, common pitfalls, naming conventions, and how to run the tests.

The Dominant Programmer
The Dominant Programmer
The Dominant Programmer
Mockito Unit Testing: A Complete Guide to Testing Java Service Layers

What is Unit Testing

Unit testing validates the smallest testable unit—usually a single method—by verifying its behavior under various inputs, quickly catching logic errors and providing a safety net for refactoring.

Validate method behavior under different conditions

Detect code logic errors early

Provide a safety net for refactoring

Unit Test vs Integration Test

Scope : single method/class vs multiple components

Dependencies : all mocked vs real (DB, MQ, etc.)

Speed : milliseconds vs seconds or minutes

Environment : no Spring container vs full environment

Use case : verify business logic branches vs verify component interaction

Mockito Core Concepts

Mockito is the most popular Java mock framework. Its core idea is to replace real dependencies with virtual objects so that only the logic of the method under test is exercised.

Key Annotations

import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

public class OrderServiceImplTest {
    @InjectMocks
    private OrderServiceImpl orderService;

    @Mock
    private OrderRepository orderRepository;
    @Mock
    private UserRepository userRepository;
    @Mock
    private MessageSender messageSender;
}

Initialization

// Option 1: manual init in @BeforeEach (recommended, good compatibility)
@BeforeEach
void setUp() {
    MockitoAnnotations.openMocks(this);
}

// Option 2: JUnit5 extension (more concise)
@ExtendWith(MockitoExtension.class)
public class OrderServiceImplTest { /* ... */ }

Mockito Core API

when(...).thenReturn(...)

// Basic usage: when findById(1) is called, return an Order
Order mockOrder = new Order();
mockOrder.setId(1);
mockOrder.setStatus("CREATED");
when(orderRepository.findById(1)).thenReturn(Optional.of(mockOrder));

// Return empty
when(orderRepository.findById(999)).thenReturn(Optional.empty());

// Return a list
List<Order> orderList = Arrays.asList(mockOrder);
when(orderRepository.findByUserId(100)).thenReturn(orderList);

Argument Matchers

import static org.mockito.ArgumentMatchers.*;

when(orderRepository.save(any(Order.class))).thenReturn(mockOrder);
when(orderRepository.findById(anyInt())).thenReturn(Optional.of(mockOrder));
when(userRepository.findByName(anyString())).thenReturn(mockUser);
when(orderRepository.findByUserIdAndStatus(eq(100), anyString()))
    .thenReturn(orderList);
when(orderRepository.save(argThat(order -> order.getStatus().equals("PAID"))))
    .thenReturn(mockOrder);

Important rule : if any argument uses a matcher, all arguments must use matchers.

// Wrong: mixing concrete value and matcher
when(orderRepository.findByUserIdAndStatus(100, anyString()))
    .thenReturn(orderList);

// Correct: use matchers for all arguments
when(orderRepository.findByUserIdAndStatus(eq(100), anyString()))
    .thenReturn(orderList);

when(...).thenThrow(...)

// Simulate exception from repository
when(orderRepository.findById(anyInt()))
    .thenThrow(new RuntimeException("Database connection failed"));

// Simulate exception from void method
doThrow(new RuntimeException("Send failed")).when(messageSender).send(any());

verify(...)

// Verify save called once
verify(orderRepository).save(any(Order.class));

// Verify save called twice
verify(orderRepository, times(2)).save(any(Order.class));

// Verify method never called
verify(messageSender, never()).send(any());

// Verify specific argument content
verify(orderRepository).save(argThat(order -> {
    return order.getStatus().equals("CANCELLED") &&
           order.getUserId().equals(100);
}));

doReturn / doNothing / doThrow

// Void method does nothing
doNothing().when(messageSender).send(any());

// Void method throws
doThrow(new RuntimeException("error")).when(messageSender).send(any());

// Consecutive calls return different values
when(orderRepository.findById(1))
    .thenReturn(Optional.of(order1)) // first call
    .thenReturn(Optional.of(order2)); // second call

Full Example: Order Resend Scenario

Business Code

@Service
public class OrderServiceImpl implements OrderService {
    @Resource
    private OrderRepository orderRepository;
    @Resource
    private OrderLogRepository orderLogRepository;
    @Resource
    private NotificationSender notificationSender;

    /**
     * Process order resend logic.
     * If a tracking number already has a push record and the old order is cancelled:
     *   1. Mark old record as "invalid"
     *   2. Create a new push record
     *   3. Trigger notification
     */
    public Integer processResend(String trackingNo, Integer newOrderId) {
        OrderLog existingLog = orderLogRepository.findByTrackingNoAndType(trackingNo, 6);
        if (existingLog == null) {
            Order newOrder = orderRepository.findById(newOrderId).orElse(null);
            if (newOrder == null) return null;
            OrderLog newLog = new OrderLog();
            newLog.setOrderId(newOrderId);
            newLog.setTrackingNo(trackingNo);
            newLog.setType(6);
            newLog.setStatus(0); // not sent
            orderLogRepository.save(newLog);
            return newLog.getId();
        } else {
            Order oldOrder = orderRepository.findById(existingLog.getOrderId()).orElse(null);
            if (oldOrder != null && "CANCELLED".equals(oldOrder.getStatus())) {
                existingLog.setStatus(4); // invalidated
                orderLogRepository.save(existingLog);
                Order newOrder = orderRepository.findById(newOrderId).orElse(null);
                OrderLog newLog = new OrderLog();
                newLog.setOrderId(newOrderId);
                newLog.setTrackingNo(trackingNo);
                newLog.setType(6);
                newLog.setStatus(0);
                orderLogRepository.save(newLog);
                return newLog.getId();
            }
            if (existingLog.getStatus() == 1 || existingLog.getStatus() == 3) {
                return null; // already success or in delay, no duplicate push
            }
            return existingLog.getId();
        }
    }
}

Complete Unit Tests

package com.example.service.impl;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.example.entity.Order;
import com.example.entity.OrderLog;
import com.example.repository.OrderLogRepository;
import com.example.repository.OrderRepository;
import com.example.sender.NotificationSender;
import java.util.Optional;
import org.junit.jupiter.api.*;
import org.mockito.*;

class OrderServiceImplTest {
    @InjectMocks
    private OrderServiceImpl orderService;
    @Mock
    private OrderRepository orderRepository;
    @Mock
    private OrderLogRepository orderLogRepository;
    @Mock
    private NotificationSender notificationSender;
    private AutoCloseable mockitoCloseable;

    @BeforeEach
    void setUp() {
        mockitoCloseable = MockitoAnnotations.openMocks(this);
    }

    @AfterEach
    void tearDown() throws Exception {
        mockitoCloseable.close();
    }

    // ===== Scenario 1: First push (no history) =====
    @Test
    @DisplayName("First push – no history creates new record and returns ID")
    void processResend_noExistingLog_shouldCreateNewLog() {
        String trackingNo = "SF123456";
        Integer newOrderId = 100;
        when(orderLogRepository.findByTrackingNoAndType(trackingNo, 6)).thenReturn(null);
        Order newOrder = new Order();
        newOrder.setId(newOrderId);
        newOrder.setStatus("SHIPPING");
        when(orderRepository.findById(newOrderId)).thenReturn(Optional.of(newOrder));
        when(orderLogRepository.save(any(OrderLog.class))).thenAnswer(invocation -> {
            OrderLog saved = invocation.getArgument(0);
            saved.setId(1);
            return saved;
        });
        Integer result = orderService.processResend(trackingNo, newOrderId);
        assertNotNull(result);
        assertEquals(1, result);
        ArgumentCaptor<OrderLog> captor = ArgumentCaptor.forClass(OrderLog.class);
        verify(orderLogRepository, times(1)).save(captor.capture());
        OrderLog savedLog = captor.getValue();
        assertEquals(newOrderId, savedLog.getOrderId());
        assertEquals(trackingNo, savedLog.getTrackingNo());
        assertEquals(6, savedLog.getType());
        assertEquals(0, savedLog.getStatus());
    }

    // ===== Scenario 2: Cancelled resend (core case) =====
    @Test
    @DisplayName("Cancelled resend – old order cancelled should invalidate old and create new")
    void processResend_oldOrderCancelled_shouldInvalidateOldAndCreateNew() {
        String trackingNo = "SF123456";
        Integer oldOrderId = 50;
        Integer newOrderId = 100;
        OrderLog existingLog = new OrderLog();
        existingLog.setId(10);
        existingLog.setOrderId(oldOrderId);
        existingLog.setTrackingNo(trackingNo);
        existingLog.setType(6);
        existingLog.setStatus(1); // success
        when(orderLogRepository.findByTrackingNoAndType(trackingNo, 6)).thenReturn(existingLog);
        Order oldOrder = new Order();
        oldOrder.setId(oldOrderId);
        oldOrder.setStatus("CANCELLED");
        when(orderRepository.findById(oldOrderId)).thenReturn(Optional.of(oldOrder));
        Order newOrder = new Order();
        newOrder.setId(newOrderId);
        newOrder.setStatus("SHIPPING");
        when(orderRepository.findById(newOrderId)).thenReturn(Optional.of(newOrder));
        when(orderLogRepository.save(any(OrderLog.class))).thenAnswer(invocation -> {
            OrderLog saved = invocation.getArgument(0);
            if (saved.getId() == null) saved.setId(20);
            return saved;
        });
        Integer result = orderService.processResend(trackingNo, newOrderId);
        assertEquals(20, result);
        ArgumentCaptor<OrderLog> captor = ArgumentCaptor.forClass(OrderLog.class);
        verify(orderLogRepository, times(2)).save(captor.capture());
        OrderLog firstSave = captor.getAllValues().get(0);
        assertEquals(10, firstSave.getId());
        assertEquals(4, firstSave.getStatus()); // invalidated
        OrderLog secondSave = captor.getAllValues().get(1);
        assertEquals(newOrderId, secondSave.getOrderId());
        assertEquals(trackingNo, secondSave.getTrackingNo());
        assertEquals(6, secondSave.getType());
        assertEquals(0, secondSave.getStatus());
    }

    // Additional scenarios (old not cancelled, order not found, failure retry) follow the same pattern.
}

Test Writing Methodology: Given‑When‑Then

@Test
void testMethodName_condition_expectedBehavior() {
    // ===== Given (prepare) =====
    // 1. Build input parameters
    // 2. Stub mock returns
    // 3. Define when(...).thenReturn(...)

    // ===== When (execute) =====
    // Call the method under test

    // ===== Then (verify) =====
    // 1. assertEquals / assertNull / assertNotNull for return value
    // 2. verify(...) for dependency calls
    // 3. ArgumentCaptor to capture and assert passed arguments
}

Advanced Techniques

ArgumentCaptor

ArgumentCaptor<OrderLog> captor = ArgumentCaptor.forClass(OrderLog.class);
verify(orderLogRepository).save(captor.capture());
OrderLog captured = captor.getValue();
assertEquals("SF123456", captured.getTrackingNo());
assertEquals(0, captured.getStatus());

thenAnswer for Dynamic Returns

when(orderLogRepository.save(any(OrderLog.class)))
    .thenAnswer(invocation -> {
        OrderLog saved = invocation.getArgument(0);
        saved.setId(100); // simulate DB generated ID
        return saved;
    });

Verifying Call Order

InOrder inOrder = inOrder(orderLogRepository);
// Verify old record is invalidated first
inOrder.verify(orderLogRepository).save(argThat(log -> log.getStatus() == 4));
// Then new record is created
inOrder.verify(orderLogRepository).save(argThat(log -> log.getStatus() == 0));

ReflectionTestUtils for @Value fields

ReflectionTestUtils.setField(orderService, "maxRetryCount", 3);
ReflectionTestUtils.setField(orderService, "warehouseId", "WH001");

Testing Private Methods

Method method = OrderServiceImpl.class.getDeclaredMethod("processResend", String.class, Integer.class);
method.setAccessible(true);
Integer result = (Integer) method.invoke(orderService, "SF123456", 100);
assertEquals(1, result);

Common Issues and Pitfalls

NullPointerException

Cause: a mocked method was not stubbed, so it returns null and subsequent calls on the result trigger NPE.

// Forgot to stub – NPE occurs
Order order = orderRepository.findById(1).orElse(null);
order.getStatus(); // NPE

// Correct: stub first
when(orderRepository.findById(1)).thenReturn(Optional.of(mockOrder));

Unnecessary Stubbing Exception

Cause: a stubbed method is never invoked.

// Solution 1: remove the unused stub
// Solution 2: use lenient mode
lenient().when(orderRepository.findById(anyInt())).thenReturn(Optional.empty());

Mock Returning Null Instead of Expected Value

Cause: argument mismatch.

// when(repo.findById(1)) works only for exact 1
// Use any() when the exact value is not important
when(orderRepository.findById(anyInt())).thenReturn(Optional.of(mockOrder));

Void Methods Cannot Use when().thenReturn()

// Compilation error
// when(messageSender.send(any())).thenReturn(null);

// Correct: use doNothing or doThrow
doNothing().when(messageSender).send(any());
doThrow(new RuntimeException("error")).when(messageSender).send(any());

Test Naming Conventions

Recommended format:

methodName_condition_expectedBehavior
// Good names
void processResend_oldOrderCancelled_shouldCreateNewLog();
void processResend_noExistingLog_shouldReturnNewId();
void processResend_statusAlreadySuccess_shouldReturnNull();

// Bad names
void test1();
void testProcessResend();
void testSuccess();

Running Tests

# Run all tests
mvn test

# Run a specific test class
mvn test -Dtest=OrderServiceImplTest

# Run a specific test method
mvn test -Dtest="OrderServiceImplTest#processResend_oldOrderCancelled_shouldInvalidateOldAndCreateNew"

# Skip test compilation and run directly
mvn test -pl . -Dtest=OrderServiceImplTest

Checklist Before Writing a Unit Test

Identify the method under test

Map out branch paths – each if/else becomes a test case

Determine dependencies – mock all external calls (repositories, feign, MQ, etc.)

Construct data – prepare input parameters and mock return values for each branch

Verify results

Is the return value correct? (assertEquals)

Are dependent methods called as expected? (verify)

Are the passed arguments correct? (ArgumentCaptor)

Are unintended methods not called? (verify never)

Following this checklist lets you achieve comprehensive unit‑test coverage for any service method.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaUnit TestingMockingMockitoservice-layerTest NamingGiven-When-ThenJUnit5
The Dominant Programmer
Written by

The Dominant Programmer

Resources and tutorials for programmers' advanced learning journey. Advanced tracks in Java, Python, and C#. Blog: https://blog.csdn.net/badao_liumang_qizhi

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.