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()
}

避坑指南:我曾经遇到过一个问题——对话框关闭后,onDismissRequestdismissButton的点击回调同时触发,导致逻辑执行了两次。解决方案是:在回调里加一个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 对话框测试的常见陷阱

我整理了几个容易踩的坑,你写测试时多留个心眼:

  1. 对话框节点查找失败——对话框的语义节点可能不在主布局树里。用onNodeWithText找不到时,试试onNode(hasText("xxx"))
  2. 按钮文本重复——如果界面上有多个"确定"按钮,用onAllNodesWithText配合[0][1]索引。
  3. 动画导致测试不稳定——对话框弹出动画没结束就点击按钮。解决方案:在测试前加composeTestRule.mainClock.autoAdvance = true
  4. 状态重置问题——每个测试用例之间,Compose状态不会自动重置。记得在每个测试里重新setContent

我曾经踩过的坑:有一个对话框,确认按钮的enabled状态依赖输入框内容。我测的时候没注意,直接点了禁用的按钮,结果测试一直报错。后来才发现,performClick()在按钮禁用时会抛出异常。解决方案:先assertIsEnabled()再点击。

10.7 知识体系图

下面这张图总结了对话框测试的核心要点,你可以把它当作测试清单:

对话框测试知识体系 AlertDialog测试 显示测试 assertIsDisplayed() assertDoesNotExist() 按钮测试 确认按钮点击 取消按钮点击 回调验证 内容测试 标题验证 消息验证 常见陷阱 节点查找失败 | 按钮文本重复 | 动画干扰 | 状态未重置 | 禁用按钮点击

10.8 总结

对话框测试其实不难,关键是要把显示、按钮、内容三个维度都覆盖到。我个人写测试时,会先写一个显示测试,再写两个按钮测试(确认和取消各一个),最后补一个内容测试。这样一套下来,基本不会漏测。

记住一句话:对话框测试不是为了测UI,而是为了测逻辑。按钮点下去之后,状态变了没有?回调执行了没有?这才是测试的重点。

最后分享一个我的习惯:每次写完对话框测试,我都会手动跑一遍App,确认测试覆盖的场景和真实操作一致。毕竟,自动化测试再完善,也替代不了人的直觉。


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