第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协程测试的标配,它会自动处理协程调度。
  • coEverycoVerify是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.UnconfinedrunTest,否则协程可能不会按你期望的顺序执行。
  • 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,你会发现测试其实没那么难。而且,当你重构代码时,看到所有测试都变绿,那种安全感,真的很爽。

MVVM + Clean Architecture 测试分层 UI层(不写单元测试) Activity / Fragment / Compose — 依赖Android框架,写Instrumented Test ViewModel层(重点测试) 验证 StateFlow 发出的值 — 使用 Turbine + MockK Domain层(核心测试) 测试 UseCase 的业务逻辑 — 纯Kotlin,跑得最快 Data层(依赖测试) 测试 Repository 的数据流 — Mock 网络/数据库,验证 Flow 发射顺序

工具链总结:JUnit 5 跑测试,MockK 做Mock,Turbine 测Flow,Truth 做断言。这四个工具配合起来,覆盖了MVVM架构中90%的测试场景。

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