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()。用 TestCoroutineDispatcher 的 advanceTimeBy() 来模拟时间流逝,这样测试跑得快,也不会受真实时间影响。
错误处理测试:优雅降级
网络请求总会失败。Repository 的错误处理,决定了 App 在弱网或无网环境下的表现。
我曾经在一个项目中,因为 Repository 没有处理网络异常,导致用户断网后 App 直接崩溃。嗯,从那以后,我写 Repository 一定会加三层保护:
- 捕获异常:不让异常冒泡到上层
- 返回兜底数据:比如缓存数据或默认值
- 记录错误:方便排查问题
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 测试时,我一般会关注这几个覆盖率指标:
| 测试维度 | 核心验证点 | 建议覆盖率 |
|---|---|---|
| 数据源选择 | 本地有数据时不调远程,本地无数据时调远程 | 100% |
| 缓存过期 | 过期前用缓存,过期后用远程 | 100% |
| 网络异常 | 有缓存返回缓存,无缓存返回失败 | 100% |
| 数据格式异常 | 不崩溃,返回失败结果 | 80% |
| 并发请求 | 多个请求同时进来,数据一致 | 70% |
我的经验:数据源选择和缓存过期这两个维度,我要求自己必须 100% 覆盖。因为这两个逻辑一旦出错,用户看到的就是错误数据,比崩溃还可怕。错误处理可以稍微放宽,但核心路径一定要测到。
写 Repository 测试,说白了就是给数据流动画一条「规则线」。数据从哪来、怎么缓存、出错怎么办——把这些规则写清楚、测明白,上层调用方就能安心使用。你想想看,如果每个 Repository 都经过这样的测试,整个 App 的数据层会稳定很多。