10、对话框测试:AlertDialog测试、确认/取消按钮测试
对话框测试,说白了就是测弹窗。你想想看,用户用App时最怕什么?最怕弹窗点不了、点错了、或者压根没弹出来。我这些年踩过的坑,有一半都跟对话框有关——不是按钮没响应,就是确认取消逻辑写反了。
今天我们就来聊聊,怎么用Compose的测试框架,把AlertDialog测得明明白白。
10.1 对话框测试的核心思路
对话框本质上也是一个Composable。它跟普通界面最大的区别在于:对话框是浮在原有界面之上的。所以测试时,我们要关注两件事:
- 对话框是否显示——用
assertIsDisplayed()判断 - 按钮点击是否生效——用
performClick()触发,然后验证状态变化
我个人习惯把对话框测试分成三类:
| 测试类型 | 验证点 | 常用断言 |
|---|---|---|
| 显示测试 | 对话框是否弹出 | assertIsDisplayed() |
| 按钮测试 | 确认/取消是否触发回调 | assert(showDialog == false) |
| 内容测试 | 标题、消息、图标是否正确 | assertTextEquals() |
核心原则:对话框测试不要只测UI显示,一定要测按钮点击后的逻辑变化。否则你测了个寂寞。
10.2 基础AlertDialog测试
先看一个最简单的例子。假设我们有一个删除确认对话框:
@Composable
fun DeleteConfirmDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("确认删除") },
text = { Text("删除后无法恢复,确定要删除吗?") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("确认删除")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
测试代码怎么写?我一般这样写:
@Test
fun deleteDialog_显示正确内容() {
// 准备
var dialogVisible = true
composeTestRule.setContent {
if (dialogVisible) {
DeleteConfirmDialog(
onConfirm = { dialogVisible = false },
onDismiss = { dialogVisible = false }
)
}
}
// 验证标题和消息
composeTestRule.onNodeWithText("确认删除").assertIsDisplayed()
composeTestRule.onNodeWithText("删除后无法恢复,确定要删除吗?").assertIsDisplayed()
}
@Test
fun deleteDialog_点击确认按钮_关闭对话框() {
// 准备
var dialogVisible = true
composeTestRule.setContent {
if (dialogVisible) {
DeleteConfirmDialog(
onConfirm = { dialogVisible = false },
onDismiss = { dialogVisible = false }
)
}
}
// 点击确认按钮
composeTestRule.onNodeWithText("确认删除").performClick()
// 验证对话框已关闭
composeTestRule.onNodeWithText("确认删除").assertDoesNotExist()
}
小技巧:用assertDoesNotExist()比assertIsNotDisplayed()更严格。前者确保节点完全不存在,后者只是不可见。我建议用assertDoesNotExist(),因为对话框关闭后节点应该被移除。
10.3 确认/取消按钮的边界测试
嗯,这里要注意。很多同学只测点击确认,不测点击取消。我曾经在一个项目里,取消按钮绑定了错误的回调,结果用户点取消反而执行了删除操作……那叫一个惨。
所以,确认和取消两个按钮都要测。而且最好测一下它们的行为是否互斥:
@Test
fun deleteDialog_点击取消_不执行删除逻辑() {
// 准备
var deleteExecuted = false
var dialogVisible = true
composeTestRule.setContent {
if (dialogVisible) {
DeleteConfirmDialog(
onConfirm = { deleteExecuted = true; dialogVisible = false },
onDismiss = { dialogVisible = false }
)
}
}
// 点击取消
composeTestRule.onNodeWithText("取消").performClick()
// 验证删除逻辑未执行
assertThat(deleteExecuted).isFalse()
// 验证对话框已关闭
composeTestRule.onNodeWithText("确认删除").assertDoesNotExist()
}
避坑指南:我曾经遇到过一个问题——对话框关闭后,onDismissRequest和dismissButton的点击回调同时触发,导致逻辑执行了两次。解决方案是:在回调里加一个if (dialogVisible)判断,防止重复执行。
10.4 带输入框的对话框测试
有些对话框不止有按钮,还有输入框。比如重命名对话框:
@Composable
fun RenameDialog(
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
var inputText by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("重命名") },
text = {
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
label = { Text("新名称") }
)
},
confirmButton = {
TextButton(onClick = { onConfirm(inputText) }) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
测试时,我们要模拟用户输入:
@Test
fun renameDialog_输入新名称_点击确认_返回新名称() {
// 准备
var resultName = ""
composeTestRule.setContent {
RenameDialog(
currentName = "旧名称",
onConfirm = { resultName = it },
onDismiss = {}
)
}
// 清空输入框并输入新名称
composeTestRule.onNodeWithText("旧名称").performTextClearance()
composeTestRule.onNodeWithText("").performTextInput("新名称")
// 点击确认
composeTestRule.onNodeWithText("确定").performClick()
// 验证回调结果
assertThat(resultName).isEqualTo("新名称")
}
注意:performTextClearance()会清空输入框,但不会触发onValueChange。如果你需要模拟逐字删除,可以用performTextInput("")配合performTextReplacement()。
10.5 对话框的异步测试
有时候对话框的显示需要等待异步操作完成。比如从网络获取数据后弹出提示:
@Test
fun asyncDialog_等待数据加载后_显示对话框() {
// 准备
composeTestRule.setContent {
val viewModel = remember { MyViewModel() }
MyScreen(viewModel = viewModel)
}
// 触发异步加载
composeTestRule.onNodeWithText("加载数据").performClick()
// 等待对话框出现(最多等2秒)
composeTestRule.waitUntil(2000) {
composeTestRule.onAllNodesWithText("加载完成").fetchSemanticsNodes().isNotEmpty()
}
// 验证对话框内容
composeTestRule.onNodeWithText("加载完成").assertIsDisplayed()
}
个人经验:waitUntil的轮询间隔默认是100ms。如果你的异步操作特别快(比如<50ms),建议用waitForIdle()代替,它会在Compose渲染空闲时立即返回。
10.6 对话框测试的常见陷阱
我整理了几个容易踩的坑,你写测试时多留个心眼:
- 对话框节点查找失败——对话框的语义节点可能不在主布局树里。用
onNodeWithText找不到时,试试onNode(hasText("xxx"))。 - 按钮文本重复——如果界面上有多个"确定"按钮,用
onAllNodesWithText配合[0]或[1]索引。 - 动画导致测试不稳定——对话框弹出动画没结束就点击按钮。解决方案:在测试前加
composeTestRule.mainClock.autoAdvance = true。 - 状态重置问题——每个测试用例之间,Compose状态不会自动重置。记得在每个测试里重新
setContent。
我曾经踩过的坑:有一个对话框,确认按钮的enabled状态依赖输入框内容。我测的时候没注意,直接点了禁用的按钮,结果测试一直报错。后来才发现,performClick()在按钮禁用时会抛出异常。解决方案:先assertIsEnabled()再点击。
10.7 知识体系图
下面这张图总结了对话框测试的核心要点,你可以把它当作测试清单:
10.8 总结
对话框测试其实不难,关键是要把显示、按钮、内容三个维度都覆盖到。我个人写测试时,会先写一个显示测试,再写两个按钮测试(确认和取消各一个),最后补一个内容测试。这样一套下来,基本不会漏测。
记住一句话:对话框测试不是为了测UI,而是为了测逻辑。按钮点下去之后,状态变了没有?回调执行了没有?这才是测试的重点。
最后分享一个我的习惯:每次写完对话框测试,我都会手动跑一遍App,确认测试覆盖的场景和真实操作一致。毕竟,自动化测试再完善,也替代不了人的直觉。
公众号:蓝海资料掘金营,微信deep3321