Android中如何测试viewModelScope下API请求失败的场景?
问题:Repository返回失败时,如何测试ViewModel的UI状态是否为Error?
问题背景
我通过密封接口实现了ViewModel的状态管理,现在需要编写测试用例,验证当Repository返回Result.failure(e)时,marsUiState是否正确切换为MarsUiState.Error。但当前测试始终失败,实际状态停留在Loading。
相关代码实现
MarsUiState 密封接口
sealed interface MarsUiState<out T> { data class Success<out T>(val photos: T) : MarsUiState<T> data class Error<out T: Throwable>(val error: T) : MarsUiState<Nothing> data object Loading : MarsUiState<Nothing> }
ViewModel 代码
class MarsViewModel( private val marsPhotosRepository: MarsPhotosRepository ) : ViewModel() { var marsUiState: MarsUiState<List<MarsPhoto>> by mutableStateOf(MarsUiState.Loading) private set init { getMarsPhotos() } internal fun getMarsPhotos() { viewModelScope.launch { marsUiState = marsPhotosRepository.getMarsPhotos().fold( onSuccess = { MarsUiState.Success(it) }, onFailure = { Log.d("viewModel Exception", "$it") MarsUiState.Error(it) } ) } } }
Repository 代码
fun interface MarsPhotosRepository { suspend fun getMarsPhotos(): Result<List<MarsPhoto>> } class NetworkMarsPhotosRepository(private val service: MarsApiService) : MarsPhotosRepository { override suspend fun getMarsPhotos(): Result<List<MarsPhoto>> = try { val response = service.getPhotos() Result.success(response) } catch (e: IOException) { Result.failure(e) } }
Retrofit 服务代码
fun interface MarsApiService { @GET("photos") suspend fun getPhotos(): List<MarsPhoto> }
MarsPhoto 模型
@Serializable data class MarsPhoto( @SerialName("id") val id: String, @SerialName("img_src") val imgSrc: String, )
现有测试代码及失败结果
测试代码:
@OptIn(ExperimentalCoroutinesApi::class) @Test fun `When connection fails, IOException is cached and uiState is set to Error`() = runTest { val expectedState: MarsUiState<Nothing> = MarsUiState.Error(IOException()) val throwableRepository = NetworkMarsPhotosRepository(FakeFailingMarsApiService(IOException())) val viewModelTest = MarsViewModel(throwableRepository) viewModelTest.getMarsPhotos() val realState = viewModelTest.marsUiState advanceTimeBy(10000) runCurrent() assertEquals(expectedState, realState) }
失败结果:
Expected :Error(error=java.io.IOException) Actual :Loading
问题原因及解决方案
核心问题分析
- 状态引用时机错误:测试中提前获取了
realState的引用,此时协程尚未执行完毕,状态仍为初始的Loading。后续协程更新状态后,realState保存的还是旧对象引用。 - 异常实例不匹配:测试中新建的
IOException和Repository抛出的不是同一个对象,直接用assertEquals会因引用不同导致断言失败。 - 协程调度未同步:未确保
viewModelScope中的协程在测试中完全执行完毕。
修正后的测试代码
@OptIn(ExperimentalCoroutinesApi::class) class MarsViewModelTest { @Test fun `when repository returns failure, ui state becomes Error`() = runTest { // 1. 创建统一的测试异常实例 val testException = IOException("Test connection failure") // 2. 直接Mock Repository,返回预期失败结果 val fakeRepository = object : MarsPhotosRepository { override suspend fun getMarsPhotos(): Result<List<MarsPhoto>> { return Result.failure(testException) } } // 3. 初始化ViewModel val viewModel = MarsViewModel(fakeRepository) // 4. 等待viewModelScope内的协程执行完成 runCurrent() // 5. 灵活断言状态:先检查类型,再验证异常属性 val actualState = viewModel.marsUiState assert(actualState is MarsUiState.Error) val errorState = actualState as MarsUiState.Error assert(errorState.error is IOException) assertEquals(testException.message, errorState.error.message) } }
关键调整点
- 简化Mock逻辑:直接实现
MarsPhotosRepository接口返回失败结果,避免依赖NetworkMarsPhotosRepository和FakeFailingMarsApiService,减少测试耦合。 - 延迟获取状态:在
runCurrent()之后再读取actualState,确保协程已执行完毕、状态已更新。 - 灵活断言:不直接比较
Error对象实例,而是检查状态类型和异常的类型/消息,避免引用匹配问题。 - 利用测试调度器:
runTest默认会将Dispatchers.Main替换为测试调度器,确保viewModelScope中的协程能立即执行,无需额外的advanceTimeBy。
可选:若需要全局配置测试调度器,可添加前后置方法:
@Before fun setup() { Dispatchers.setMain(UnconfinedTestDispatcher()) } @After fun tearDown() { Dispatchers.resetMain() }
内容的提问来源于stack exchange,提问作者Camilo Ruiz
相关产品推荐
相关产品推荐

