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更新是异步的,测试中要用
waitUntil或advanceUntilIdle来等待状态刷新。我刚开始写测试时,经常因为没等UI更新就断言,导致测试假失败。 - 错误文案要统一。团队里最好约定一套错误提示的文案规范,比如「加载失败,请重试」而不是「出错了」或「网络错误」。这样测试代码更容易维护。
- 别忘了测试「从错误状态恢复」。用户点击重试后,页面应该能正常加载。这个场景也要写测试。
我的习惯:每个页面的错误状态测试,我都会写三个独立的测试用例:一个测加载失败、一个测空状态、一个测网络异常。虽然看起来有点重复,但每个用例的失败原因一目了然,排查问题非常快。
错误状态测试,说白了就是「把用户可能遇到的坏情况,提前在测试里暴露出来」。你想想看,如果连测试都没覆盖到这些场景,那用户遇到时,大概率就是白屏或崩溃。嗯,这部分做好了,应用的健壮性会上一个台阶。