28、Repository单元测试:数据源测试、缓存策略测试、错误处理测试

Repository 层,说白了就是数据的中转站。它不生产数据,只是数据的搬运工。但搬运工要是出了问题,整个 App 的数据流就全乱了。今天我们就来聊聊怎么给 Repository 写单元测试,重点放在数据源、缓存策略和错误处理这三个核心点上。

核心观点:Repository 测试不是为了测试数据本身,而是测试数据流动的规则。数据从哪里来、怎么缓存、出错怎么办——这些才是我们要验证的。

数据源测试:验证数据流向

Repository 通常管理多个数据源,比如本地数据库和远程 API。测试的核心是:在特定条件下,数据应该从哪个源来

我个人习惯把数据源抽象成接口,然后用 Mock 对象来模拟。这样测试就能聚焦在 Repository 的逻辑上,而不是真的去调数据库或网络。

// 数据源接口
interface UserDataSource {
    suspend fun getUser(id: String): User?
    suspend fun saveUser(user: User)
}

// Repository 实现
class UserRepository(
    private val localDataSource: UserDataSource,
    private val remoteDataSource: UserDataSource
) {
    suspend fun getUser(id: String): User? {
        // 先查本地
        val localUser = localDataSource.getUser(id)
        if (localUser != null) return localUser
        
        // 本地没有,查远程
        val remoteUser = remoteDataSource.getUser(id)
        if (remoteUser != null) {
            // 缓存到本地
            localDataSource.saveUser(remoteUser)
        }
        return remoteUser
    }
}

测试的时候,我们只需要验证:当本地有数据时,不会去调远程;当本地没有时,会调远程并缓存结果

class UserRepositoryTest {
    
    private val localDataSource = mock<UserDataSource>()
    private val remoteDataSource = mock<UserDataSource>()
    private lateinit var repository: UserRepository
    
    @Before
    fun setup() {
        repository = UserRepository(localDataSource, remoteDataSource)
    }
    
    @Test
    fun `本地有数据时,从本地获取`() = runTest {
        // 模拟本地有数据
        val user = User("1", "张三")
        whenever(localDataSource.getUser("1")).thenReturn(user)
        
        val result = repository.getUser("1")
        
        assertEquals(user, result)
        // 验证远程没有被调用
        verify(remoteDataSource, never()).getUser(any())
    }
    
    @Test
    fun `本地无数据时,从远程获取并缓存`() = runTest {
        // 模拟本地无数据
        whenever(localDataSource.getUser("1")).thenReturn(null)
        val remoteUser = User("1", "李四")
        whenever(remoteDataSource.getUser("1")).thenReturn(remoteUser)
        
        val result = repository.getUser("1")
        
        assertEquals(remoteUser, result)
        // 验证缓存被调用
        verify(localDataSource).saveUser(remoteUser)
    }
}

小技巧:verify 来验证数据源的调用次数和顺序。我经常用 verify(..., times(1)) 来确保某个数据源只被调了一次,避免重复请求。

缓存策略测试:过期与刷新

缓存策略是 Repository 里最容易出 bug 的地方。我见过太多因为缓存过期时间算错,导致用户看到旧数据的案例。

常见的缓存策略有:

  • 先缓存后网络:先显示缓存数据,再异步刷新
  • 缓存优先:缓存有效就用缓存,无效才走网络
  • 网络优先:每次都走网络,但缓存结果备用

测试缓存策略,核心是验证时间判断逻辑数据刷新时机

// 带缓存的 Repository
class CachedUserRepository(
    private val localDataSource: UserDataSource,
    private val remoteDataSource: UserDataSource,
    private val cacheExpiryMs: Long = 5 * 60 * 1000 // 默认5分钟
) {
    private var lastFetchTime: Long = 0
    
    suspend fun getUser(id: String): User? {
        val now = System.currentTimeMillis()
        
        // 缓存有效期内,直接返回本地
        if (now - lastFetchTime < cacheExpiryMs) {
            return localDataSource.getUser(id)
        }
        
        // 缓存过期,从远程获取
        return try {
            val remoteUser = remoteDataSource.getUser(id)
            if (remoteUser != null) {
                localDataSource.saveUser(remoteUser)
                lastFetchTime = now
            }
            remoteUser
        } catch (e: Exception) {
            // 网络失败时,返回过期缓存
            localDataSource.getUser(id)
        }
    }
}

测试时,我们需要模拟时间流逝:

class CachedUserRepositoryTest {
    
    private val localDataSource = mock<UserDataSource>()
    private val remoteDataSource = mock<UserDataSource>()
    private lateinit var repository: CachedUserRepository
    
    @Before
    fun setup() {
        repository = CachedUserRepository(
            localDataSource, 
            remoteDataSource,
            cacheExpiryMs = 1000 // 1秒过期,方便测试
        )
    }
    
    @Test
    fun `缓存有效期内返回本地数据`() = runTest {
        val user = User("1", "王五")
        whenever(localDataSource.getUser("1")).thenReturn(user)
        
        // 第一次调用,会走远程
        repository.getUser("1")
        
        // 第二次调用,缓存未过期
        val result = repository.getUser("1")
        
        assertEquals(user, result)
        // 远程只被调了一次
        verify(remoteDataSource, times(1)).getUser(any())
    }
    
    @Test
    fun `缓存过期后重新获取远程数据`() = runTest {
        val localUser = User("1", "本地用户")
        val remoteUser = User("1", "远程用户")
        whenever(localDataSource.getUser("1")).thenReturn(localUser)
        whenever(remoteDataSource.getUser("1")).thenReturn(remoteUser)
        
        // 第一次调用
        repository.getUser("1")
        
        // 模拟时间流逝
        advanceTimeBy(2000) // 超过1秒过期时间
        
        // 再次调用
        val result = repository.getUser("1")
        
        assertEquals(remoteUser, result)
        // 远程被调了两次
        verify(remoteDataSource, times(2)).getUser(any())
    }
}

注意:测试时间相关逻辑时,不要用 Thread.sleep()。用 TestCoroutineDispatcheradvanceTimeBy() 来模拟时间流逝,这样测试跑得快,也不会受真实时间影响。

错误处理测试:优雅降级

网络请求总会失败。Repository 的错误处理,决定了 App 在弱网或无网环境下的表现。

我曾经在一个项目中,因为 Repository 没有处理网络异常,导致用户断网后 App 直接崩溃。嗯,从那以后,我写 Repository 一定会加三层保护:

  1. 捕获异常:不让异常冒泡到上层
  2. 返回兜底数据:比如缓存数据或默认值
  3. 记录错误:方便排查问题
class ResilientUserRepository(
    private val localDataSource: UserDataSource,
    private val remoteDataSource: UserDataSource
) {
    suspend fun getUser(id: String): Result<User?> {
        return try {
            // 先尝试远程
            val remoteUser = remoteDataSource.getUser(id)
            if (remoteUser != null) {
                localDataSource.saveUser(remoteUser)
            }
            Result.success(remoteUser)
        } catch (e: IOException) {
            // 网络错误,尝试本地
            val localUser = localDataSource.getUser(id)
            if (localUser != null) {
                // 返回本地数据,但标记为过期
                Result.success(localUser)
            } else {
                // 本地也没有,返回错误
                Result.failure(e)
            }
        } catch (e: Exception) {
            // 其他异常,记录日志
            Log.e("UserRepo", "获取用户失败", e)
            Result.failure(e)
        }
    }
}

测试错误处理,核心是验证异常场景下的行为

class ResilientUserRepositoryTest {
    
    private val localDataSource = mock<UserDataSource>()
    private val remoteDataSource = mock<UserDataSource>()
    private lateinit var repository: ResilientUserRepository
    
    @Before
    fun setup() {
        repository = ResilientUserRepository(localDataSource, remoteDataSource)
    }
    
    @Test
    fun `网络异常时返回本地数据`() = runTest {
        val localUser = User("1", "本地用户")
        whenever(localDataSource.getUser("1")).thenReturn(localUser)
        whenever(remoteDataSource.getUser("1")).thenThrow(IOException("网络不通"))
        
        val result = repository.getUser("1")
        
        assertTrue(result.isSuccess)
        assertEquals(localUser, result.getOrNull())
    }
    
    @Test
    fun `网络异常且本地无数据时返回失败`() = runTest {
        whenever(localDataSource.getUser("1")).thenReturn(null)
        whenever(remoteDataSource.getUser("1")).thenThrow(IOException("网络不通"))
        
        val result = repository.getUser("1")
        
        assertTrue(result.isFailure)
        assertTrue(result.exceptionOrNull() is IOException)
    }
    
    @Test
    fun `远程返回null时不会缓存`() = runTest {
        whenever(remoteDataSource.getUser("1")).thenReturn(null)
        
        val result = repository.getUser("1")
        
        assertTrue(result.isSuccess)
        assertNull(result.getOrNull())
        // 验证没有调用缓存
        verify(localDataSource, never()).saveUser(any())
    }
}

关键点:错误处理测试要覆盖三种场景:

  • 可恢复错误(如网络超时):返回缓存数据
  • 不可恢复错误(如数据格式错误):返回失败结果
  • 边界情况(如远程返回 null):不缓存,不崩溃

知识体系总览

下面这张图总结了 Repository 单元测试的核心脉络:

Repository 单元测试知识体系 数据源测试 缓存策略测试 错误处理测试 验证数据流向 Mock 数据源接口 verify 调用次数 过期时间判断 advanceTimeBy 模拟 缓存刷新时机 可恢复错误 不可恢复错误 边界情况处理 目标:数据流动可预测,异常场景不崩溃

测试覆盖率建议

写 Repository 测试时,我一般会关注这几个覆盖率指标:

测试维度 核心验证点 建议覆盖率
数据源选择 本地有数据时不调远程,本地无数据时调远程 100%
缓存过期 过期前用缓存,过期后用远程 100%
网络异常 有缓存返回缓存,无缓存返回失败 100%
数据格式异常 不崩溃,返回失败结果 80%
并发请求 多个请求同时进来,数据一致 70%

我的经验:数据源选择和缓存过期这两个维度,我要求自己必须 100% 覆盖。因为这两个逻辑一旦出错,用户看到的就是错误数据,比崩溃还可怕。错误处理可以稍微放宽,但核心路径一定要测到。

写 Repository 测试,说白了就是给数据流动画一条「规则线」。数据从哪来、怎么缓存、出错怎么办——把这些规则写清楚、测明白,上层调用方就能安心使用。你想想看,如果每个 Repository 都经过这样的测试,整个 App 的数据层会稳定很多。

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