24、错误状态测试:加载失败测试、空状态测试、网络异常测试

说实话,UI测试里最容易忽略的就是错误状态。大家写测试时,往往习惯盯着「正常流程」——数据加载成功、列表展示完美、点击跳转正常。但真实用户遇到的情况,往往是网络断了、服务器挂了、数据为空。

我在项目中见过太多次这样的场景:开发同学自信满满地说「我的页面没问题」,结果一断网,整个App直接白屏。嗯,这就是我们今天要聊的核心——错误状态测试

为什么错误状态测试如此重要?

你想想看,用户打开App,网络不好,看到的不是友好的提示,而是一个空白页面或者转圈圈转个不停。这种体验,一次就足以让用户卸载。

我个人习惯把错误状态分成三类:

  • 加载失败:请求发出去了,但服务器返回了错误码(4xx、5xx)
  • 空状态:请求成功了,但数据列表是空的
  • 网络异常:压根没连上网,或者请求超时

这三种状态,每一种都需要我们单独写测试来覆盖。

核心原则:UI测试不仅要测「页面长什么样」,更要测「页面在异常情况下长什么样」。

测试前的准备工作

在Compose中测试错误状态,我们需要一个可控的「数据源」。说白了,就是不能让测试真的去发网络请求。我通常用StateFlow来模拟数据状态。

来看一个典型的ViewModel设计:

// 定义UI状态
data class UserListUiState(
    val isLoading: Boolean = false,
    val users: List<User> = emptyList(),
    val errorMessage: String? = null,
    val isEmpty: Boolean = false
)

// ViewModel中暴露状态
class UserListViewModel(
    private val repository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(UserListUiState())
    val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()

    fun loadUsers() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, errorMessage = null) }
            try {
                val users = repository.getUsers()
                if (users.isEmpty()) {
                    _uiState.update { it.copy(isLoading = false, isEmpty = true) }
                } else {
                    _uiState.update { it.copy(isLoading = false, users = users) }
                }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, errorMessage = "加载失败,请重试") }
            }
        }
    }
}

这里的关键是:ViewModel不直接依赖网络,而是依赖一个抽象的UserRepository。这样我们在测试时,就可以轻松替换成「会抛出异常」或「返回空列表」的假实现。

加载失败测试实战

加载失败,说白了就是网络请求抛异常了。我们需要验证:页面是否显示了错误提示?用户能否重试?

先写一个模拟失败场景的Repository:

class FakeFailingRepository : UserRepository {
    override suspend fun getUsers(): List<User> {
        throw IOException("网络连接失败")
    }
}

然后写测试:

@Test
fun loadUsers_whenNetworkFails_showsErrorMessage() {
    // 准备:使用会失败的Repository
    val viewModel = UserListViewModel(FakeFailingRepository())
    val composeTestRule = createComposeRule()

    composeTestRule.setContent {
        UserListScreen(viewModel = viewModel)
    }

    // 触发加载
    viewModel.loadUsers()

    // 验证:错误信息是否显示
    composeTestRule.onNodeWithText("加载失败,请重试").assertIsDisplayed()
    // 验证:重试按钮是否存在
    composeTestRule.onNodeWithText("重试").assertIsDisplayed()
}

我的小技巧:测试错误状态时,不要只检查「有没有文字」,还要检查「正常内容是否隐藏」。比如列表区域应该不可见,加载指示器应该消失。这样能避免「错误信息和正常内容同时显示」的bug。

空状态测试实战

空状态和加载失败不一样。空状态是请求成功了,但服务器返回了一个空数组。很多新手会忽略这个场景。

模拟空数据:

class FakeEmptyRepository : UserRepository {
    override suspend fun getUsers(): List<User> = emptyList()
}

测试代码:

@Test
fun loadUsers_whenDataIsEmpty_showsEmptyState() {
    val viewModel = UserListViewModel(FakeEmptyRepository())
    val composeTestRule = createComposeRule()

    composeTestRule.setContent {
        UserListScreen(viewModel = viewModel)
    }

    viewModel.loadUsers()

    // 验证空状态提示
    composeTestRule.onNodeWithText("暂无用户数据").assertIsDisplayed()
    // 验证列表不存在
    composeTestRule.onNodeWithTag("user_list").assertDoesNotExist()
}

这里我用onNodeWithTag来定位列表组件。我个人习惯给所有可组合项加上testTag,这样测试代码更稳定,不会因为文案变化而挂掉。

网络异常测试实战

网络异常和加载失败其实是一回事吗?不完全一样。加载失败可能是服务器返回500,但网络异常是客户端根本发不出请求。不过从UI测试的角度,我们关心的都是「异常发生后页面怎么展示」。

但有一个细节要注意:超时场景。我曾经遇到过一个bug,网络超时后页面一直显示加载中,没有任何提示。这是因为ViewModel里没有设置超时处理。

测试超时场景,我们可以用delay来模拟:

class FakeTimeoutRepository : UserRepository {
    override suspend fun getUsers(): List<User> {
        delay(30_000) // 模拟长时间无响应
        return emptyList()
    }
}

然后在测试中,我们可以验证:在合理时间内,是否显示了「加载超时」的提示。

@Test
fun loadUsers_whenTimeout_showsTimeoutMessage() {
    val viewModel = UserListViewModel(FakeTimeoutRepository())
    val composeTestRule = createComposeRule()

    composeTestRule.setContent {
        UserListScreen(viewModel = viewModel)
    }

    viewModel.loadUsers()

    // 等待一段时间后检查超时提示
    composeTestRule.waitUntil(timeoutMillis = 5_000) {
        composeTestRule.onNodeWithText("请求超时,请检查网络").isDisplayed()
    }
}

注意waitUntil的timeout不要设得太短,否则测试可能因为设备性能波动而误报。我一般设5秒,但实际项目中要根据你的超时策略来定。

错误状态测试的完整流程

为了让你更直观地理解,我画了一张流程图:

错误状态测试流程 用户触发加载 网络请求发出 请求结果? 成功/失败/空 成功 数据不为空 展示列表 空数据 展示空状态提示 隐藏列表组件 失败 展示错误信息 显示重试按钮 三种错误状态:加载失败 → 错误提示 + 重试 | 空数据 → 空状态占位 | 网络异常 → 超时提示

测试代码的组织技巧

写多了你会发现,错误状态测试的代码结构其实很相似。我建议用参数化测试来减少重复代码。

举个例子:

@RunWith(Parameterized::class)
class UserListErrorStateTest(
    private val repository: UserRepository,
    private val expectedMessage: String
) {
    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "{index}: {1}")
        fun data(): Collection<Array<Any>> {
            return listOf(
                arrayOf(FakeFailingRepository(), "加载失败,请重试"),
                arrayOf(FakeEmptyRepository(), "暂无用户数据"),
                arrayOf(FakeTimeoutRepository(), "请求超时,请检查网络")
            )
        }
    }

    @Test
    fun testErrorState() {
        val viewModel = UserListViewModel(repository)
        val composeTestRule = createComposeRule()

        composeTestRule.setContent {
            UserListScreen(viewModel = viewModel)
        }

        viewModel.loadUsers()
        composeTestRule.onNodeWithText(expectedMessage).assertIsDisplayed()
    }
}

这样写的好处是:加一种新的错误状态,只需要加一行数据,测试逻辑完全复用。

避坑指南

最后分享几个我踩过的坑:

  • 不要用真实网络测试错误状态。我曾经见过有人手动断网来测试,结果CI环境里网络不稳定,测试时断时续。一定要用Mock或Fake。
  • 注意异步时序。Compose的UI更新是异步的,测试中要用waitUntiladvanceUntilIdle来等待状态刷新。我刚开始写测试时,经常因为没等UI更新就断言,导致测试假失败。
  • 错误文案要统一。团队里最好约定一套错误提示的文案规范,比如「加载失败,请重试」而不是「出错了」或「网络错误」。这样测试代码更容易维护。
  • 别忘了测试「从错误状态恢复」。用户点击重试后,页面应该能正常加载。这个场景也要写测试。

我的习惯:每个页面的错误状态测试,我都会写三个独立的测试用例:一个测加载失败、一个测空状态、一个测网络异常。虽然看起来有点重复,但每个用例的失败原因一目了然,排查问题非常快。

错误状态测试,说白了就是「把用户可能遇到的坏情况,提前在测试里暴露出来」。你想想看,如果连测试都没覆盖到这些场景,那用户遇到时,大概率就是白屏或崩溃。嗯,这部分做好了,应用的健壮性会上一个台阶。

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