You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

问题原因及解决方案

核心问题分析

  1. 状态引用时机错误:测试中提前获取了realState的引用,此时协程尚未执行完毕,状态仍为初始的Loading。后续协程更新状态后,realState保存的还是旧对象引用。
  2. 异常实例不匹配:测试中新建的IOException和Repository抛出的不是同一个对象,直接用assertEquals会因引用不同导致断言失败。
  3. 协程调度未同步:未确保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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.08 23:26:00