Mockito 单元测试从入门到实战:Java Service 层测试完全指南

Mockito 单元测试从入门到实战:Java Service 层测试完全指南

一、什么是单元测试

单元测试是对代码中最小可测试单元(通常是一个方法)进行验证的测试。它的核心目标是:

  • 验证方法在各种输入条件下的行为是否符合预期
  • 快速发现代码逻辑错误
  • 为代码重构提供安全网

单元测试 vs 集成测试

维度 单元测试 集成测试
测试范围 单个方法/类 多个组件协作
外部依赖 全部 Mock 掉 使用真实依赖(数据库、MQ等)
执行速度 毫秒级 秒级甚至分钟级
环境要求 无需启动 Spring 容器 需要完整环境
适用场景 验证业务逻辑分支 验证组件间交互

二、Mockito 框架核心概念

Mockito 是 Java 中最流行的 Mock 框架,核心思想是:用虚拟对象替代真实依赖,让你只关注被测方法本身的逻辑。

2.1 核心注解

java 复制代码
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

public class OrderServiceImplTest {

    /**
     * @InjectMocks:被测试的目标类
     * Mockito 会自动将 @Mock 标注的对象注入到这个类中
     */
    @InjectMocks
    private OrderServiceImpl orderService;

    /**
     * @Mock:模拟的依赖对象
     * 这些对象不会执行真实逻辑,所有方法默认返回 null/0/false
     */
    @Mock
    private OrderRepository orderRepository;

    @Mock
    private UserRepository userRepository;

    @Mock
    private MessageSender messageSender;
}

2.2 初始化方式

java 复制代码
// 方式一:在 @BeforeEach 中手动初始化(推荐,兼容性好)
@BeforeEach
void setUp() {
    MockitoAnnotations.openMocks(this);
}

// 方式二:使用 JUnit5 扩展(更简洁)
@ExtendWith(MockitoExtension.class)
public class OrderServiceImplTest {
    // ...
}

三、Mockito 核心 API 详解

3.1 when(...).thenReturn(...):定义 Mock 行为

作用:当 Mock 对象的某个方法被调用时,返回指定的值。

java 复制代码
// 基本用法:当调用 findById(1) 时,返回一个 Order 对象
Order mockOrder = new Order();
mockOrder.setId(1);
mockOrder.setStatus("CREATED");
when(orderRepository.findById(1)).thenReturn(Optional.of(mockOrder));

// 返回空
when(orderRepository.findById(999)).thenReturn(Optional.empty());

// 返回列表
List<Order> orderList = Arrays.asList(mockOrder);
when(orderRepository.findByUserId(100)).thenReturn(orderList);

3.2 参数匹配器(Argument Matchers)

当你不关心具体参数值时,使用匹配器:

java 复制代码
import static org.mockito.ArgumentMatchers.*;

// any():匹配任意对象
when(orderRepository.save(any(Order.class))).thenReturn(mockOrder);

// anyInt():匹配任意 int
when(orderRepository.findById(anyInt())).thenReturn(Optional.of(mockOrder));

// anyString():匹配任意字符串
when(userRepository.findByName(anyString())).thenReturn(mockUser);

// eq():精确匹配某个值(与其他匹配器混用时必须用 eq 包裹具体值)
when(orderRepository.findByUserIdAndStatus(eq(100), anyString()))
    .thenReturn(orderList);

// argThat():自定义匹配逻辑
when(orderRepository.save(argThat(order -> order.getStatus().equals("PAID"))))
    .thenReturn(mockOrder);

重要规则:如果一个方法的参数中使用了匹配器,那么所有参数都必须使用匹配器。

java 复制代码
// 错误写法:混用了具体值和匹配器
when(orderRepository.findByUserIdAndStatus(100, anyString()))
    .thenReturn(orderList);

// 正确写法:全部使用匹配器
when(orderRepository.findByUserIdAndStatus(eq(100), anyString()))
    .thenReturn(orderList);

3.3 when(...).thenThrow(...):模拟异常

java 复制代码
// 模拟方法抛出异常
when(orderRepository.findById(anyInt()))
    .thenThrow(new RuntimeException("数据库连接失败"));

// 模拟 void 方法抛出异常
doThrow(new RuntimeException("发送失败")).when(messageSender).send(any());

3.4 verify(...):验证方法是否被调用

作用:断言某个 Mock 对象的方法是否被调用、调用了几次、用什么参数调用的。

java 复制代码
// 验证 save 方法被调用了 1 次
verify(orderRepository).save(any(Order.class));

// 验证 save 方法被调用了 2 次
verify(orderRepository, times(2)).save(any(Order.class));

// 验证 send 方法从未被调用
verify(messageSender, never()).send(any());

// 验证调用参数的具体内容
verify(orderRepository).save(argThat(order -> {
    return order.getStatus().equals("CANCELLED")
        && order.getUserId().equals(100);
}));

3.5 doReturn/doNothing/doThrow:处理 void 方法和特殊场景

java 复制代码
// void 方法不做任何事
doNothing().when(messageSender).send(any());

// void 方法抛异常
doThrow(new RuntimeException("error")).when(messageSender).send(any());

// 连续调用返回不同值
when(orderRepository.findById(1))
    .thenReturn(Optional.of(order1))  // 第一次调用
    .thenReturn(Optional.of(order2)); // 第二次调用

四、完整示例:订单取消重发场景

下面用一个与实际业务类似但完全独立的示例来演示完整的单元测试流程。

4.1 业务代码

java 复制代码
/**
 * 订单服务实现类.
 */
@Service
public class OrderServiceImpl implements OrderService {

    @Resource
    private OrderRepository orderRepository;

    @Resource
    private OrderLogRepository orderLogRepository;

    @Resource
    private NotificationSender notificationSender;

    /**
     * 处理订单重发逻辑.
     * 如果同一个追踪号已有推送记录,且关联的老订单已取消,则:
     * 1. 老记录状态改为"已作废"
     * 2. 新建一条推送记录
     * 3. 触发推送通知
     *
     * @param trackingNo 物流追踪号
     * @param newOrderId 新订单ID
     * @return 推送记录ID,null 表示无需推送
     */
    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); // 未传输
            orderLogRepository.save(newLog);
            return newLog.getId();
        } else {
            // 已有记录:判断关联订单是否已取消
            Order oldOrder =
                orderRepository.findById(existingLog.getOrderId()).orElse(null);

            if (oldOrder == null) {
                // 关联订单不存在,记录日志,走原有逻辑
                log.warn("推送记录关联的订单不存在, logId:{}, orderId:{}",
                    existingLog.getId(), existingLog.getOrderId());
            } else if ("CANCELLED".equals(oldOrder.getStatus())) {
                // 老订单已取消:作废老记录,新建记录,触发推送
                existingLog.setStatus(4); // 已作废
                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;
            }
            return existingLog.getId();
        }
    }
}

4.2 完整单元测试

java 复制代码
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.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

/**
 * OrderServiceImpl 单元测试.
 */
class OrderServiceImplTest {

    @InjectMocks
    private OrderServiceImpl orderService;

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private OrderLogRepository orderLogRepository;

    @Mock
    private NotificationSender notificationSender;

    private AutoCloseable mockitoCloseable;

    @BeforeEach
    void setUp() {
        // 初始化所有 @Mock 和 @InjectMocks 注解
        mockitoCloseable = MockitoAnnotations.openMocks(this);
    }

    @AfterEach
    void tearDown() throws Exception {
        // 释放 Mockito 资源,避免内存泄漏
        mockitoCloseable.close();
    }

    // ========== 场景一:首次推送(无历史记录) ==========

    @Test
    @DisplayName("首次推送 - 无历史记录时应创建新记录并返回ID")
    void processResend_noExistingLog_shouldCreateNewLog() {
        // ===== Given(准备数据和 Mock 行为) =====
        String trackingNo = "SF123456";
        Integer newOrderId = 100;

        // Mock:查询历史记录返回 null(无历史)
        when(orderLogRepository.findByTrackingNoAndType(trackingNo, 6))
            .thenReturn(null);

        // Mock:查询新订单
        Order newOrder = new Order();
        newOrder.setId(newOrderId);
        newOrder.setStatus("SHIPPING");
        when(orderRepository.findById(newOrderId))
            .thenReturn(Optional.of(newOrder));

        // Mock:保存记录后设置 ID(模拟数据库自增主键)
        when(orderLogRepository.save(any(OrderLog.class)))
            .thenAnswer(invocation -> {
                OrderLog savedLog = invocation.getArgument(0);
                savedLog.setId(1);
                return savedLog;
            });

        // ===== When(执行被测方法) =====
        Integer result = orderService.processResend(trackingNo, newOrderId);

        // ===== Then(验证结果) =====
        // 验证返回值
        assertNotNull(result);
        assertEquals(1, result);

        // 验证 save 被调用了 1 次,并捕获参数检查内容
        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());
    }

    // ========== 场景二:取消重发(核心场景) ==========

    @Test
    @DisplayName("取消重发 - 老订单已取消时应作废老记录并创建新记录")
    void processResend_oldOrderCancelled_shouldInvalidateOldAndCreateNew() {
        // ===== Given =====
        String trackingNo = "SF123456";
        Integer oldOrderId = 50;
        Integer newOrderId = 100;

        // 构造已有的推送记录(status=1 已成功推送过)
        OrderLog existingLog = new OrderLog();
        existingLog.setId(10);
        existingLog.setOrderId(oldOrderId);
        existingLog.setTrackingNo(trackingNo);
        existingLog.setType(6);
        existingLog.setStatus(1); // 已成功

        // Mock:查询历史记录返回已有记录
        when(orderLogRepository.findByTrackingNoAndType(trackingNo, 6))
            .thenReturn(existingLog);

        // Mock:查询老订单,状态为已取消
        Order oldOrder = new Order();
        oldOrder.setId(oldOrderId);
        oldOrder.setStatus("CANCELLED");  // 关键:已取消
        when(orderRepository.findById(oldOrderId))
            .thenReturn(Optional.of(oldOrder));

        // Mock:查询新订单
        Order newOrder = new Order();
        newOrder.setId(newOrderId);
        newOrder.setStatus("SHIPPING");
        when(orderRepository.findById(newOrderId))
            .thenReturn(Optional.of(newOrder));

        // Mock:保存时设置 ID
        when(orderLogRepository.save(any(OrderLog.class)))
            .thenAnswer(invocation -> {
                OrderLog savedLog = invocation.getArgument(0);
                if (savedLog.getId() == null) {
                    savedLog.setId(20); // 新记录
                }
                return savedLog;
            });

        // ===== When =====
        Integer result = orderService.processResend(trackingNo, newOrderId);

        // ===== Then =====
        // 验证返回了新记录的 ID
        assertEquals(20, result);

        // 验证 save 被调用了 2 次(一次更新老记录,一次创建新记录)
        ArgumentCaptor<OrderLog> captor =
            ArgumentCaptor.forClass(OrderLog.class);
        verify(orderLogRepository, times(2)).save(captor.capture());

        // 第一次调用:老记录状态改为 4(已作废)
        OrderLog firstSave = captor.getAllValues().get(0);
        assertEquals(10, firstSave.getId());
        assertEquals(4, firstSave.getStatus());

        // 第二次调用:新记录
        OrderLog secondSave = captor.getAllValues().get(1);
        assertEquals(newOrderId, secondSave.getOrderId());
        assertEquals(trackingNo, secondSave.getTrackingNo());
        assertEquals(6, secondSave.getType());
        assertEquals(0, secondSave.getStatus());
    }

    // ========== 场景三:老订单未取消,已成功推送 ==========

    @Test
    @DisplayName("老订单未取消且已成功推送 - 应返回null不重复推送")
    void processResend_oldOrderNotCancelled_statusSuccess_shouldReturnNull() {
        // ===== Given =====
        String trackingNo = "SF123456";
        Integer oldOrderId = 50;
        Integer newOrderId = 100;

        OrderLog existingLog = new OrderLog();
        existingLog.setId(10);
        existingLog.setOrderId(oldOrderId);
        existingLog.setStatus(1); // 已成功
        existingLog.setType(6);

        when(orderLogRepository.findByTrackingNoAndType(trackingNo, 6))
            .thenReturn(existingLog);

        // 老订单状态正常(未取消)
        Order oldOrder = new Order();
        oldOrder.setId(oldOrderId);
        oldOrder.setStatus("COMPLETED");
        when(orderRepository.findById(oldOrderId))
            .thenReturn(Optional.of(oldOrder));

        // ===== When =====
        Integer result = orderService.processResend(trackingNo, newOrderId);

        // ===== Then =====
        assertNull(result);

        // 验证没有创建新记录
        verify(orderLogRepository, never()).save(any(OrderLog.class));
    }

    // ========== 场景四:关联订单查不到 ==========

    @Test
    @DisplayName("关联订单不存在 - 应走原有逻辑返回null")
    void processResend_oldOrderNotFound_shouldFallbackToOriginalLogic() {
        // ===== Given =====
        String trackingNo = "SF123456";
        Integer oldOrderId = 50;
        Integer newOrderId = 100;

        OrderLog existingLog = new OrderLog();
        existingLog.setId(10);
        existingLog.setOrderId(oldOrderId);
        existingLog.setStatus(1); // 已成功
        existingLog.setType(6);

        when(orderLogRepository.findByTrackingNoAndType(trackingNo, 6))
            .thenReturn(existingLog);

        // 关联订单查不到
        when(orderRepository.findById(oldOrderId))
            .thenReturn(Optional.empty());

        // ===== When =====
        Integer result = orderService.processResend(trackingNo, newOrderId);

        // ===== Then =====
        // status=1,走原有逻辑返回 null
        assertNull(result);

        // 验证老记录没有被修改保存
        verify(orderLogRepository, never()).save(any(OrderLog.class));
    }

    // ========== 场景五:老订单未取消,推送失败需重试 ==========

    @Test
    @DisplayName("老订单未取消且推送失败 - 应返回老记录ID触发重试")
    void processResend_oldOrderNotCancelled_statusFailed_shouldReturnOldId() {
        // ===== Given =====
        String trackingNo = "SF123456";
        Integer oldOrderId = 50;
        Integer newOrderId = 100;

        OrderLog existingLog = new OrderLog();
        existingLog.setId(10);
        existingLog.setOrderId(oldOrderId);
        existingLog.setStatus(2); // 传输失败
        existingLog.setType(6);

        when(orderLogRepository.findByTrackingNoAndType(trackingNo, 6))
            .thenReturn(existingLog);

        // 老订单状态正常(未取消)
        Order oldOrder = new Order();
        oldOrder.setId(oldOrderId);
        oldOrder.setStatus("SHIPPING");
        when(orderRepository.findById(oldOrderId))
            .thenReturn(Optional.of(oldOrder));

        // ===== When =====
        Integer result = orderService.processResend(trackingNo, newOrderId);

        // ===== Then =====
        // status=2(失败),应返回老记录 ID 触发重试
        assertEquals(10, result);
    }
}

五、测试编写方法论:Given-When-Then

每个测试方法遵循三段式结构:

java 复制代码
@Test
void testMethodName_condition_expectedBehavior() {
    // ===== Given(准备) =====
    // 1. 构造输入参数
    // 2. 构造 Mock 对象的返回值
    // 3. 定义 when(...).thenReturn(...) 行为

    // ===== When(执行) =====
    // 调用被测方法

    // ===== Then(验证) =====
    // 1. assertEquals / assertNull / assertNotNull 验证返回值
    // 2. verify(...) 验证依赖方法的调用情况
    // 3. ArgumentCaptor 捕获并验证传入参数的内容
}

六、高级技巧

6.1 ArgumentCaptor:捕获方法参数

当你需要验证传给 Mock 对象的参数内容时:

java 复制代码
// 声明捕获器
ArgumentCaptor<OrderLog> captor =
    ArgumentCaptor.forClass(OrderLog.class);

// 执行被测方法后...

// 捕获 save 方法的参数
verify(orderLogRepository).save(captor.capture());

// 验证参数内容
OrderLog captured = captor.getValue();
assertEquals("SF123456", captured.getTrackingNo());
assertEquals(0, captured.getStatus());

// 如果方法被调用多次,获取所有参数
verify(orderLogRepository, times(2)).save(captor.capture());
List<OrderLog> allValues = captor.getAllValues();
assertEquals(4, allValues.get(0).getStatus());  // 第一次调用
assertEquals(0, allValues.get(1).getStatus());  // 第二次调用

6.2 thenAnswer:动态返回值

当返回值需要根据输入参数动态计算时:

java 复制代码
when(orderLogRepository.save(any(OrderLog.class)))
    .thenAnswer(invocation -> {
        OrderLog savedLog = invocation.getArgument(0); // 获取第一个参数
        savedLog.setId(100); // 模拟数据库生成 ID
        return savedLog;
    });

6.3 验证调用顺序

java 复制代码
import org.mockito.InOrder;

InOrder inOrder = inOrder(orderLogRepository);
// 验证先作废老记录
inOrder.verify(orderLogRepository)
    .save(argThat(savedLog -> savedLog.getStatus() == 4));
// 再新建记录
inOrder.verify(orderLogRepository)
    .save(argThat(savedLog -> savedLog.getStatus() == 0));

6.4 ReflectionTestUtils:设置私有字段

当被测类有 @Value 注入的配置值时:

java 复制代码
import org.springframework.test.util.ReflectionTestUtils;

@BeforeEach
void setUp() {
    MockitoAnnotations.openMocks(this);
    // 设置 @Value 注入的字段
    ReflectionTestUtils.setField(orderService, "maxRetryCount", 3);
    ReflectionTestUtils.setField(orderService, "warehouseId", "WH001");
}

6.5 测试私有方法

Mockito 不能直接 Mock 私有方法,但可以通过反射调用:

java 复制代码
import java.lang.reflect.Method;

@Test
void testPrivateMethod() throws Exception {
    Method method = OrderServiceImpl.class.getDeclaredMethod(
        "processResend", String.class, Integer.class);
    method.setAccessible(true);
    Integer result = (Integer) method.invoke(
        orderService, "SF123456", 100);
    assertEquals(1, result);
}

七、常见问题与陷阱

7.1 NullPointerException

原因 :Mock 对象的方法没有定义 when().thenReturn(),默认返回 null。

java 复制代码
// 忘记 mock,调用时 NPE
Order order = orderRepository.findById(1).orElse(null);
order.getStatus(); // NPE!因为 findById 默认返回 null

// 正确做法:先定义 mock 行为
when(orderRepository.findById(1)).thenReturn(Optional.of(mockOrder));

7.2 Unnecessary Stubbing 异常

原因 :定义了 when().thenReturn() 但方法实际没被调用。

java 复制代码
// 解决方案一:删除多余的 stub
// 解决方案二:使用 lenient 模式
lenient().when(orderRepository.findById(anyInt()))
    .thenReturn(Optional.empty());

7.3 Mock 的方法返回了 null 而非预期值

原因 :参数不匹配。when(repo.findById(1)) 只在参数恰好是 1 时生效。

java 复制代码
// 如果不确定参数值,用 any()
when(orderRepository.findById(anyInt()))
    .thenReturn(Optional.of(mockOrder));

7.4 void 方法无法用 when().thenReturn()

java 复制代码
// 编译错误:void 方法不能用 when().thenReturn()
// when(messageSender.send(any())).thenReturn(null);

// 正确做法:使用 doNothing 或 doThrow
doNothing().when(messageSender).send(any());
doThrow(new RuntimeException("error")).when(messageSender).send(any());

八、测试命名规范

推荐格式:方法名_条件_预期行为

java 复制代码
// 好的命名
void processResend_oldOrderCancelled_shouldCreateNewLog()
void processResend_noExistingLog_shouldReturnNewId()
void processResend_statusAlreadySuccess_shouldReturnNull()

// 不好的命名
void test1()
void testProcessResend()
void testSuccess()

九、运行测试

bash 复制代码
# 运行所有测试
mvn test

# 运行指定测试类
mvn test -Dtest=OrderServiceImplTest

# 运行指定测试方法
mvn test -Dtest="OrderServiceImplTest#processResend_oldOrderCancelled_shouldInvalidateOldAndCreateNew"

# 跳过测试编译直接运行
mvn test -pl . -Dtest=OrderServiceImplTest

十、总结:单元测试编写清单

编写一个单元测试前,按以下步骤思考:

  1. 确定被测方法:哪个方法需要测试?
  2. 梳理分支路径:方法中有几个 if/else 分支?每个分支是一个测试用例
  3. 确定依赖:方法调用了哪些外部依赖(Repository、Feign、MQ)?全部 Mock
  4. 构造数据:每个分支需要什么样的输入数据和 Mock 返回值?
  5. 验证结果
    • 返回值是否正确?(assertEquals)
    • 依赖方法是否被正确调用?(verify)
    • 传入参数是否正确?(ArgumentCaptor)
    • 不该调用的方法是否没被调用?(verify never)

遵循这个清单,你可以为任何 Service 方法编写完整的单元测试覆盖。

相关推荐
菠萝猫yena2 天前
【读书笔记】《测试架构师修炼之道》读书笔记
功能测试·测试工具·单元测试
慧一居士2 天前
冒烟自测用例怎么写?
功能测试·单元测试·测试用例·可用性测试·模块测试
前端若水3 天前
智能体测试策略:单元测试、集成测试与模拟LLM
单元测试·集成测试
小羊Yveesss3 天前
AI智能单元测试:覆盖率泡沫与可信测试的产业破局
人工智能·单元测试
测试员周周3 天前
【AI测试路线图2】功能测试转 AI 测试:4~5 个月,一条最稳的路
开发语言·人工智能·python·功能测试·测试工具·单元测试·pytest
川石课堂软件测试3 天前
接口测试常见面试题及答案
python·网络协议·mysql·华为·单元测试·prometheus·harmonyos
MC皮蛋侠客3 天前
Catch2 单元测试指南
单元测试·catch2
诸葛李3 天前
集成构建xxxxx
java·junit·单元测试
yeshan4 天前
【Draft】基于 cluacov 的 Lua 代码分支覆盖率统计:从行级近似到指令级精确
单元测试·lua