4. Mock 概念与框架

说到 Mock 测试,我想先聊聊一个很常见的场景——你写完了一个模块,想跑单元测试,结果发现它依赖了好几个外部服务:数据库、REST API、甚至消息队列。没有这些服务的运行环境,测试就跑不起来。你想想看,这多烦人。

我记得在我刚转 Java 那会儿,就吃过这个亏。有一次我负责一个支付结算模块,单元测试里还得启动一个本地的 MySQL 实例。代码改了之后跑一遍测试,先得等 10 秒让数据库启动,然后清表、插数据、跑用例、再清表……一天下来,大部分时间花在了「等」上,而不是写代码上。

后来我遇到了一位老同事,他告诉我:「你试试 Mock。」 这个词当时听起来有点玄学。说白了,Mock 就是「假装」。假装那个外部服务是真的,假装它返回了你期望的数据。这样,你的测试就可以脱离真实的数据库、网络、文件系统,只测试你代码本身的逻辑。

所以这一章,我们就来彻底搞清楚:Mock 到底解决了什么问题?主流的框架有哪些?以及,在多模块的 Gradle 项目里,我应该选哪个?

4.1 Mock 测试的意义

Mock 的核心价值,在我看来就三条:

  • 解耦测试依赖 — 你想测试模块 A,它调用了模块 B 的接口。模块 B 还写着呢,或者它依赖数据库。有了 Mock,你可以让 A 的测试独立跑,不需要 B 真的部署。
  • 提升测试速度 — 我在项目里实测过,一个依赖 Spring 容器的集成测试可能需要 10 秒;而一个 Mock 掉外部依赖的纯 JUnit 测试,50 毫秒就完成了。差别有多大?你现在就能感受到。
  • 模拟边界场景 — 比如网络超时、数据库连接池耗尽、第三方返回异常 HTTP 400。这些场景在真实环境很难复现,但在 Mock 里只需要一行代码就能模拟出来。

当然,Mock 也不是万能的。如果你完全 Mock 掉一切,那测试就失去了对真实集成的校验能力。我个人习惯是:「单元测试尽量 Mock,集成测试尽量真实」。把握好这个度,项目质量才能真正有保障。

4.2 常用 Mock 框架概览

Java 生态中最常用的 Mock 框架,我接触过的有三个:Mockito、MockK、以及 EasyMock。我个人的经验是:在纯 Java 项目里,Mockito 几乎是事实标准;在 Kotlin 项目里,MockK 是更优的选择。

下面这张表你直接拿来用,后面选框架的时候参考就行:

框架 版本(推荐) 语言 核心特性 适用场景
Mockito 5.x Java 灵活、API 简洁、与 JUnit 5 集成好 纯 Java 项目、Spring Boot 项目
MockK 1.13+ Kotlin 支持协程、可空类型、lambda 隔离 Kotlin 多模块项目、协程测试
EasyMock 5.x Java 偏传统的录制-验证模式 老旧项目升级场景

4.3 Mockito 实战要点

Mockito 是我用得最多的框架。它的语法非常直接,你几乎不用学什么特别的 DSL。来看一个最简单的例子:

import static org.mockito.Mockito.*;

public class OrderServiceTest {

    @Test
    void testGetOrder() {
        // 1. 创建 Mock 对象
        PaymentGateway gateway = mock(PaymentGateway.class);

        // 2. 定义行为
        when(gateway.charge(anyString(), eq(100.0)))
                .thenReturn(true);

        // 3. 注入并执行业务逻辑
        OrderService service = new OrderService(gateway);
        boolean result = service.processOrder("order-123");

        // 4. 验证交互
        verify(gateway).charge("order-123", 100.0);
        assertTrue(result);
    }
}

这里要注意几个点:

  • mock() 方法会生成一个空壳对象,所有方法默认返回 null 或零值。
  • when().thenReturn() 是最常见的「打桩」方式。我曾踩过一个坑:如果用了 any() 匹配器,所有的参数匹配都必须换成匹配器,不能混用真实值。否则会抛出 InvalidUseOfMatchersException。
  • verify() 用来检查被测方法是否真的调用了某个依赖——我第一次用 Mockito 时没注意这步,后来发现某个模块外部接口根本没被正确调用,而测试通过了。所以我现在习惯在每个关键路径上加 verify。
注意: 不要 Mock 掉你自己正在测试的类里的私有方法。Mockito 通过 CGLIB 或 ByteBuddy 生成子类,final 类/方法默认不可以被 Mock。如果你碰到这类问题,可以考虑提取接口或用 mockConstruction() API(Mockito 5+ 支持)。

4.4 MockK:Kotlin 项目的首选

如果你在写 Kotlin 多模块项目,我个人强烈建议你用 MockK 替代 Mockito。为什么呢?因为 MockK 对 Kotlin 的协程、可空类型、lambda 做了更优雅的支持。举个我踩过的坑当例子:

// 这是 MockK 的写法,简洁得让人感动
class UserServiceTest {

    @Test
    fun `test fetch user`() = runTest {
        val repo = mockk<UserRepository>()
        every { repo.findUser("alice") } returns User("alice", 30)

        val service = UserService(repo)
        val user = service.fetchUser("alice")

        assertEquals("alice", user.name)
        verify { repo.findUser("alice") }
    }
}

这里有几句关键语法:

  • mockk() 创建 Mock 对象。
  • every { ... } returns ... 等价于 Mockito 的 when().thenReturn(),但更加类型安全。
  • 对于 suspend 函数,MockK 自带支持,不需要额外配置。我见过一个团队用 Mockito 测协程,不得不在测试里加 blockingGet() 或 runBlocking,非常别扭。
  • 另外,MockK 的 verifySequence 可以检验多个调用的顺序,这在测试复杂业务流程时特别好用。
一个小技巧: 如果你的项目同时有 Java 和 Kotlin 模块,可以在 Java 模块用 Mockito,Kotlin 模块用 MockK。Gradle 的多模块依赖管理完全可以同时引入两个框架,不会冲突。我现在的项目就是这么配置的,运行得挺稳。

4.5 如何选择 Mock 框架?

你可能会问:这么多框架,我到底该选哪个?我给几条实际建议:

  • 如果项目 90% 以上是 Java,直接用 Mockito 5.x,社区大、资料多、踩坑少。
  • 如果项目包含 Kotlin 模块,尤其是用到协程、sealed class 或者 data class,那就在 Kotlin 模块里用 MockK。别硬撑。
  • 如果项目是遗留的,已经在用 EasyMock,可以维持不动。但从我个人的升级经验看,没必要主动从 EasyMock 迁移——除非你遇到严重的性能问题或缺少协程支持。
  • 如果团队对测试风格有统一要求,建议使用 Mockito + MockK 组合。用 Gradle 的 testImplementation 分别导入即可:
// 在 Java 模块的 build.gradle 中
dependencies {
    testImplementation "org.mockito:mockito-core:5.13.0"
    testImplementation "org.mockito:mockito-junit-jupiter:5.13.0"
}

// 在 Kotlin 模块的 build.gradle.kts 中
dependencies {
    testImplementation("io.mockk:mockk:1.13.12")
}
Mock 测试知识体系与框架选择 Mock 测试核心目标 解耦测试依赖 模块独立测试 提升测试速度 ms级 vs s级 模拟边界场景 异常/超时/错误 Mockito 5.x MockK 1.13+ EasyMock 5.x JUnit 5 集成 成熟稳定 协程+类型安全 Kotlin 首选 老旧项目迁移 可维持使用 建议策略:Java 模块用 Mockito,Kotlin 模块用 MockK Gradle 多模块可同时引入,互不冲突

4.6 核心 Mock 技术术语

我知道刚开始接触 Mock 时,这些术语容易混。我整理了一个快速对照表,你开发时当个速查手册用:

术语 含义 Mockito 对应 MockK 对应
Mock 对象 替身对象,不执行真正逻辑 mock() mockk()
Stub(打桩) 预设方法返回值 when().thenReturn() every { } returns
Spy 保留真实方法,局部打桩 spy() spyk()
Verify 验证某方法是否被调用 verify() verify { }
Argument Matcher 灵活匹配方法参数 any(), eq() any(), eq()
一个小提醒: 有时候你会在测试里用到 @InjectMocks(Mockito)或 @RelaxedMockK 注解,它们能帮你自动注入 Mock 对象到被测类中。但注意,不要过度依赖自动注入——它可能会让测试的依赖关系变得不透明。我一般只在简单场景下用。

4.7 避坑指南:从实战出发

我记得有一次,我在一个多模块项目里用了 Mockito 的 mockStatic() 来 Mock 一个静态工具类。结果测试通过了,但后续版本升级时,那个静态方法换了签名,我们的测试没有及时发现。为什么?因为 Mock 掉了静态方法,真实调用链路被切断了。从那以后,我对 Mock 静态方法就特别小心。

这里给出几个我踩过的坑:

  • 不要 Mock 掉你自己的私有方法。 如果需要测试私有方法逻辑,重构它——提取成包级可见或 protected 方法,或者干脆拆分成小方法。
  • 不要在集成测试里过度 Mock。 集成测试的目的就是验证真实交互。如果你把数据库和外部 API 都 Mock 了,那这个测试的价值就大打折扣。
  • 版本一致性。 如果用了 Junit 5 @ExtendWith(MockitoExtension.class),请确保 mockito-junit-jupiter 与 mockito-core 版本一致。我见过一个团队因为版本不匹配,测试一直报 IllegalStateException,排查了一下午。
  • 在 Gradle 多模块中保持依赖版本统一。 建议在根项目的 build.gradle 中使用 dependencyConstraints 或 platform 统一管理 Mock 框架版本,避免子模块各自声明不同版本。