13、Repository模拟测试:MockK入门、模拟数据层、验证交互

说到Repository层的测试,很多新手会问:我直接调真实数据库不就行了?嗯,想法很美好,现实很骨感。真实数据库有网络延迟、文件读写、状态污染,跑一次测试等半天,还经常因为环境问题报错。我个人的习惯是——能用Mock绝不用真家伙。

这一章我们聊聊MockK这个库。它是Kotlin生态里最流行的Mock框架,语法简洁,对协程支持也好。说白了,就是帮我们造一个假的Repository,让它按剧本演戏,我们只管验证它演得对不对。

为什么需要Mock数据层?

你想想看,Repository层通常依赖网络API、本地数据库、SharedPreferences这些外部资源。测试时如果真去联网,那叫集成测试,不是单元测试。单元测试的核心是快,毫秒级跑完。Mock就是为了这个目的。

我在项目中遇到过好几次:测试因为网络超时而失败,结果发现是后端接口挂了。这种问题不该归单元测试管。Mock掉数据层,测试只关心业务逻辑对不对,至于数据从哪来,那是Mock的事。

核心原则: 单元测试只测你的代码逻辑,不测第三方库和外部系统。

MockK快速上手

先加依赖。在build.gradle.kts里加上:

testImplementation("io.mockk:mockk:1.13.8")

然后就可以写测试了。我习惯用mockk<T>()来创建Mock对象,简单直接。

// 假设我们有一个UserRepository接口
interface UserRepository {
    fun getUser(id: String): User
    fun saveUser(user: User): Boolean
}

// 测试代码
@Test
fun `test mock repository`() {
    val mockRepo = mockk<UserRepository>()
    
    // 定义行为:当调用getUser("1")时,返回一个假用户
    every { mockRepo.getUser("1") } returns User("1", "张三")
    
    // 调用
    val user = mockRepo.getUser("1")
    
    assertEquals("张三", user.name)
}

这里every { ... } returns ...是MockK的核心语法。它告诉Mock对象:当这个方法被这样调用时,你就返回这个值。有点像给演员发剧本。

模拟数据层的几种模式

实际项目中,Repository往往不是接口,而是类。而且可能有多个依赖。我总结了三种常见场景:

场景一:接口模拟

最简单,直接mock接口。适合依赖倒置做得好的项目。

val api = mockk<ApiService>()
val dao = mockk<UserDao>()
val repo = UserRepositoryImpl(api, dao)

场景二:类模拟

如果Repository是具体类,也可以用mockkClass。但要注意,MockK默认不会调用真实构造方法。

val repo = mockkClass(UserRepositoryImpl::class)
every { repo.getUser(any()) } returns User("mock", "Mock用户")

场景三:部分模拟(Spy)

有时候我们想保留真实对象的部分行为,只Mock掉某些方法。这时候用spyk

val realRepo = UserRepositoryImpl(api, dao)
val spyRepo = spyk(realRepo)
// 只Mock getUser,其他方法走真实逻辑
every { spyRepo.getUser(any()) } returns User("spy", "间谍用户")
我的建议: 能用接口Mock就别用Spy。Spy容易让测试变得脆弱,因为你不知道真实方法里有没有副作用。

验证交互:你调了没?

MockK不仅能模拟返回值,还能验证方法是否被调用、调了几次、传了什么参数。这在测试Repository的交互逻辑时特别有用。

举个例子,用户登录成功后,应该先保存token,再跳转页面。我们想验证Repository的saveToken方法是否被调用了。

@Test
fun `test login saves token`() {
    val mockRepo = mockk<UserRepository>(relaxed = true)
    val viewModel = LoginViewModel(mockRepo)
    
    viewModel.login("user", "pass")
    
    // 验证 saveToken 被调用了一次,参数是 "abc123"
    verify(exactly = 1) { mockRepo.saveToken("abc123") }
}

verify是验证的核心。你可以指定:

  • exactly = 1:恰好调用一次
  • atLeast = 1:至少一次
  • atMost = 3:最多三次
  • wasNot Called:从未调用

我曾经踩过一个坑:测试里忘了加exactly,结果方法被调了两次我都没发现。后来加了验证,才发现有个循环里重复调用了API。嗯,verify真的是排查重复调用的利器。

协程与MockK

现在的Repository大多用挂起函数。MockK对协程支持很好,用coEverycoVerify就行。

interface UserRepository {
    suspend fun fetchUser(id: String): User
}

@Test
fun `test coroutine repository`() = runTest {
    val mockRepo = mockk<UserRepository>()
    coEvery { mockRepo.fetchUser("1") } returns User("1", "协程用户")
    
    val user = mockRepo.fetchUser("1")
    
    assertEquals("协程用户", user.name)
    coVerify { mockRepo.fetchUser("1") }
}

注意:协程测试要用runTest,这是Kotlin官方提供的测试协程工具。MockK的coEvery会自动处理挂起函数的Mock。

注意: 如果忘记用coEvery而用了every,MockK会报错,提示你协程方法需要用co前缀。别问我怎么知道的,我debug了半小时才发现。

Relaxed Mock:懒人福音

有时候我们不想为每个方法都写every。对于不关心的调用,可以用relaxed = true。这样MockK会给所有方法返回默认值(数字返回0,对象返回null,集合返回空列表)。

val mockRepo = mockk<UserRepository>(relaxed = true)
// 不用写 every,直接调用
mockRepo.getUser("1")  // 返回 null
mockRepo.saveUser(User("1", "test"))  // 返回 false

但要注意,relaxed mock容易掩盖问题。我一般只在验证交互时用,如果关心返回值,还是老老实实写every

知识体系一览

下面这张图总结了MockK在Repository测试中的核心用法:

MockK Repository测试知识体系 MockK 核心 模拟数据层 • mockk<T>() 创建Mock • every { } returns 定义行为 • spyk() 部分模拟 • relaxed = true 懒人模式 验证交互 • verify { } 检查调用 • exactly / atLeast / atMost • wasNot Called 未调用 • coVerify 协程验证 协程支持 • coEvery 模拟挂起函数 • runTest 协程测试环境

实战:完整的Repository测试

最后给一个完整的例子。假设我们有一个OrderRepository,它依赖网络API和本地缓存。我们要测试下单逻辑:先保存到本地,再调用网络,如果网络失败则回滚本地。

class OrderRepositoryTest {
    
    private val api = mockk<OrderApi>()
    private val cache = mockk<OrderCache>(relaxed = true)
    private val repo = OrderRepository(api, cache)
    
    @Test
    fun `test place order success`() = runTest {
        val order = Order("123", 99.9)
        
        coEvery { api.placeOrder(order) } returns OrderResult.Success("order_001")
        
        val result = repo.placeOrder(order)
        
        assertTrue(result is OrderResult.Success)
        coVerify(exactly = 1) { cache.saveOrder(order) }
        coVerify(exactly = 1) { api.placeOrder(order) }
    }
    
    @Test
    fun `test place order failure rollback`() = runTest {
        val order = Order("123", 99.9)
        
        coEvery { api.placeOrder(order) } throws NetworkException("timeout")
        
        val result = repo.placeOrder(order)
        
        assertTrue(result is OrderResult.Failure)
        // 验证回滚:缓存被删除
        coVerify { cache.deleteOrder(order.id) }
    }
}

这个测试里,我们验证了两件事:成功时缓存和API都被调用;失败时缓存被回滚。这就是MockK的价值——你不需要真的连数据库,就能把整个流程测透。

避坑指南: 我曾经在测试里忘了Mock一个依赖,结果跑测试时抛了NullPointerException。后来我养成了一个习惯:每个测试方法开头先检查所有依赖是否都Mock了。用mockkrelaxed模式可以避免这个问题,但记得在关键路径上还是要显式定义行为。

好了,这一章的内容就这些。MockK其实还有很多高级用法,比如参数匹配、答案块、对象Mock等。但掌握了everyverifycoEvery这三个核心,你已经能应对90%的Repository测试场景了。剩下的,遇到具体问题再查文档也不迟。


公众号:蓝海资料掘金营,微信deep3321