第17章:测试驱动开发:单元测试、MockK与Turbine测试Flow
说实话,很多Android开发者对测试的态度是「知道很重要,但就是不想写」。我以前也这样。直到有一次,我接手了一个遗留项目,改一个登录逻辑,结果把支付流程搞崩了。嗯,从那以后,我再也不敢轻视单元测试了。
这一章,我们来聊聊测试驱动开发(TDD)在MVVM + Clean Architecture中的落地。我会重点讲三个东西:单元测试怎么写、MockK怎么用、以及如何用Turbine测试Flow。
核心观点:测试不是负担,是你重构时的底气。没有测试的代码,就像没有安全绳的攀岩。
17.1 单元测试在MVVM中的位置
在Clean Architecture中,我们通常把业务逻辑放在Domain层,数据操作放在Data层。这两层是最适合写单元测试的。为什么?因为它们不依赖Android框架。
我个人习惯把测试分成三层:
- Domain层测试:测试UseCase和Entity的逻辑。纯Kotlin,跑得飞快。
- Data层测试:测试Repository和DataSource。需要Mock掉网络和数据库。
- ViewModel测试:测试UI状态和事件。需要Mock UseCase,并验证StateFlow。
你想想看,如果这三层都测好了,UI层出问题的概率就极低。我在项目中遇到过好几次,UI层看起来有问题,最后定位发现是Repository返回了错误数据。有了测试,这种问题几分钟就能定位。
17.2 测试工具选型
在Kotlin生态里,我推荐以下组合:
| 工具 | 用途 | 为什么选它 |
|---|---|---|
| JUnit 5 | 测试框架 | Kotlin原生支持,注解简洁 |
| MockK | Mock框架 | 专为Kotlin设计,支持协程和Flow |
| Turbine | Flow测试 | 轻量级,API直观 |
| Truth | 断言库 | 可读性强,错误信息友好 |
小提示:MockK比Mockito更适合Kotlin。因为Mockito对Kotlin的扩展函数、协程支持不太好。我曾经在Mockito上折腾了半天,换成MockK十分钟搞定。
17.3 实战:测试一个UseCase
我们先从Domain层开始。假设有一个GetUserProfileUseCase:
class GetUserProfileUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(userId: String): Result<UserProfile> {
return userRepository.getProfile(userId)
}
}
测试这个UseCase,我们需要Mock掉UserRepository。用MockK写起来是这样的:
class GetUserProfileUseCaseTest {
private val userRepository = mockk<UserRepository>()
private val useCase = GetUserProfileUseCase(userRepository)
@Test
fun `should return profile when repository succeeds`() = runTest {
// 准备
val userId = "123"
val expectedProfile = UserProfile(userId, "张三", "avatar.png")
coEvery { userRepository.getProfile(userId) } returns Result.success(expectedProfile)
// 执行
val result = useCase(userId)
// 验证
assertThat(result).isEqualTo(Result.success(expectedProfile))
coVerify { userRepository.getProfile(userId) }
}
@Test
fun `should return error when repository fails`() = runTest {
// 准备
val userId = "456"
val error = RuntimeException("网络错误")
coEvery { userRepository.getProfile(userId) } returns Result.failure(error)
// 执行
val result = useCase(userId)
// 验证
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isInstanceOf(RuntimeException::class.java)
}
}
这里有几个关键点:
runTest是Kotlin协程测试的标配,它会自动处理协程调度。coEvery和coVerify是MockK对挂起函数的支持。- 用
Result包装返回值,测试起来非常方便。
注意:不要忘记在测试类上添加@OptIn(ExperimentalCoroutinesApi::class),否则runTest会报错。我刚开始用的时候就被这个坑过。
17.4 测试ViewModel中的StateFlow
ViewModel是MVVM的核心。它把UseCase的结果转换成UI状态。测试ViewModel时,我们需要验证StateFlow发出的值是否正确。
来看一个例子:
class UserViewModel(
private val getProfileUseCase: GetUserProfileUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<UserProfile>>(UiState.Loading)
val uiState: StateFlow<UiState<UserProfile>> = _uiState.asStateFlow()
fun loadProfile(userId: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
val result = getProfileUseCase(userId)
_uiState.value = result.fold(
onSuccess = { UiState.Success(it) },
onFailure = { UiState.Error(it.message ?: "未知错误") }
)
}
}
}
用Turbine测试这个Flow,代码非常简洁:
class UserViewModelTest {
private val getProfileUseCase = mockk<GetUserProfileUseCase>()
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
viewModel = UserViewModel(getProfileUseCase)
}
@Test
fun `should emit loading then success`() = runTest {
// 准备
val userId = "1"
val profile = UserProfile(userId, "李四", "avatar.png")
coEvery { getProfileUseCase(userId) } returns Result.success(profile)
// 执行 & 验证
viewModel.loadProfile(userId)
viewModel.uiState.test {
// 第一个值应该是 Loading
assertThat(awaitItem()).isEqualTo(UiState.Loading)
// 第二个值应该是 Success
assertThat(awaitItem()).isEqualTo(UiState.Success(profile))
// 确保没有更多值
awaitComplete()
}
}
@Test
fun `should emit loading then error`() = runTest {
// 准备
val userId = "2"
coEvery { getProfileUseCase(userId) } returns Result.failure(RuntimeException("网络异常"))
// 执行 & 验证
viewModel.loadProfile(userId)
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(UiState.Loading)
assertThat(awaitItem()).isEqualTo(UiState.Error("网络异常"))
awaitComplete()
}
}
}
Turbine的test函数会自动收集Flow,awaitItem会等待下一个值发出。如果超时(默认1秒),它会抛出异常。这个机制特别好用,能帮你发现「Flow该发却没发」的问题。
避坑指南:我曾经在测试中忘记调用awaitComplete(),结果测试通过了,但Flow实际上多发了几个值。加上awaitComplete()后,测试立刻失败,帮我抓到了一个隐藏的bug。
17.5 测试Repository中的Flow
Data层的Repository通常会返回Flow,比如从Room数据库读取数据。测试这种Flow,Turbine同样好用。
class UserRepositoryImplTest {
private val localDataSource = mockk<UserLocalDataSource>()
private val remoteDataSource = mockk<UserRemoteDataSource>()
private val repository = UserRepositoryImpl(localDataSource, remoteDataSource)
@Test
fun `should emit cached data then remote data`() = runTest {
// 准备
val cachedUser = UserProfile("1", "缓存用户", "cached.png")
val remoteUser = UserProfile("1", "远程用户", "remote.png")
every { localDataSource.observeUser("1") } returns flowOf(cachedUser)
coEvery { remoteDataSource.fetchUser("1") } returns Result.success(remoteUser)
coEvery { localDataSource.saveUser(remoteUser) } returns Unit
// 执行
repository.getUserProfile("1").test {
// 先发缓存数据
assertThat(awaitItem()).isEqualTo(cachedUser)
// 再发远程数据
assertThat(awaitItem()).isEqualTo(remoteUser)
awaitComplete()
}
}
}
这个测试验证了一个常见的模式:先展示缓存数据,再请求网络更新。如果网络请求失败,应该只发缓存数据。这种场景用Turbine测起来特别直观。
17.6 测试中的常见陷阱
写测试多了,你会发现一些容易踩的坑。我列几个最常见的:
- 协程调度器没处理好:测试中一定要用
Dispatchers.Unconfined或runTest,否则协程可能不会按你期望的顺序执行。 - Mock对象没重置:每个测试方法之间,Mock对象的状态是共享的。记得用
clearMocks()或@Before重新初始化。 - Flow的冷流特性:Flow是冷流,如果没有收集者,它不会执行。Turbine的
test函数会自动收集,但如果你自己写收集逻辑,别忘了调用collect。 - 超时设置不合理:Turbine默认超时1秒。如果你的UseCase里有网络请求,记得调整超时时间,或者用
coEvery直接返回结果,不要真的去请求网络。
我的习惯:在项目的build.gradle里统一配置测试超时时间,比如testOptions { timeoutPerTest = 5_000 }。这样所有测试共享一个超时策略,省得每个测试单独配。
17.7 测试覆盖率不是目标
最后我想说一句:测试覆盖率只是一个数字,别太迷信它。我见过一些项目,覆盖率90%以上,但核心逻辑根本没测到。为什么?因为测试都写在了getter/setter上。
我个人更看重「关键路径覆盖率」。比如:
- 用户登录流程
- 支付流程
- 数据同步逻辑
- 错误处理分支
这些路径测好了,项目质量就有保障。至于那些工具类、常量类,测不测其实无所谓。
总结一下:测试驱动开发不是让你先写测试再写代码,而是让你在写代码时始终想着「这段逻辑怎么测」。用MockK处理依赖,用Turbine验证Flow,你会发现测试其实没那么难。而且,当你重构代码时,看到所有测试都变绿,那种安全感,真的很爽。
工具链总结:JUnit 5 跑测试,MockK 做Mock,Turbine 测Flow,Truth 做断言。这四个工具配合起来,覆盖了MVVM架构中90%的测试场景。