第1课建立了声明式 UI 的基本认知,第2课掌握了布局系统,第3课深入了状态管理与副作用,第4课完成了导航与多页面架构,第5课构建了主题与设计系统,第6课让界面"活"了起来,第7课让界面面向全球用户,第8课让界面流畅运行。第9课聚焦 Compose 的测试与质量保证。
本课用真实 App 场景深入理解:createComposeRule 与 createAndroidComposeRule 的选择、语义树查找器与断言、testTag 与 contentDescription 的测试用法、节点交互与手势操作、同步机制与 waitUntil、Hilt 集成测试、KMP 共享代码测试,以及完整的测试策略。
每个案例都按"真实场景 → 设计目标 → 界面拆解 → 完整代码(逐行注释) → UI 设计解读 → 常见陷阱 → 可改进方向(含参考答案) → 关键要点"展开。
课后共 8 道练习,每道练习后紧跟参考答案(逐行注释)和设计解读。

一、为什么测试是 Compose UI 设计的最后一块拼图?

前八课我们做的界面"好看、好动、好用、好访问、好流畅",但有一个关键问题被忽略了:这些界面,正确吗?

你写了一个登录页面,逻辑看起来没问题。但用户输入密码后点击登录,按钮没有反应——因为你在 enabled 条件里写错了变量名。你写了一个商品列表,数据加载后界面不更新——因为你忘了用 mutableStateOf 包装列表。你写了一个导航流程,从首页进入详情页后按返回键却退出了 App——因为返回栈的 popUpTo 配置错了。

这些问题,人工测试也能发现,但成本极高。每改一行代码,就要把所有页面重新点一遍。而 Compose 的测试 API 让你可以自动化验证界面行为:输入文字、点击按钮、断言文字显示、验证导航跳转。改完代码跑一遍测试,几分钟就能确认没有回归。

一个真实的教训

某团队在开发一个电商 App 时,商品详情页的"加入购物车"按钮有一个 bug:当商品库存为 0 时,按钮仍然可点击,点击后弹出"添加成功"的 Toast,但购物车中并没有商品。这个 bug 在人工测试中被遗漏了,因为测试人员总是用有库存的商品测试。上线后,用户投诉"加入购物车没反应"。

如果当时有一个自动化测试:

@Test
fun addToCart_whenStockIsZero_buttonShouldBeDisabled() {
    composeTestRule.setContent {
        AddToCartButton(stock = 0, onAddToCart = {})
    }
    composeTestRule.onNodeWithText("加入购物车")
        .assertIsNotEnabled()
}

这个 bug 在提交代码时就会被发现。自动化测试的价值不在于"发现 bug",而在于"防止 bug 回归"——每次改代码,跑一遍测试,几分钟就能确认没有破坏已有功能。

Compose 测试的核心:语义树

第7课我们讲过,Compose 的 UI 对 TalkBack 来说是一棵语义树。测试 API 也使用同一棵语义树来查找元素。这是 Compose 测试的核心机制:测试和辅助技术看到的是同一个界面抽象。

语义树(测试看到的)
├── 节点 #1 (Button, Text="登录", Enabled=true)
├── 节点 #2 (TextField, Text="密码", EditableText="123456")
├── 节点 #3 (Text, Text="密码至少 6 位")
└── 节点 #4 (Text, Text="欢迎登录")

测试 API 通过查找器选择节点,通过断言验证属性,通过操作注入用户事件。这与第7课的可访问性语义树是同一个东西——测试和辅助技术共享同一套语义基础设施。这意味着:为可访问性添加的 contentDescription 和 mergeDescendants,同时也是测试的查找依据。做好可访问性就等于为测试打好了基础。

测试的两个规则:createComposeRule vs createAndroidComposeRule

Compose 测试的核心是 ComposeTestRule。有两种创建方式:

规则用途适用场景
createComposeRule()不依赖 Activity纯 Composable 测试,如单个组件的渲染和交互
createAndroidComposeRule<A>()依赖 Activity集成测试,需要访问 Activity、导航、Hilt

选择原则: 如果测试只需要验证 Composable 的渲染和交互,用 createComposeRule()。如果需要启动 Activity、测试导航、集成 Hilt,用 createAndroidComposeRule<A>()。

用一个比喻: createComposeRule 像在实验室里测试一个零件,createAndroidComposeRule 像把零件装到整车上测试。前者更快更专注,后者更真实但更慢。

测试的四层金字塔

层次测试类型工具运行速度覆盖范围
单元测试ViewModel、RepositoryJUnit + MockK快(毫秒)逻辑正确性
Compose UI 测试单个 ComposablecreateComposeRule中(秒)渲染和交互
集成测试页面 + 导航 + HiltcreateAndroidComposeRule慢(秒-分钟)端到端流程
截图测试视觉回归Paparazzi/Roborazzi中视觉一致性

测试金字塔的核心理念: 越底层的测试越多、越快、越稳定;越高层的测试越少、越慢、越脆弱。不要把所有测试都写成端到端测试,那样运行一次要几十分钟,开发效率会急剧下降。

本课案例地图

案例核心技术真实场景
一createComposeRule + 语义查找器登录表单测试
二testTag + contentDescription图标按钮测试
三节点操作 + 手势列表滚动 + 滑动删除
四同步机制 + waitUntil异步数据加载测试
五Hilt + createAndroidComposeRule登录流程集成测试
六导航测试多页面流程测试
七KMP 共享代码测试跨平台逻辑测试
八综合案例 + 测试策略完整电商 App 测试

案例一:登录表单——用 createComposeRule 和语义查找器测试

1. 真实场景

你在做一个登录页面,包含用户名输入框、密码输入框、登录按钮。你需要测试以下行为:

  • 初始状态下,登录按钮禁用。
  • 输入用户名和密码后,登录按钮变为可点击。
  • 密码少于 6 位时,显示错误提示。
  • 点击登录按钮后,回调被触发并携带正确的用户名和密码。

这些测试不需要启动 Activity,用 createComposeRule() 就足够了。

2. 设计目标

  • 用 createComposeRule() 创建测试规则。
  • 用 onNodeWithText 和 onNodeWithTag 查找节点。
  • 用 performTextInput、performClick 执行操作。
  • 用 assertIsDisplayed、assertIsEnabled、assertIsNotEnabled 断言。
  • 用捕获变量验证回调参数。

3. 界面拆解

  • 查找器:onNodeWithText、onNodeWithTag、onNodeWithContentDescription。
  • 操作:performTextInput、performClick、performTextClearance。
  • 断言:assertIsDisplayed、assertIsEnabled、assertIsNotEnabled、assertTextEquals。

测试的 AAA 模式:

  1. Arrange:设置测试内容,准备数据。
  2. Act:执行用户操作(输入、点击)。
  3. Assert:验证结果(断言状态、捕获回调)。

4. 完整代码(逐行注释)

// ============ 被测组件 ============
@Composable
fun LoginForm( // 登录表单
    onLoginClick: (String, String) -> Unit // 登录回调
) {
    var username by remember { mutableStateOf("") } // 用户名
    var password by remember { mutableStateOf("") } // 密码
    var error by remember { mutableStateOf<String?>(null) } // 错误

    val canSubmit = username.isNotBlank() && password.length >= 6 // 能否提交

    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.spacedBy(12.dp) // 间距
    ) {
        OutlinedTextField( // 用户名输入框
            value = username, // 值
            onValueChange = { // 变化
                username = it // 更新
                error = null // 清空错误
            },
            label = { Text("用户名") }, // 标签
            modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .testTag("username_field") // 测试标签
        )

        OutlinedTextField( // 密码输入框
            value = password, // 值
            onValueChange = { // 变化
                password = it // 更新
                error = if (it.length in 1..5) "密码至少 6 位" else null // 校验
            },
            label = { Text("密码") }, // 标签
            isError = error != null, // 错误状态
            modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .testTag("password_field") // 测试标签
        )

        if (error != null) { // 错误提示
            Text( // 错误文字
                text = error!!, // 内容
                color = MaterialTheme.colorScheme.error, // 错误色
                modifier = Modifier.testTag("error_text") // 测试标签
            )
        }

        Button( // 登录按钮
            onClick = { // 点击
                if (username.isBlank()) { // 用户名为空
                    error = "请输入用户名" // 错误
                } else if (password.length < 6) { // 密码太短
                    error = "密码至少 6 位" // 错误
                } else { // 校验通过
                    onLoginClick(username, password) // 回调
                }
            },
            enabled = canSubmit, // 能否点击
            modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .testTag("login_button") // 测试标签
        ) {
            Text("登录") // 按钮文字
        }
    }
}

// ============ 测试代码 ============
class LoginFormTest { // 登录表单测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 创建 Compose 测试规则

    @Test // 测试:初始状态按钮禁用
    fun loginButton_disabledInitially() { // 初始禁用
        // Arrange
        composeTestRule.setContent { // 设置内容
            LoginForm(onLoginClick = { _, _ -> }) // 登录表单
        }

        // Assert
        composeTestRule.onNodeWithTag("login_button") // 查找按钮
            .assertIsNotEnabled() // 断言禁用
    }

    @Test // 测试:输入后按钮可用
    fun loginButton_enabledAfterValidInput() { // 输入有效后按钮可用
        // Arrange
        composeTestRule.setContent { // 设置内容
            LoginForm(onLoginClick = { _, _ -> }) // 登录表单
        }

        // Act
        composeTestRule.onNodeWithTag("username_field") // 查找用户名输入框
            .performTextInput("张三") // 输入文字
        composeTestRule.onNodeWithTag("password_field") // 查找密码输入框
            .performTextInput("123456") // 输入文字

        // Assert
        composeTestRule.onNodeWithTag("login_button") // 查找按钮
            .assertIsEnabled() // 断言可用
    }

    @Test // 测试:密码太短显示错误
    fun passwordTooShort_showsError() { // 密码太短显示错误
        // Arrange
        composeTestRule.setContent { // 设置内容
            LoginForm(onLoginClick = { _, _ -> }) // 登录表单
        }

        // Act
        composeTestRule.onNodeWithTag("password_field") // 查找密码输入框
            .performTextInput("123") // 输入文字

        // Assert
        composeTestRule.onNodeWithTag("error_text") // 查找错误文字
            .assertTextEquals("密码至少 6 位") // 断言文字
    }

    @Test // 测试:点击登录触发回调
    fun loginClick_triggersCallback() { // 点击登录触发回调
        // Arrange
        var capturedUsername = "" // 捕获的用户名
        var capturedPassword = "" // 捕获的密码

        composeTestRule.setContent { // 设置内容
            LoginForm( // 登录表单
                onLoginClick = { username, password -> // 回调
                    capturedUsername = username // 捕获
                    capturedPassword = password // 捕获
                }
            )
        }

        // Act
        composeTestRule.onNodeWithTag("username_field") // 用户名
            .performTextInput("张三") // 输入
        composeTestRule.onNodeWithTag("password_field") // 密码
            .performTextInput("123456") // 输入
        composeTestRule.onNodeWithTag("login_button") // 按钮
            .performClick() // 点击

        // Assert
        assertEquals("张三", capturedUsername) // 断言用户名
        assertEquals("123456", capturedPassword) // 断言密码
    }
}

5. UI 设计解读

第一,createComposeRule() 的适用场景。 这个测试不需要启动 Activity,只需要验证 Composable 的渲染和交互。createComposeRule() 提供了 setContent 方法,可以直接设置被测内容。它比 createAndroidComposeRule() 更快,适合测试单个组件。记住:测试越轻量,运行越快,反馈越及时。

第二,testTag 的核心作用。 Modifier.testTag("username_field") 为组件添加一个测试标签,测试中通过 onNodeWithTag 查找。testTag 不会影响生产环境,它只在测试时可用。如果组件有多个相同文本的元素,用 testTag 比用文本更可靠。

第三,语义查找器的选择。 onNodeWithText 适合查找有文字的元素。onNodeWithTag 适合查找有测试标签的元素。onNodeWithContentDescription 适合查找有内容描述的元素(如图标按钮)。优先使用 testTag,因为文本可能变化(如国际化),但 testTag 是稳定的。

第四,AAA 测试模式。 每个测试都遵循 Arrange(准备)、Act(执行)、Assert(断言)三个阶段。这让测试代码结构清晰,一眼就能看出"测试了什么"和"期望什么"。好的测试名应该是"被测行为_条件_期望结果"的格式。

第五,测试的稳定性。 Compose 测试默认在 UI 空闲时执行操作和断言。performTextInput 输入文字后,Compose 会等待重组完成再返回。这保证了测试的稳定性,不需要手动 Thread.sleep。

6. 常见陷阱

  • 用 Thread.sleep 等待异步操作:应该用 waitUntil 或 IdlingResource。
  • 用文本查找元素但文本会变化:应该用 testTag。
  • 测试中创建真实的网络请求:应该用 Mock 或 Fake 替代。
  • 忘记 debugImplementation("androidx.compose.ui:ui-test-manifest") :createComposeRule() 需要这个依赖。
  • setContent 中调用多次:一个测试只能调用一次 setContent。
  • 测试名不清晰:test1、test2 这样的命名无法表达测试意图。

7. 可改进方向(含参考答案)

可改进点
  • 用 onAllNodesWithTag 测试多个节点。
  • 用 performTextClearance 测试清空输入。
  • 用 assertCountEquals 测试列表项数量。
增强版参考答案
@Test // 测试:多个列表项
fun list_containsCorrectItems() { // 列表包含正确项
    composeTestRule.setContent { // 设置内容
        MyAppTheme { // 主题
            ProductList( // 商品列表
                products = listOf("商品 A", "商品 B", "商品 C") // 数据
            )
        }
    }

    // 断言列表项数量
    composeTestRule.onAllNodesWithTag("product_item") // 所有商品项
        .assertCountEquals(3) // 断言数量
}

@Test // 测试:清空输入
fun clearTextField() { // 清空输入
    composeTestRule.setContent { // 设置内容
        LoginForm(onLoginClick = { _, _ -> }) // 登录表单
    }

    composeTestRule.onNodeWithTag("username_field") // 用户名输入框
        .performTextInput("张三") // 输入

    composeTestRule.onNodeWithTag("username_field") // 用户名输入框
        .performTextClearance() // 清空

    composeTestRule.onNodeWithTag("username_field") // 用户名输入框
        .assertTextEquals("") // 断言为空
}
改进解读

onAllNodesWithTag 返回一个 SemanticsNodeInteractionCollection,可以对其执行 assertCountEquals、filter 等操作。performTextClearance 清空输入框的内容。这些 API 让测试可以覆盖更复杂的交互场景。建议为每个测试写一个清晰的测试名,说明测试的行为和期望。

8. 关键要点

  • createComposeRule() 适合不需要 Activity 的 Composable 测试。
  • testTag 为组件添加稳定的测试标识,不受文本变化影响。
  • performTextInput、performClick 执行用户操作。
  • assertIsEnabled、assertTextEquals 验证组件状态。
  • Compose 测试自动等待 UI 空闲,不需要手动延迟。
  • 测试命名遵循"被测行为_条件_期望结果"格式。

案例二:图标按钮——用 contentDescription 和 testTag 测试

1. 真实场景

你在做一个工具栏,包含返回按钮、搜索按钮、购物车按钮。每个按钮都是一个 IconButton,没有可见文本。测试这些按钮需要特殊的查找方式。

2. 设计目标

  • 用 onNodeWithContentDescription 查找图标按钮。
  • 用 testTag 为图标按钮添加稳定标识。
  • 用 assertContentDescriptionEquals 验证描述。
  • 理解 useUnmergedTree 参数的使用场景。

3. 界面拆解

  • 图标按钮:IconButton 包含 Icon,Icon 的 contentDescription 是测试查找的关键。
  • useUnmergedTree :默认情况下,Compose 会合并子节点的语义。如果按钮合并了子节点的语义,contentDescription 可以在父节点上找到。如果需要访问未合并的树,设置 useUnmergedTree = true。
  • printToLog :在测试中打印语义树结构,帮助调试查找器。

关键决策:testTag 还是 contentDescription?

维度testTagcontentDescription
用途测试专用可访问性 + 测试
是否影响生产否是(TalkBack 会朗读)
稳定性高中(可能随 UI 变化)
推荐优先使用图标按钮必须使用

推荐:同时使用两者。 testTag 用于测试查找,contentDescription 用于可访问性。测试中优先用 testTag。

4. 完整代码(逐行注释)

// ============ 被测组件 ============
@Composable
fun AppToolbar( // 应用工具栏
    onBackClick: () -> Unit, // 返回回调
    onSearchClick: () -> Unit, // 搜索回调
    onCartClick: () -> Unit, // 购物车回调
    cartCount: Int // 购物车数量
) {
    Row( // 横向布局
        modifier = Modifier
            .fillMaxWidth() // 占满宽度
            .padding(16.dp), // 内边距
        horizontalArrangement = Arrangement.SpaceBetween // 两端分布
    ) {
        IconButton( // 返回按钮
            onClick = onBackClick, // 点击
            modifier = Modifier.testTag("back_button") // 测试标签
        ) {
            Icon( // 图标
                Icons.AutoMirrored.Filled.ArrowBack, // 自动镜像返回
                contentDescription = "返回上一页" // 内容描述
            )
        }

        Row { // 右侧按钮组
            IconButton( // 搜索按钮
                onClick = onSearchClick, // 点击
                modifier = Modifier.testTag("search_button") // 测试标签
            ) {
                Icon( // 图标
                    Icons.Default.Search, // 搜索图标
                    contentDescription = "搜索" // 内容描述
                )
            }

            Box { // 购物车按钮容器
                IconButton( // 购物车按钮
                    onClick = onCartClick, // 点击
                    modifier = Modifier.testTag("cart_button") // 测试标签
                ) {
                    Icon( // 图标
                        Icons.Default.ShoppingCart, // 购物车图标
                        contentDescription = null // 由父级统一描述
                    )
                }

                if (cartCount > 0) { // 有商品时显示角标
                    Box( // 角标
                        modifier = Modifier
                            .align(Alignment.TopEnd) // 右上角
                            .size(18.dp) // 尺寸
                            .clip(CircleShape) // 圆形
                            .background(MaterialTheme.colorScheme.error) // 错误色
                    ) {
                        Text( // 数量
                            text = cartCount.toString(), // 数量
                            color = MaterialTheme.colorScheme.onError, // 对比色
                            style = MaterialTheme.typography.labelSmall, // 小标签
                            modifier = Modifier.align(Alignment.Center) // 居中
                        )
                    }
                }
            }
            .semantics(mergeDescendants = true) { // 合并语义
                contentDescription = "购物车,$cartCount 件商品" // 统一描述
            }
        }
    }
}

// ============ 测试代码 ============
class AppToolbarTest { // 工具栏测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 测试规则

    @Test // 测试:返回按钮有正确的描述
    fun backButton_hasCorrectDescription() { // 返回按钮描述
        composeTestRule.setContent { // 设置内容
            AppToolbar( // 工具栏
                onBackClick = {}, // 返回
                onSearchClick = {}, // 搜索
                onCartClick = {}, // 购物车
                cartCount = 0 // 数量
            )
        }

        // 通过 contentDescription 查找
        composeTestRule.onNodeWithContentDescription("返回上一页") // 查找节点
            .assertExists() // 断言存在
            .assertIsDisplayed() // 断言显示

        // 通过 testTag 查找
        composeTestRule.onNodeWithTag("back_button") // 查找节点
            .assertExists() // 断言存在
    }

    @Test // 测试:购物车按钮有数量描述
    fun cartButton_hasCountDescription() { // 购物车按钮数量描述
        composeTestRule.setContent { // 设置内容
            AppToolbar( // 工具栏
                onBackClick = {}, // 返回
                onSearchClick = {}, // 搜索
                onCartClick = {}, // 购物车
                cartCount = 3 // 数量
            )
        }

        // 查找合并后的语义节点
        composeTestRule.onNodeWithContentDescription("购物车,3 件商品") // 查找
            .assertExists() // 断言存在
    }

    @Test // 测试:点击搜索按钮触发回调
    fun searchButton_triggersCallback() { // 搜索按钮回调
        var searchClicked = false // 是否点击

        composeTestRule.setContent { // 设置内容
            AppToolbar( // 工具栏
                onBackClick = {}, // 返回
                onSearchClick = { searchClicked = true }, // 搜索
                onCartClick = {}, // 购物车
                cartCount = 0 // 数量
            )
        }

        // 通过 contentDescription 点击
        composeTestRule.onNodeWithContentDescription("搜索") // 查找节点
            .performClick() // 点击

        assertTrue(searchClicked) // 断言回调被触发
    }

    @Test // 测试:打印语义树
    fun printSemanticsTree() { // 打印语义树
        composeTestRule.setContent { // 设置内容
            AppToolbar( // 工具栏
                onBackClick = {}, // 返回
                onSearchClick = {}, // 搜索
                onCartClick = {}, // 购物车
                cartCount = 3 // 数量
            )
        }

        // 打印语义树到 Logcat
        composeTestRule.onRoot().printToLog("ToolbarTree") // 打印
    }
}

5. UI 设计解读

第一,onNodeWithContentDescription 的核心作用。 对于没有可见文本的图标按钮,contentDescription 是测试查找的主要方式。composeTestRule.onNodeWithContentDescription("搜索") 会查找 contentDescription 包含"搜索"的节点。

第二,testTag 和 contentDescription 的选择。 testTag 是测试专用的稳定标识,不受 UI 变化影响。contentDescription 是面向辅助技术的描述,测试也可以使用。推荐同时使用两者:testTag 用于测试查找,contentDescription 用于可访问性,测试中优先用 testTag。

第三,useUnmergedTree 参数的使用场景。 当节点合并了子节点的语义时(如 mergeDescendants = true),默认查找器在合并后的树上查找。如果需要访问未合并的树中的子节点,设置 useUnmergedTree = true。购物车按钮的角标和图标被合并后,onNodeWithContentDescription("购物车,3 件商品") 可以在合并节点上找到。

第四,printToLog 的调试作用。 当查找器找不到节点时,用 composeTestRule.onRoot().printToLog("TAG") 打印语义树结构,查看节点的实际层级和属性。这是调试测试失败的最快方式。建议在测试失败时,先打印语义树,再调整查找器。

第五,测试与可访问性的统一。 第7课讲的可访问性语义树和本课的测试语义树是同一个东西。为可访问性添加的 contentDescription 和 mergeDescendants 同时也是测试的查找依据。做好可访问性就等于为测试打好了基础。

6. 常见陷阱

  • 图标按钮没有 contentDescription:测试无法通过描述查找,TalkBack 也无法朗读。
  • contentDescription 硬编码:应该用 stringResource,方便国际化。
  • 忘记 mergeDescendants 导致查找失败:购物车按钮的图标和角标被合并后,需要在父节点上查找。
  • 用 onNodeWithText 查找无文本按钮:应该用 onNodeWithContentDescription 或 onNodeWithTag。
  • useUnmergedTree 滥用:只在需要访问未合并子节点时使用,否则可能找到错误的节点。

7. 可改进方向(含参考答案)

可改进点
  • 用 assertContentDescriptionEquals 精确验证描述。
  • 用 onAllNodesWithContentDescription 查找多个匹配节点。
  • 用 hasContentDescription 匹配器组合查找条件。
增强版参考答案
@Test // 测试:精确匹配 contentDescription
fun contentDescription_exactMatch() { // 精确匹配
    composeTestRule.setContent { // 设置内容
        AppToolbar( // 工具栏
            onBackClick = {}, // 返回
            onSearchClick = {}, // 搜索
            onCartClick = {}, // 购物车
            cartCount = 3 // 数量
        )
    }

    // 精确匹配描述
    composeTestRule.onNodeWithContentDescription("搜索") // 查找
        .assertContentDescriptionEquals("搜索") // 断言描述
}

@Test // 测试:使用匹配器组合查找
fun combinedMatcher() { // 组合匹配器
    composeTestRule.setContent { // 设置内容
        AppToolbar( // 工具栏
            onBackClick = {}, // 返回
            onSearchClick = {}, // 搜索
            onCartClick = {}, // 购物车
            cartCount = 0 // 数量
        )
    }

    // 组合查找:有 testTag 且有 contentDescription
    composeTestRule.onNode( // 查找
        hasTestTag("back_button") and hasContentDescription("返回上一页") // 组合条件
    ).assertExists() // 断言存在
}
改进解读

assertContentDescriptionEquals 精确验证 contentDescription 是否等于指定值。hasTestTag 和 hasContentDescription 是 SemanticsMatcher,可以用 and、or 组合。这些匹配器让测试查找更加灵活和精确。当查找器不确定时,用 onNode + 匹配器组合是最灵活的方式。

8. 关键要点

  • 图标按钮用 onNodeWithContentDescription 或 onNodeWithTag 查找。
  • testTag 是测试专用的稳定标识,优先使用。
  • useUnmergedTree 用于访问未合并的语义树。
  • printToLog 打印语义树,帮助调试。
  • 测试语义树与可访问性语义树是同一个东西。

案例三:列表交互——用节点操作和手势测试

1. 真实场景

你在做一个待办列表,用户可以滑动删除待办项、点击复选框切换完成状态、滚动列表。你需要测试这些手势交互。

2. 设计目标

  • 用 performClick 点击复选框。
  • 用 performTouchInput { swipeLeft() } 执行滑动手势。
  • 用 performScrollToIndex 滚动到指定项。
  • 用 assertIsOn、assertIsOff 验证复选框状态。

3. 界面拆解

  • 点击:performClick()。
  • 滑动:performTouchInput { swipeLeft() }。
  • 滚动:performScrollToIndex()、performScrollToNode()。
  • 复选框状态:assertIsOn()、assertIsOff()。

核心区别:

API用途适用组件
performClick点击Button、Checkbox、IconButton
performTouchInput手势任意组件(滑动、长按、拖拽)
performScrollToIndex滚动到索引LazyColumn、LazyRow
performScrollToNode滚动到节点LazyColumn、LazyRow

4. 完整代码(逐行注释)

// ============ 被测组件 ============
@Composable
fun TodoList( // 待办列表
    items: List<String>, // 待办项
    onDelete: (String) -> Unit // 删除回调
) {
    val checkedStates = remember { // 复选框状态
        mutableStateMapOf<String, Boolean>() // 可变状态映射
    }

    LazyColumn( // 列表
        modifier = Modifier.testTag("todo_list"), // 测试标签
        contentPadding = PaddingValues(16.dp), // 内边距
        verticalArrangement = Arrangement.spacedBy(8.dp) // 间距
    ) {
        items(items, key = { it }) { item -> // 遍历
            val isChecked = checkedStates[item] ?: false // 是否选中

            Card( // 卡片
                modifier = Modifier
                    .fillMaxWidth() // 占满宽度
                    .testTag("todo_item_$item") // 测试标签
                ,
                shape = MaterialTheme.shapes.medium // 圆角
            ) {
                Row( // 横向布局
                    modifier = Modifier.padding(12.dp), // 内边距
                    verticalAlignment = Alignment.CenterVertically // 垂直居中
                ) {
                    Checkbox( // 复选框
                        checked = isChecked, // 状态
                        onCheckedChange = { checkedStates[item] = it }, // 变化
                        modifier = Modifier.testTag("checkbox_$item") // 测试标签
                    )

                    Spacer(modifier = Modifier.width(8.dp)) // 间距

                    Text( // 文字
                        text = item, // 内容
                        modifier = Modifier.weight(1f) // 占剩余
                    )
                }
            }
        }
    }
}

// ============ 测试代码 ============
class TodoListTest { // 待办列表测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 测试规则

    @Test // 测试:点击复选框切换状态
    fun checkbox_togglesState() { // 复选框切换状态
        val items = listOf("学习 Compose", "写测试", "发布应用") // 待办项

        composeTestRule.setContent { // 设置内容
            TodoList(items = items, onDelete = {}) // 待办列表
        }

        // 初始状态:未选中
        composeTestRule.onNodeWithTag("checkbox_学习 Compose") // 查找复选框
            .assertIsOff() // 断言未选中

        // 点击复选框
        composeTestRule.onNodeWithTag("checkbox_学习 Compose") // 查找
            .performClick() // 点击

        // 断言已选中
        composeTestRule.onNodeWithTag("checkbox_学习 Compose") // 查找
            .assertIsOn() // 断言选中
    }

    @Test // 测试:滚动到指定项
    fun scrollToItem() { // 滚动到指定项
        val items = (1..50).map { "项目 $it" } // 50 个项

        composeTestRule.setContent { // 设置内容
            TodoList(items = items, onDelete = {}) // 待办列表
        }

        // 滚动到第 30 项
        composeTestRule.onNodeWithTag("todo_list") // 查找列表
            .performScrollToIndex(29) // 滚动到索引 29

        // 断言第 30 项显示
        composeTestRule.onNodeWithTag("todo_item_项目 30") // 查找项
            .assertIsDisplayed() // 断言显示
    }

    @Test // 测试:滑动删除
    fun swipeToDelete() { // 滑动删除
        var deletedItem: String? = null // 被删除的项
        val items = listOf("学习 Compose", "写测试") // 待办项

        composeTestRule.setContent { // 设置内容
            TodoList( // 待办列表
                items = items, // 数据
                onDelete = { deletedItem = it } // 删除回调
            )
        }

        // 滑动第一项
        composeTestRule.onNodeWithTag("todo_item_学习 Compose") // 查找项
            .performTouchInput { // 执行手势
                swipeLeft() // 向左滑动
            }

        // 注意:实际滑动删除需要 SwipeToDismiss 组件
        // 这里仅演示手势 API
    }
}

5. UI 设计解读

第一,performClick 的核心作用。 点击是最基本的交互。performClick() 在节点中心执行点击。对于 Checkbox、Button、IconButton 等可点击组件,performClick 会触发对应的 onClick。

第二,performTouchInput 的核心作用。 对于滑动、拖拽、长按等手势,用 performTouchInput { } 执行。内部可以调用 swipeLeft()、swipeRight()、swipeUp()、swipeDown()、longClick()、doubleClick() 等方法。手势测试是 Compose 测试中最复杂的部分,需要准确模拟用户的手指动作。

第三,performScrollToIndex 的核心作用。 对于 LazyColumn 等可滚动组件,用 performScrollToIndex(index) 滚动到指定索引。这会自动处理滚动动画,确保目标项可见。这是测试长列表的必备 API。

第四,复选框状态断言。 assertIsOn() 验证复选框已选中。assertIsOff() 验证复选框未选中。这两个断言是 ToggleableState 的便捷方法。

第五,UI 设计意义。 列表交互是移动端最常见的操作。自动化测试可以确保:点击复选框后状态正确切换,滑动删除后列表正确更新,滚动后目标项可见。这些测试覆盖了用户最常使用的交互路径。

6. 常见陷阱

  • performClick 在不可见节点上执行:应该先用 performScrollToNode 滚动到可见。
  • swipeLeft 的距离不够:默认滑动距离可能不足以触发删除,需要指定自定义距离。
  • performScrollToIndex 在非 Lazy 列表上使用:只适用于 LazyColumn、LazyRow 等惰性列表。
  • 测试中创建真实的手势序列:应该用 performTouchInput 的便捷方法,而不是手动模拟 down、move、up。
  • 忘记 testTag 使用动态内容:testTag("todo_item_$item") 中的 $item 如果包含空格或特殊字符,测试可能失败。

7. 可改进方向(含参考答案)

可改进点
  • 用 performScrollToNode 按条件滚动。
  • 用 swipeLeft(startX, endX) 指定滑动距离。
  • 用 assertIsSelected 验证选中状态。
增强版参考答案
@Test // 测试:按条件滚动
fun scrollToNodeByMatcher() { // 按条件滚动
    val items = (1..50).map { "项目 $it" } // 50 个项

    composeTestRule.setContent { // 设置内容
        TodoList(items = items, onDelete = {}) // 待办列表
    }

    // 滚动到文本为"项目 40"的节点
    composeTestRule.onNodeWithTag("todo_list") // 列表
        .performScrollToNode(hasText("项目 40")) // 滚动到节点

    composeTestRule.onNodeWithText("项目 40") // 目标节点
        .assertIsDisplayed() // 断言显示
}

@Test // 测试:指定滑动距离
fun swipeWithDistance() { // 指定滑动距离
    composeTestRule.setContent { // 设置内容
        TodoList(items = listOf("测试项"), onDelete = {}) // 待办列表
    }

    composeTestRule.onNodeWithTag("todo_item_测试项") // 项
        .performTouchInput { // 手势
            swipeLeft(startX = right, endX = left - 200f) // 指定距离
        }
}
改进解读

performScrollToNode(hasText("项目 40")) 按文本条件滚动到目标节点,比按索引更灵活。swipeLeft(startX, endX) 可以指定滑动的起止位置,精确控制滑动距离。当测试包含动态内容的列表时,按条件滚动比按索引滚动更稳定。

8. 关键要点

  • performClick 执行点击,performTouchInput 执行手势。
  • performScrollToIndex 滚动到指定索引,performScrollToNode 按条件滚动。
  • assertIsOn、assertIsOff 验证复选框状态。
  • 滑动删除需要 SwipeToDismiss 组件配合。
  • 测试列表时优先使用按条件滚动。

案例四:异步加载——用同步机制和 waitUntil 测试

1. 真实场景

你在做一个商品列表页面,进入页面后异步加载数据。加载中显示进度条,加载完成后显示列表。你需要测试异步加载的完整流程。

2. 设计目标

  • 用 waitUntil 等待异步操作完成。
  • 用 assertIsDisplayed 验证加载中状态。
  • 用 assertCountEquals 验证加载完成后的列表项数量。
  • 理解 Compose 测试的同步机制。

3. 界面拆解

  • waitUntil :等待条件满足,超时后失败。
  • waitUntilExactlyOneExists :等待恰好一个节点存在。
  • waitUntilDoesNotExist :等待节点不存在。
  • 同步机制:Compose 测试在 UI 空闲时自动同步,但异步操作(如网络请求)需要手动等待。

Compose 测试的同步机制:

操作是否自动同步说明
重组是测试等待重组完成
动画是测试等待动画完成
布局是测试等待布局完成
网络请求否需要 waitUntil 手动等待
协程延迟否需要 waitUntil 手动等待
数据库操作否需要 waitUntil 手动等待

4. 完整代码(逐行注释)

// ============ 被测组件 ============
@Composable
fun AsyncProductList( // 异步商品列表
    loadProducts: suspend () -> List<String> // 加载函数
) {
    var products by remember { mutableStateOf<List<String>>(emptyList()) } // 商品
    var isLoading by remember { mutableStateOf(true) } // 加载中

    LaunchedEffect(Unit) { // 进入时
        products = loadProducts() // 加载
        isLoading = false // 结束加载
    }

    if (isLoading) { // 加载中
        Box( // 容器
            modifier = Modifier
                .fillMaxSize() // 占满
                .testTag("loading_indicator") // 测试标签
            ,
            contentAlignment = Alignment.Center // 居中
        ) {
            CircularProgressIndicator() // 进度条
        }
    } else { // 加载完成
        LazyColumn( // 列表
            modifier = Modifier.testTag("product_list"), // 测试标签
            contentPadding = PaddingValues(16.dp) // 内边距
        ) {
            items(products) { product -> // 遍历
                Text( // 商品名
                    text = product, // 内容
                    modifier = Modifier
                        .fillMaxWidth() // 占满宽度
                        .padding(12.dp) // 内边距
                        .testTag("product_item") // 测试标签
                )
            }
        }
    }
}

// ============ 测试代码 ============
class AsyncProductListTest { // 异步商品列表测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 测试规则

    @Test // 测试:加载中显示进度条
    fun loading_showsIndicator() { // 加载中显示进度条
        composeTestRule.setContent { // 设置内容
            AsyncProductList( // 异步商品列表
                loadProducts = { // 加载函数
                    delay(1000) // 模拟延迟
                    listOf("商品 A", "商品 B") // 返回数据
                }
            )
        }

        // 断言加载指示器显示
        composeTestRule.onNodeWithTag("loading_indicator") // 查找
            .assertIsDisplayed() // 断言显示
    }

    @Test // 测试:加载完成后显示列表
    fun loaded_showsList() { // 加载完成显示列表
        composeTestRule.setContent { // 设置内容
            AsyncProductList( // 异步商品列表
                loadProducts = { // 加载函数
                    delay(500) // 模拟延迟
                    listOf("商品 A", "商品 B", "商品 C") // 返回数据
                }
            )
        }

        // 等待列表出现
        composeTestRule.waitUntil(timeoutMillis = 5000) { // 等待
            composeTestRule // 测试规则
                .onAllNodesWithTag("product_item") // 所有商品项
                .fetchSemanticsNodes() // 获取节点
                .isNotEmpty() // 不为空
        }

        // 断言列表项数量
        composeTestRule.onAllNodesWithTag("product_item") // 所有商品项
            .assertCountEquals(3) // 断言数量

        // 断言加载指示器消失
        composeTestRule.onNodeWithTag("loading_indicator") // 查找
            .assertDoesNotExist() // 断言不存在
    }

    @Test // 测试:空列表显示
    fun emptyList_showsNothing() { // 空列表
        composeTestRule.setContent { // 设置内容
            AsyncProductList( // 异步商品列表
                loadProducts = { emptyList() } // 空列表
            )
        }

        // 等待加载完成
        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule // 测试规则
                .onAllNodesWithTag("product_item") // 所有商品项
                .fetchSemanticsNodes() // 获取节点
                .isEmpty() // 为空
        }

        // 断言没有商品项
        composeTestRule.onAllNodesWithTag("product_item") // 所有商品项
            .assertCountEquals(0) // 断言数量
    }
}

5. UI 设计解读

第一,waitUntil 的核心作用。 Compose 测试默认在 UI 空闲时执行操作和断言。但异步操作(如网络请求)不会让 UI 空闲,因为协程在后台运行。waitUntil 让测试暂停,直到条件满足或超时。timeoutMillis 指定超时时间,默认是 1000ms。

第二,fetchSemanticsNodes 的使用。 onAllNodesWithTag("product_item") 返回一个集合。fetchSemanticsNodes() 获取当前匹配的节点列表。isNotEmpty() 检查是否有匹配的节点。这是 waitUntil 中最常用的组合。

第三,同步机制的重要性。 Compose 测试的同步机制确保测试在 UI 空闲时执行。当有动画、重组、布局、绘制正在进行时,测试会等待。但异步数据加载不是"UI 工作",所以需要 waitUntil 手动等待。

第四,超时设置。 waitUntil 的默认超时是 1000ms。如果异步操作可能需要更长时间(如网络请求),应该设置更大的 timeoutMillis。超时时间应该大于最慢的异步操作耗时。 如果测试在 CI 上运行,网络可能更慢,超时时间应该设置得更宽松。

第五,UI 设计意义。 异步加载是真实 App 的标配。测试需要覆盖:加载中状态、加载完成状态、空列表状态、错误状态。每个状态都应该有对应的测试。

6. 常见陷阱

  • 用 Thread.sleep 等待异步操作:应该用 waitUntil。
  • waitUntil 的超时时间太短:导致测试不稳定,偶发失败。
  • 忘记断言加载指示器消失:只断言列表出现,没有验证加载状态结束。
  • 在 waitUntil 中做复杂操作:应该只做简单的条件检查。
  • 测试中创建真实的网络请求:应该用 Mock 或 Fake 替代,确保测试快速稳定。
  • waitUntil 条件永远为真:比如检查一个总是存在的节点,导致测试不等待。

7. 可改进方向(含参考答案)

可改进点
  • 用 waitUntilExactlyOneExists 等待恰好一个节点。
  • 用 waitUntilDoesNotExist 等待节点消失。
  • 用 mainClock.advanceTimeBy 控制虚拟时钟。
增强版参考答案
@Test // 测试:等待恰好一个节点
fun waitForExactlyOne() { // 等待恰好一个
    composeTestRule.setContent { // 设置内容
        AsyncProductList( // 异步商品列表
            loadProducts = { // 加载
                delay(500) // 延迟
                listOf("商品 A") // 一个商品
            }
        )
    }

    // 等待恰好一个商品项
    composeTestRule.waitUntilExactlyOneExists( // 等待恰好一个
        matcher = hasTestTag("product_item"), // 匹配器
        timeoutMillis = 5000 // 超时
    )
}

@Test // 测试:控制虚拟时钟
fun controlClock() { // 控制时钟
    composeTestRule.mainClock.autoAdvance = false // 关闭自动推进

    composeTestRule.setContent { // 设置内容
        AnimatedVisibility(visible = true) { // 动画可见
            Text("Hello") // 文字
        }
    }

    composeTestRule.mainClock.advanceTimeBy(500) // 推进 500ms

    composeTestRule.onNodeWithText("Hello") // 查找
        .assertIsDisplayed() // 断言显示
}
改进解读

waitUntilExactlyOneExists 等待恰好一个节点存在,比 waitUntil 更精确。mainClock.autoAdvance = false 关闭自动时钟推进,用 advanceTimeBy 手动控制动画时间。这对测试动画的中间状态非常有用。注意:使用 mainClock 时,必须在测试结束前恢复 autoAdvance = true,否则后续测试可能失败。

8. 关键要点

  • waitUntil 等待异步操作完成。
  • fetchSemanticsNodes 获取当前匹配的节点列表。
  • 超时时间应该大于最慢的异步操作耗时。
  • 测试中避免真实网络请求,用 Mock 或 Fake 替代。
  • mainClock 可以控制虚拟时钟,测试动画中间状态。

案例五:Hilt 集成测试——用 createAndroidComposeRule 测试依赖注入

1. 真实场景

你在做一个登录流程,ViewModel 通过 Hilt 注入 Repository,Repository 通过 Hilt 注入网络客户端。你需要测试完整的登录流程,包括 ViewModel 和 Hilt 依赖注入。

2. 设计目标

  • 用 createAndroidComposeRule<MainActivity>() 启动 Activity。
  • 用 @HiltAndroidTest 启用 Hilt 测试。
  • 用 @TestInstallIn 替换真实 Repository 为 Fake。
  • 测试完整的登录流程。

3. 界面拆解

  • @HiltAndroidTest :标记 Hilt 测试类。
  • HiltAndroidRule :注入依赖。
  • @TestInstallIn :替换生产环境的模块为测试模块。
  • createAndroidComposeRule<MainActivity>() :启动 Activity。

Hilt 测试的三个关键点:

  1. @HiltAndroidTest 标记测试类。
  2. HiltAndroidRule 在 @Before 中调用 inject()。
  3. @TestInstallIn 替换生产模块为测试模块。

4. 完整代码(逐行注释)

// ============ 生产代码 ============
// Repository 接口
interface AuthRepository { // 认证仓库
    suspend fun login(username: String, password: String): Boolean // 登录
}

// 生产实现
class RealAuthRepository @Inject constructor( // 真实认证仓库
    private val api: AuthApi // 网络客户端
) : AuthRepository { // 实现
    override suspend fun login(username: String, password: String): Boolean { // 登录
        return api.login(username, password).success // 调用 API
    }
}

// ViewModel
@HiltViewModel // Hilt ViewModel
class LoginViewModel @Inject constructor( // 登录 ViewModel
    private val repository: AuthRepository // 注入仓库
) : ViewModel() {
    var username by mutableStateOf("") // 用户名
    var password by mutableStateOf("") // 密码
    var isLoading by mutableStateOf(false) // 加载中
    var isLoggedIn by mutableStateOf(false) // 已登录

    fun login() { // 登录
        viewModelScope.launch { // 启动协程
            isLoading = true // 加载中
            isLoggedIn = repository.login(username, password) // 调用仓库
            isLoading = false // 结束
        }
    }
}

// 页面
@Composable
fun LoginScreen( // 登录页面
    viewModel: LoginViewModel = hiltViewModel() // 注入 ViewModel
) {
    Column( // 纵向布局
        modifier = Modifier
            .fillMaxSize() // 占满
            .padding(16.dp), // 内边距
        verticalArrangement = Arrangement.Center // 垂直居中
    ) {
        OutlinedTextField( // 用户名
            value = viewModel.username, // 值
            onValueChange = { viewModel.username = it }, // 变化
            label = { Text("用户名") }, // 标签
            modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .testTag("username_field") // 测试标签
        )

        Spacer(modifier = Modifier.height(12.dp)) // 间距

        OutlinedTextField( // 密码
            value = viewModel.password, // 值
            onValueChange = { viewModel.password = it }, // 变化
            label = { Text("密码") }, // 标签
            modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .testTag("password_field") // 测试标签
        )

        Spacer(modifier = Modifier.height(24.dp)) // 间距

        Button( // 登录按钮
            onClick = { viewModel.login() }, // 点击
            enabled = !viewModel.isLoading, // 能否点击
            modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .testTag("login_button") // 测试标签
        ) {
            if (viewModel.isLoading) { // 加载中
                CircularProgressIndicator( // 进度条
                    modifier = Modifier.size(18.dp), // 尺寸
                    strokeWidth = 2.dp // 线宽
                )
            } else { // 非加载
                Text("登录") // 文字
            }
        }

        if (viewModel.isLoggedIn) { // 已登录
            Text( // 提示
                text = "登录成功", // 文字
                modifier = Modifier.testTag("success_text"), // 测试标签
                color = MaterialTheme.colorScheme.primary // 主题色
            )
        }
    }
}

// ============ 测试代码 ============
// Fake Repository
class FakeAuthRepository : AuthRepository { // 假认证仓库
    var shouldSucceed = true // 是否成功
    override suspend fun login(username: String, password: String): Boolean { // 登录
        delay(100) // 模拟延迟
        return shouldSucceed // 返回结果
    }
}

// 测试模块
@Module // 模块
@TestInstallIn( // 安装到测试
    components = [SingletonComponent::class], // 组件
    replaces = [AuthModule::class] // 替换的模块
)
object FakeAuthModule { // 假认证模块
    @Provides // 提供
    @Singleton // 单例
    fun provideAuthRepository(): AuthRepository = FakeAuthRepository() // 假仓库
}

// 测试类
@HiltAndroidTest // Hilt 测试
class LoginFlowTest { // 登录流程测试
    @get:Rule(order = 0) // 规则顺序 0
    val hiltRule = HiltAndroidRule(this) // Hilt 规则

    @get:Rule(order = 1) // 规则顺序 1
    val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose 规则

    @Inject // 注入
    lateinit var authRepository: AuthRepository // 认证仓库

    @Before // 前置
    fun setup() { // 设置
        hiltRule.inject() // 注入依赖
    }

    @Test // 测试:成功登录
    fun login_success() { // 成功登录
        (authRepository as FakeAuthRepository).shouldSucceed = true // 设置成功

        composeTestRule.setContent { // 设置内容
            MyAppTheme { // 主题
                LoginScreen() // 登录页面
            }
        }

        // 输入用户名和密码
        composeTestRule.onNodeWithTag("username_field") // 用户名
            .performTextInput("张三") // 输入
        composeTestRule.onNodeWithTag("password_field") // 密码
            .performTextInput("123456") // 输入

        // 点击登录
        composeTestRule.onNodeWithTag("login_button") // 按钮
            .performClick() // 点击

        // 等待登录完成
        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule // 规则
                .onAllNodesWithTag("success_text") // 成功文字
                .fetchSemanticsNodes() // 获取节点
                .isNotEmpty() // 不为空
        }

        // 断言成功文字显示
        composeTestRule.onNodeWithTag("success_text") // 查找
            .assertTextEquals("登录成功") // 断言文字
    }

    @Test // 测试:失败登录
    fun login_failure() { // 失败登录
        (authRepository as FakeAuthRepository).shouldSucceed = false // 设置失败

        composeTestRule.setContent { // 设置内容
            MyAppTheme { // 主题
                LoginScreen() // 登录页面
            }
        }

        // 输入
        composeTestRule.onNodeWithTag("username_field") // 用户名
            .performTextInput("张三") // 输入
        composeTestRule.onNodeWithTag("password_field") // 密码
            .performTextInput("123456") // 输入

        // 点击
        composeTestRule.onNodeWithTag("login_button") // 按钮
            .performClick() // 点击

        // 等待
        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule // 规则
                .onAllNodesWithTag("success_text") // 成功文字
                .fetchSemanticsNodes() // 获取节点
                .isEmpty() // 为空
        }

        // 断言没有成功文字
        composeTestRule.onNodeWithTag("success_text") // 查找
            .assertDoesNotExist() // 断言不存在
    }
}

5. UI 设计解读

第一,@HiltAndroidTest 和 HiltAndroidRule 的核心作用。 @HiltAndroidTest 标记测试类使用 Hilt。HiltAndroidRule 负责注入依赖,必须在 @Before 中调用 hiltRule.inject()。@get:Rule(order = 0) 保证 Hilt 规则在 Compose 规则之前执行。

第二,@TestInstallIn 替换生产模块。 @TestInstallIn(components = [SingletonComponent::class], replaces = [AuthModule::class]) 告诉 Hilt 在测试中用 FakeAuthModule 替换 AuthModule。这样测试中注入的是 FakeAuthRepository,而不是真实的网络请求。

第三,createAndroidComposeRule<MainActivity>() 的适用场景。 这个测试需要启动 Activity 并注入 Hilt 依赖,所以用 createAndroidComposeRule。它比 createComposeRule 更重,但支持 Activity 级别的集成测试。

第四,Fake Repository 的优势。 FakeAuthRepository 实现了 AuthRepository 接口,但用 delay(100) 模拟延迟,用 shouldSucceed 控制成功/失败。Fake 比 Mock 更适合集成测试,因为 Fake 有真实的行为逻辑,而 Mock 只验证调用。

第五,UI 设计意义。 Hilt 集成测试覆盖了从 UI 到 ViewModel 到 Repository 的完整链路。它确保依赖注入配置正确,ViewModel 和 Repository 的交互符合预期。这是最接近真实用户场景的测试。

6. 常见陷阱

  • 忘记 hiltRule.inject() :依赖不会被注入,测试中的 lateinit var 会崩溃。
  • @get:Rule(order = 0) 顺序错误:Hilt 规则必须在 Compose 规则之前。
  • @TestInstallIn 的 replaces 配置错误:没有替换生产模块,测试中仍然使用真实网络请求。
  • Fake Repository 没有模拟延迟:测试太快,可能无法验证加载状态。
  • createAndroidComposeRule 中 setContent 与 Activity 冲突:如果 Activity 已经设置了内容,不应该再调用 setContent。
  • 在测试中直接修改 shouldSucceed 后忘记等待:修改后需要 waitUntil 等待状态更新。

7. 可改进方向(含参考答案)

可改进点
  • 用 @UninstallModules 卸载特定模块。
  • 用 @BindValue 绑定测试值。
  • 用 runTest 测试 ViewModel 逻辑。
增强版参考答案
@HiltAndroidTest // Hilt 测试
class LoginFlowTestEnhanced { // 增强版登录流程测试
    @get:Rule(order = 0) // Hilt 规则
    val hiltRule = HiltAndroidRule(this) // Hilt

    @get:Rule(order = 1) // Compose 规则
    val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose

    @BindValue // 绑定值
    @JvmField // JVM 字段
    val fakeRepository: AuthRepository = FakeAuthRepository() // 假仓库

    @Before // 前置
    fun setup() { // 设置
        hiltRule.inject() // 注入
    }
}
改进解读

@BindValue 直接将一个实例绑定到 Hilt 图中,不需要 @TestInstallIn 和 @Module。这更简洁,适合只需要替换一个依赖的场景。@BindValue 的优点是测试代码更少,缺点是每次只能绑定一个依赖。

8. 关键要点

  • @HiltAndroidTest 和 HiltAndroidRule 启用 Hilt 测试。
  • @TestInstallIn 替换生产模块为测试模块。
  • createAndroidComposeRule<MainActivity>() 用于 Activity 级别的集成测试。
  • Fake Repository 比 Mock 更适合集成测试。
  • @get:Rule(order = 0) 保证 Hilt 规则在 Compose 规则之前。

案例六:导航测试——用 createAndroidComposeRule 测试多页面流程

1. 真实场景

你在做一个多页面 App,从首页点击按钮进入详情页,详情页点击返回回到首页。你需要测试完整的导航流程。

2. 设计目标

  • 用 createAndroidComposeRule 启动包含导航的 Activity。
  • 用 onNodeWithTag 查找导航目标页面的元素。
  • 用 performClick 触发导航。
  • 用 waitUntil 等待导航完成。

3. 界面拆解

  • 首页:显示"首页"文字和一个"进入详情"按钮。
  • 详情页:显示"详情页"文字和一个"返回"按钮。
  • 测试:点击"进入详情"后断言"详情页"显示;点击"返回"后断言"首页"显示。

导航测试的核心挑战: 导航涉及异步的页面切换。点击按钮后,Compose 需要执行导航操作,然后重组新页面。测试需要等待新页面出现。不能用 Thread.sleep,必须用 waitUntil。

4. 完整代码(逐行注释)

// ============ 被测组件 ============
@Serializable object HomeRoute // 首页路由
@Serializable object DetailRoute // 详情路由

@Composable
fun NavApp() { // 导航应用
    val navController = rememberNavController() // 导航控制器

    NavHost( // 导航容器
        navController = navController, // 绑定
        startDestination = HomeRoute // 起始页
    ) {
        composable<HomeRoute> { // 首页
            Column( // 纵向布局
                modifier = Modifier
                    .fillMaxSize() // 占满
                    .testTag("home_screen") // 测试标签
                ,
                verticalArrangement = Arrangement.Center // 垂直居中
            ) {
                Text("首页", style = MaterialTheme.typography.headlineMedium) // 文字
                Button( // 按钮
                    onClick = { navController.navigate(DetailRoute) }, // 导航
                    modifier = Modifier.testTag("go_detail_button") // 测试标签
                ) {
                    Text("进入详情") // 文字
                }
            }
        }

        composable<DetailRoute> { // 详情页
            Column( // 纵向布局
                modifier = Modifier
                    .fillMaxSize() // 占满
                    .testTag("detail_screen") // 测试标签
                ,
                verticalArrangement = Arrangement.Center // 垂直居中
            ) {
                Text("详情页", style = MaterialTheme.typography.headlineMedium) // 文字
                Button( // 返回按钮
                    onClick = { navController.popBackStack() }, // 返回
                    modifier = Modifier.testTag("go_back_button") // 测试标签
                ) {
                    Text("返回") // 文字
                }
            }
        }
    }
}

// ============ 测试代码 ============
@RunWith(AndroidJUnit4::class) // AndroidJUnit4
class NavAppTest { // 导航测试
    @get:Rule // 规则
    val composeTestRule = createAndroidComposeRule<MainActivity>() // 启动 Activity

    @Test // 测试:首页到详情页
    fun navigate_homeToDetail() { // 首页到详情
        // 断言首页显示
        composeTestRule.onNodeWithTag("home_screen") // 首页
            .assertIsDisplayed() // 断言显示

        // 点击进入详情
        composeTestRule.onNodeWithTag("go_detail_button") // 按钮
            .performClick() // 点击

        // 等待详情页出现
        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule // 规则
                .onAllNodesWithTag("detail_screen") // 详情页
                .fetchSemanticsNodes() // 获取节点
                .isNotEmpty() // 不为空
        }

        // 断言详情页显示
        composeTestRule.onNodeWithTag("detail_screen") // 详情页
            .assertIsDisplayed() // 断言显示
    }

    @Test // 测试:详情页返回首页
    fun navigate_detailToHome() { // 详情到首页
        // 先进入详情页
        composeTestRule.onNodeWithTag("go_detail_button") // 按钮
            .performClick() // 点击

        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule // 规则
                .onAllNodesWithTag("detail_screen") // 详情页
                .fetchSemanticsNodes() // 获取
                .isNotEmpty() // 不为空
        }

        // 点击返回
        composeTestRule.onNodeWithTag("go_back_button") // 返回按钮
            .performClick() // 点击

        // 等待首页出现
        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule // 规则
                .onAllNodesWithTag("home_screen") // 首页
                .fetchSemanticsNodes() // 获取
                .isNotEmpty() // 不为空
        }

        // 断言首页显示
        composeTestRule.onNodeWithTag("home_screen") // 首页
            .assertIsDisplayed() // 断言显示
    }
}

5. UI 设计解读

第一,导航测试的核心挑战。 导航涉及异步的页面切换。点击按钮后,Compose 需要执行导航操作,然后重组新页面。测试需要等待新页面出现,用 waitUntil 等待目标页面的节点出现。

第二,testTag 在导航测试中的重要性。 首页和详情页都有"按钮"和"文字",如果没有 testTag,测试无法区分当前在哪个页面。home_screen 和 detail_screen 标签让测试可以明确断言当前页面。这是导航测试最关键的设计。

第三,createAndroidComposeRule 的使用。 导航测试需要启动 Activity,因为 NavHost 通常放在 Activity 的 setContent 中。createAndroidComposeRule<MainActivity>() 启动 Activity 并设置内容。

第四,UI 设计意义。 导航是 App 的核心功能。测试确保:点击按钮后正确跳转,返回键正确工作,返回栈行为符合预期。导航 bug 是最影响用户体验的 bug 之一——用户点了一个按钮,App 却跳到了错误的地方。

6. 常见陷阱

  • 在导航测试中用 createComposeRule() :需要启动 Activity,应该用 createAndroidComposeRule。
  • waitUntil 超时时间太短:导航动画可能需要几百毫秒。
  • 忘记断言起始页面:应该先断言起始页面显示,再执行导航。
  • 测试导航时没有 testTag:不同页面的元素可能冲突,无法区分。
  • 在 NavHost 外调用 setContent:如果 Activity 已经设置了 NavHost,测试中不应该再调用 setContent。

7. 可改进方向(含参考答案)

可改进点
  • 测试嵌套导航图。
  • 测试导航参数传递。
  • 测试返回键行为。
增强版参考答案
@Test // 测试:导航参数传递
fun navigate_withParameter() { // 导航参数
    // 假设详情页接收 productId 参数
    composeTestRule.onNodeWithTag("go_detail_button") // 按钮
        .performClick() // 点击

    composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
        composeTestRule // 规则
            .onAllNodesWithTag("detail_screen") // 详情页
            .fetchSemanticsNodes() // 获取
            .isNotEmpty() // 不为空
    }

    // 断言详情页显示了正确的商品 ID
    composeTestRule.onNodeWithText("商品 ID: 123") // 文字
        .assertIsDisplayed() // 断言显示
}
改进解读

导航参数测试验证了路由参数是否正确传递到目标页面。在详情页中显示商品 ID 的文本,测试通过 onNodeWithText 查找并断言。建议为每个导航路径都编写测试,覆盖前进和返回两个方向。

8. 关键要点

  • 导航测试用 createAndroidComposeRule。
  • waitUntil 等待目标页面出现。
  • testTag 用于区分不同页面的元素。
  • 测试覆盖前进和返回两个方向。
  • 导航参数传递需要通过断言验证。

案例七:KMP 共享代码测试——跨平台逻辑测试

1. 真实场景

你在做一个 Kotlin Multiplatform 项目,commonMain 中有共享的业务逻辑(如价格计算、数据验证)。你需要测试这些共享逻辑在 Android 和 iOS 上都正确工作。

2. 设计目标

  • 在 commonTest 中编写共享逻辑的单元测试。
  • 用 kotlin.test 提供的断言 API。
  • 理解 KMP 测试的目录结构和运行方式。

3. 界面拆解

  • commonMain :共享业务逻辑。
  • commonTest :共享测试代码。
  • androidTest :Android 平台特定测试。
  • iosTest :iOS 平台特定测试。

KMP 测试的核心优势: commonTest 中的测试在 Android 和 iOS 上都会运行。你只需要写一次测试,就能验证共享逻辑在所有平台上都正确。这大大减少了重复测试的工作量。

4. 完整代码(逐行注释)

// ============ commonMain 中的共享逻辑 ============
// commonMain/kotlin/com/example/shared/PriceCalculator.kt
class PriceCalculator { // 价格计算器
    fun calculateTotal( // 计算总价
        price: Double, // 单价
        quantity: Int, // 数量
        taxRate: Double = 0.1 // 税率
    ): Double {
        require(price >= 0) { "价格不能为负" } // 验证价格
        require(quantity >= 0) { "数量不能为负" } // 验证数量
        require(taxRate in 0.0..1.0) { "税率必须在 0 到 1 之间" } // 验证税率

        val subtotal = price * quantity // 小计
        return subtotal * (1 + taxRate) // 含税总价
    }
}

// commonMain/kotlin/com/example/shared/Validator.kt
object Validator { // 验证器
    fun isValidEmail(email: String): Boolean { // 验证邮箱
        return email.contains("@") && email.contains(".") // 简单验证
    }

    fun isValidPassword(password: String): Boolean { // 验证密码
        return password.length >= 6 // 至少 6 位
    }
}

// ============ commonTest 中的测试 ============
// commonTest/kotlin/com/example/shared/PriceCalculatorTest.kt
import kotlin.test.Test // 测试
import kotlin.test.assertEquals // 断言相等
import kotlin.test.assertFailsWith // 断言异常

class PriceCalculatorTest { // 价格计算器测试
    private val calculator = PriceCalculator() // 计算器

    @Test // 测试:正常计算
    fun calculateTotal_normalCase() { // 正常计算
        val result = calculator.calculateTotal( // 计算
            price = 100.0, // 单价
            quantity = 2, // 数量
            taxRate = 0.1 // 税率
        )

        assertEquals(220.0, result, 0.01) // 断言结果
    }

    @Test // 测试:零数量
    fun calculateTotal_zeroQuantity() { // 零数量
        val result = calculator.calculateTotal( // 计算
            price = 100.0, // 单价
            quantity = 0 // 数量
        )

        assertEquals(0.0, result, 0.01) // 断言结果
    }

    @Test // 测试:负价格抛异常
    fun calculateTotal_negativePrice_throws() { // 负价格
        assertFailsWith<IllegalArgumentException> { // 断言异常
            calculator.calculateTotal(price = -1.0, quantity = 1) // 计算
        }
    }

    @Test // 测试:无税计算
    fun calculateTotal_noTax() { // 无税
        val result = calculator.calculateTotal( // 计算
            price = 50.0, // 单价
            quantity = 3, // 数量
            taxRate = 0.0 // 无税
        )

        assertEquals(150.0, result, 0.01) // 断言结果
    }
}

// commonTest/kotlin/com/example/shared/ValidatorTest.kt
class ValidatorTest { // 验证器测试
    @Test // 测试:有效邮箱
    fun validEmail() { // 有效邮箱
        assertTrue(Validator.isValidEmail("test@example.com")) // 断言为真
    }

    @Test // 测试:无效邮箱
    fun invalidEmail() { // 无效邮箱
        assertFalse(Validator.isValidEmail("invalid")) // 断言为假
    }

    @Test // 测试:有效密码
    fun validPassword() { // 有效密码
        assertTrue(Validator.isValidPassword("123456")) // 断言为真
    }

    @Test // 测试:密码太短
    fun shortPassword() { // 密码太短
        assertFalse(Validator.isValidPassword("123")) // 断言为假
    }
}

5. UI 设计解读

第一,KMP 测试的核心优势。 commonTest 中的测试在 Android 和 iOS 上都会运行。你只需要写一次测试,就能验证共享逻辑在所有平台上都正确。这大大减少了重复测试的工作量。

第二,kotlin.test 的 API。 KMP 使用 kotlin.test 提供的断言 API:assertEquals、assertTrue、assertFalse、assertFailsWith。这些 API 在所有平台上都可用。

第三,KMP 测试的运行方式。 在 Android 上,commonTest 的测试作为 JVM 单元测试运行。在 iOS 上,它们作为 Kotlin/Native 测试运行。测试代码在 commonTest 中,运行环境由各个平台提供。

第四,UI 设计意义。 如果你的 App 使用 Compose Multiplatform 共享 UI,commonTest 中的测试覆盖了共享逻辑,而各个平台的测试覆盖了平台特定的 UI 行为。共享逻辑测试是跨平台项目质量保证的第一道防线。

6. 常见陷阱

  • 在 commonTest 中使用平台特定 API:应该用 expect/actual 声明,在平台特定测试中实现。
  • 忘记添加 kotlin.test 依赖:需要在 commonTest 中添加 implementation(kotlin("test"))。
  • 在共享测试中测试 UI:commonTest 适合逻辑测试,UI 测试应该在平台特定测试中。
  • 测试依赖平台特定的时间或随机数:应该注入可控的依赖。
  • assertEquals 浮点数比较忘记指定精度:应该用 assertEquals(expected, actual, absoluteTolerance)。

7. 可改进方向(含参考答案)

可改进点
  • 用 expect/actual 测试平台特定逻辑。
  • 用 runTest 测试协程逻辑。
  • 用 MockK 或 Fake 测试依赖注入。
增强版参考答案
// commonMain/kotlin/com/example/shared/Clock.kt
expect fun currentTimeMillis(): Long // 当前时间

// commonTest/kotlin/com/example/shared/ClockTest.kt
class ClockTest { // 时钟测试
    @Test // 测试:时间递增
    fun timeIncreases() { // 时间递增
        val time1 = currentTimeMillis() // 第一次
        val time2 = currentTimeMillis() // 第二次
        assertTrue(time2 >= time1) // 断言递增
    }
}
改进解读

expect/actual 声明跨平台 API。commonTest 中测试共享逻辑,平台特定测试中验证 actual 实现。runTest 用于测试协程逻辑,确保协程在测试中正确完成。

8. 关键要点

  • commonTest 中的测试在 Android 和 iOS 上都会运行。
  • 用 kotlin.test 的断言 API。
  • expect/actual 用于平台特定逻辑。
  • 共享逻辑测试是跨平台项目的第一道防线。

案例八:综合案例——完整电商 App 测试策略

1. 真实场景

结合本课所有知识,为一个电商 App 制定完整的测试策略,覆盖单元测试、Compose UI 测试、Hilt 集成测试和导航测试。

2. 设计目标

  • 为 ViewModel 编写单元测试。
  • 为 Composable 编写 UI 测试。
  • 为登录流程编写 Hilt 集成测试。
  • 为导航流程编写测试。
  • 建立测试金字塔策略。

3. 测试金字塔

层次数量运行速度覆盖内容
单元测试最多快(毫秒)ViewModel、Repository、工具类
Compose UI 测试中等中(秒)单个 Composable
集成测试较少慢(秒-分钟)页面 + 导航 + Hilt

4. 完整代码(逐行注释)

// ============ 单元测试:ViewModel ============
class CartViewModelTest { // 购物车 ViewModel 测试
    private lateinit var viewModel: CartViewModel // ViewModel
    private lateinit var fakeRepository: FakeCartRepository // 假仓库

    @Before // 前置
    fun setup() { // 设置
        fakeRepository = FakeCartRepository() // 创建假仓库
        viewModel = CartViewModel(fakeRepository) // 创建 ViewModel
    }

    @Test // 测试:添加商品
    fun addProduct_increasesCount() = runTest { // 添加商品
        viewModel.addProduct(Product(1, "测试商品", 99.0)) // 添加
        advanceUntilIdle() // 等待协程完成

        assertEquals(1, viewModel.uiState.value.items.size) // 断言数量
    }

    @Test // 测试:删除商品
    fun removeProduct_decreasesCount() = runTest { // 删除商品
        viewModel.addProduct(Product(1, "测试商品", 99.0)) // 添加
        advanceUntilIdle() // 等待

        viewModel.removeProduct(1) // 删除
        advanceUntilIdle() // 等待

        assertEquals(0, viewModel.uiState.value.items.size) // 断言数量
    }

    @Test // 测试:计算总价
    fun calculateTotal_correctPrice() = runTest { // 计算总价
        viewModel.addProduct(Product(1, "商品 A", 50.0)) // 添加
        viewModel.addProduct(Product(2, "商品 B", 30.0)) // 添加
        advanceUntilIdle() // 等待

        assertEquals(80.0, viewModel.uiState.value.totalPrice, 0.01) // 断言总价
    }
}

// ============ Compose UI 测试 ============
class CartScreenTest { // 购物车页面测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 测试规则

    @Test // 测试:显示商品列表
    fun showProductList() { // 显示商品列表
        val items = listOf( // 商品
            CartItem(1, "商品 A", 50.0, 1), // 商品 A
            CartItem(2, "商品 B", 30.0, 2) // 商品 B
        )

        composeTestRule.setContent { // 设置内容
            CartScreen( // 购物车页面
                items = items, // 数据
                onRemove = {} // 删除回调
            )
        }

        composeTestRule.onAllNodesWithTag("cart_item") // 所有商品项
            .assertCountEquals(2) // 断言数量
    }

    @Test // 测试:点击删除按钮
    fun removeButton_triggersCallback() { // 删除按钮
        var removedId: Long? = null // 删除的 ID

        composeTestRule.setContent { // 设置内容
            CartScreen( // 购物车页面
                items = listOf(CartItem(1, "商品 A", 50.0, 1)), // 数据
                onRemove = { removedId = it } // 删除回调
            )
        }

        composeTestRule.onNodeWithTag("remove_button_1") // 删除按钮
            .performClick() // 点击

        assertEquals(1L, removedId) // 断言 ID
    }
}

// ============ Hilt 集成测试 ============
@HiltAndroidTest // Hilt 测试
class CheckoutFlowTest { // 结算流程测试
    @get:Rule(order = 0) // Hilt 规则
    val hiltRule = HiltAndroidRule(this) // Hilt

    @get:Rule(order = 1) // Compose 规则
    val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose

    @BindValue // 绑定值
    @JvmField // JVM
    val fakeRepository: CartRepository = FakeCartRepository() // 假仓库

    @Before // 前置
    fun setup() { // 设置
        hiltRule.inject() // 注入
    }

    @Test // 测试:结算成功
    fun checkout_success() { // 结算成功
        composeTestRule.setContent { // 设置内容
            MyAppTheme { // 主题
                CheckoutScreen() // 结算页面
            }
        }

        // 点击结算按钮
        composeTestRule.onNodeWithTag("checkout_button") // 结算
            .performClick() // 点击

        // 等待成功
        composeTestRule.waitUntil(timeoutMillis = 5000) { // 等待
            composeTestRule // 规则
                .onAllNodesWithTag("success_dialog") // 成功对话框
                .fetchSemanticsNodes() // 获取
                .isNotEmpty() // 不为空
        }

        // 断言成功
        composeTestRule.onNodeWithTag("success_dialog") // 成功对话框
            .assertIsDisplayed() // 断言显示
    }
}

5. UI 设计解读

第一,测试金字塔的实践。 单元测试数量最多,覆盖 ViewModel 和 Repository 的逻辑。Compose UI 测试数量中等,覆盖单个组件的渲染和交互。集成测试数量较少,覆盖完整的用户流程。这是 Google 推荐的测试策略。

第二,单元测试用 runTest。 runTest 是 kotlinx-coroutines-test 提供的测试协程的 API。advanceUntilIdle() 等待所有挂起的协程完成。MainDispatcherRule 用于替换主调度器,让测试在 JVM 上运行。

第三,集成测试用 @BindValue。 @BindValue 直接将 Fake Repository 绑定到 Hilt 图中,比 @TestInstallIn 更简洁。适合只需要替换一个依赖的场景。

第四,UI 设计意义。 完整的测试策略确保:业务逻辑正确(单元测试),组件渲染正确(UI 测试),端到端流程正确(集成测试)。测试覆盖率不是目标,覆盖关键用户路径才是。

6. 常见陷阱

  • 测试只覆盖 Happy Path:应该覆盖错误、空状态、边界条件。
  • 集成测试中创建真实网络请求:应该用 Fake 或 Mock。
  • 测试之间共享状态:每个测试应该独立,不依赖其他测试的结果。
  • 测试命名不清晰:测试名应该说明"测试什么"和"期望什么"。
  • 忘记在 CI 中运行测试:测试应该在每次提交时自动运行。

7. 关键要点

  • 单元测试覆盖 ViewModel 和 Repository 逻辑。
  • Compose UI 测试覆盖组件渲染和交互。
  • 集成测试覆盖端到端流程。
  • 用 runTest 测试协程,用 @BindValue 替换依赖。
  • 测试应该覆盖 Happy Path、错误路径和边界条件。

从案例中提炼的 Compose 测试与质量保证原则

  1. 用 createComposeRule() 测试单个 Composable,用 createAndroidComposeRule() 测试集成流程。 选择取决于是否需要 Activity。

  2. 用 testTag 为组件添加稳定的测试标识。 不要依赖可能变化的文本。

  3. 用 onNodeWithText、onNodeWithTag、onNodeWithContentDescription 查找节点。 优先用 testTag。

  4. 用 performClick、performTextInput、performTouchInput 执行用户操作。

  5. 用 assertIsDisplayed、assertIsEnabled、assertTextEquals 验证组件状态。

  6. 用 waitUntil 等待异步操作完成。 不要用 Thread.sleep。

  7. 用 useUnmergedTree = true 访问未合并的语义树。 只在需要时使用。

  8. 用 printToLog 打印语义树,调试查找器。

  9. 用 @HiltAndroidTest + HiltAndroidRule + @TestInstallIn 测试 Hilt 集成。 用 Fake 替代真实网络请求。

  10. 用 @get:Rule(order = 0) 保证 Hilt 规则在 Compose 规则之前。

  11. 导航测试用 createAndroidComposeRule,用 waitUntil 等待目标页面出现。

  12. KMP 项目在 commonTest 中测试共享逻辑。 用 kotlin.test 的断言 API。

  13. 遵循测试金字塔:单元测试最多,UI 测试中等,集成测试较少。

  14. 测试覆盖 Happy Path、错误路径、空状态和边界条件。

  15. 测试语义树与可访问性语义树是同一个东西。 做好可访问性就等于为测试打好了基础。

课后练习与参考答案

练习 1:测试登录表单

要求:一个登录表单,包含用户名和密码输入框、登录按钮。测试:输入有效数据后按钮可用,点击后回调被触发。

参考答案:

@Composable
fun LoginForm(onLogin: (String, String) -> Unit) { // 登录表单
    var username by remember { mutableStateOf("") } // 用户名
    var password by remember { mutableStateOf("") } // 密码

    Column( // 纵向布局
        modifier = Modifier.padding(16.dp) // 内边距
    ) {
        OutlinedTextField( // 用户名
            value = username, // 值
            onValueChange = { username = it }, // 变化
            label = { Text("用户名") }, // 标签
            modifier = Modifier.testTag("username") // 测试标签
        )
        OutlinedTextField( // 密码
            value = password, // 值
            onValueChange = { password = it }, // 变化
            label = { Text("密码") }, // 标签
            modifier = Modifier.testTag("password") // 测试标签
        )
        Button( // 登录
            onClick = { onLogin(username, password) }, // 点击
            enabled = username.isNotBlank() && password.length >= 6, // 启用
            modifier = Modifier.testTag("login") // 测试标签
        ) {
            Text("登录") // 文字
        }
    }
}

class LoginFormTest { // 测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 规则

    @Test // 测试:按钮启用
    fun button_enabledAfterInput() { // 按钮启用
        composeTestRule.setContent { // 设置内容
            LoginForm(onLogin = { _, _ -> }) // 表单
        }

        composeTestRule.onNodeWithTag("login").assertIsNotEnabled() // 初始禁用
        composeTestRule.onNodeWithTag("username").performTextInput("张三") // 输入
        composeTestRule.onNodeWithTag("password").performTextInput("123456") // 输入
        composeTestRule.onNodeWithTag("login").assertIsEnabled() // 断言启用
    }

    @Test // 测试:回调触发
    fun callback_triggered() { // 回调
        var captured = false // 捕获
        composeTestRule.setContent { // 设置内容
            LoginForm(onLogin = { _, _ -> captured = true }) // 表单
        }

        composeTestRule.onNodeWithTag("username").performTextInput("张三") // 输入
        composeTestRule.onNodeWithTag("password").performTextInput("123456") // 输入
        composeTestRule.onNodeWithTag("login").performClick() // 点击

        assertTrue(captured) // 断言
    }
}

解读: 用 testTag 查找输入框和按钮。assertIsNotEnabled 和 assertIsEnabled 验证按钮状态。performTextInput 输入文字,performClick 点击按钮。回调通过捕获变量验证。

练习 2:测试图标按钮

要求:一个工具栏,包含返回、搜索、购物车三个图标按钮。测试每个按钮的 contentDescription 和点击回调。

参考答案:

@Composable
fun Toolbar(onBack: () -> Unit, onSearch: () -> Unit, onCart: () -> Unit) { // 工具栏
    Row { // 横向
        IconButton(onClick = onBack) { // 返回
            Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") // 图标
        }
        IconButton(onClick = onSearch) { // 搜索
            Icon(Icons.Default.Search, contentDescription = "搜索") // 图标
        }
        IconButton(onClick = onCart) { // 购物车
            Icon(Icons.Default.ShoppingCart, contentDescription = "购物车") // 图标
        }
    }
}

class ToolbarTest { // 工具栏测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 规则

    @Test // 测试:返回按钮
    fun backButton_hasDescription() { // 返回按钮
        composeTestRule.setContent { // 设置内容
            Toolbar(onBack = {}, onSearch = {}, onCart = {}) // 工具栏
        }

        composeTestRule.onNodeWithContentDescription("返回") // 查找
            .assertExists() // 断言存在
    }

    @Test // 测试:搜索按钮点击
    fun searchButton_triggersCallback() { // 搜索按钮
        var clicked = false // 点击
        composeTestRule.setContent { // 设置内容
            Toolbar(onBack = {}, onSearch = { clicked = true }, onCart = {}) // 工具栏
        }

        composeTestRule.onNodeWithContentDescription("搜索") // 查找
            .performClick() // 点击

        assertTrue(clicked) // 断言
    }
}

解读: 用 onNodeWithContentDescription 查找图标按钮。performClick 执行点击。回调通过捕获变量验证。

练习 3:测试列表滚动

要求:一个 50 项的列表,测试滚动到第 30 项并断言其显示。

参考答案:

@Composable
fun LongList(items: List<String>) { // 长列表
    LazyColumn(modifier = Modifier.testTag("list")) { // 列表
        items(items) { item -> // 遍历
            Text(item, modifier = Modifier
                .fillMaxWidth() // 占满宽度
                .padding(16.dp) // 内边距
                .testTag("item_$item") // 测试标签
            )
        }
    }
}

class LongListTest { // 长列表测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 规则

    @Test // 测试:滚动到第 30 项
    fun scrollToItem30() { // 滚动到第 30 项
        val items = (1..50).map { "项目 $it" } // 数据

        composeTestRule.setContent { // 设置内容
            LongList(items) // 列表
        }

        composeTestRule.onNodeWithTag("list") // 列表
            .performScrollToIndex(29) // 滚动到索引 29

        composeTestRule.onNodeWithTag("item_项目 30") // 第 30 项
            .assertIsDisplayed() // 断言显示
    }
}

解读: performScrollToIndex 滚动到指定索引。assertIsDisplayed 验证目标项可见。索引从 0 开始,第 30 项的索引是 29。

练习 4:测试异步加载

要求:一个异步加载数据的页面,测试加载中显示进度条,加载完成后显示列表。

参考答案:

@Composable
fun AsyncScreen(load: suspend () -> List<String>) { // 异步页面
    var items by remember { mutableStateOf<List<String>>(emptyList()) } // 数据
    var loading by remember { mutableStateOf(true) } // 加载中

    LaunchedEffect(Unit) { // 进入时
        items = load() // 加载
        loading = false // 结束
    }

    if (loading) { // 加载中
        CircularProgressIndicator(modifier = Modifier.testTag("loading")) // 进度条
    } else { // 加载完成
        LazyColumn(modifier = Modifier.testTag("list")) { // 列表
            items(items) { item -> // 遍历
                Text(item, modifier = Modifier.testTag("item")) // 文字
            }
        }
    }
}

class AsyncScreenTest { // 异步页面测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 规则

    @Test // 测试:加载中显示进度条
    fun loading_showsIndicator() { // 加载中
        composeTestRule.setContent { // 设置内容
            AsyncScreen(load = { delay(1000); listOf("A") }) // 异步
        }

        composeTestRule.onNodeWithTag("loading").assertIsDisplayed() // 断言
    }

    @Test // 测试:加载完成显示列表
    fun loaded_showsList() { // 加载完成
        composeTestRule.setContent { // 设置内容
            AsyncScreen(load = { listOf("A", "B", "C") }) // 异步
        }

        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule.onAllNodesWithTag("item").fetchSemanticsNodes().isNotEmpty() // 条件
        }

        composeTestRule.onAllNodesWithTag("item").assertCountEquals(3) // 断言数量
    }
}

解读: waitUntil 等待异步加载完成。fetchSemanticsNodes 获取当前匹配的节点。assertCountEquals 验证列表项数量。

练习 5:Hilt 集成测试

要求:测试一个通过 Hilt 注入 Repository 的 ViewModel。

参考答案:

@HiltAndroidTest // Hilt 测试
class HiltIntegrationTest { // Hilt 集成测试
    @get:Rule(order = 0) // Hilt 规则
    val hiltRule = HiltAndroidRule(this) // Hilt

    @get:Rule(order = 1) // Compose 规则
    val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose

    @BindValue // 绑定值
    @JvmField // JVM
    val repository: MyRepository = FakeMyRepository() // 假仓库

    @Before // 前置
    fun setup() { // 设置
        hiltRule.inject() // 注入
    }

    @Test // 测试:加载数据
    fun loadData_showsContent() { // 加载数据
        composeTestRule.setContent { // 设置内容
            MyScreen() // 页面
        }

        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule.onAllNodesWithTag("content").fetchSemanticsNodes().isNotEmpty() // 条件
        }
    }
}

解读: @HiltAndroidTest 和 HiltAndroidRule 启用 Hilt 测试。@BindValue 绑定 Fake Repository。hiltRule.inject() 在 @Before 中调用。createAndroidComposeRule 启动 Activity。

练习 6:导航测试

要求:测试从首页进入详情页,再从详情页返回首页。

参考答案:

@RunWith(AndroidJUnit4::class) // AndroidJUnit4
class NavTest { // 导航测试
    @get:Rule // 规则
    val composeTestRule = createAndroidComposeRule<MainActivity>() // 规则

    @Test // 测试:前进和返回
    fun navigate_forwardAndBack() { // 前进和返回
        // 断言首页
        composeTestRule.onNodeWithTag("home").assertIsDisplayed() // 首页

        // 进入详情
        composeTestRule.onNodeWithTag("go_detail").performClick() // 点击
        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule.onAllNodesWithTag("detail").fetchSemanticsNodes().isNotEmpty() // 条件
        }
        composeTestRule.onNodeWithTag("detail").assertIsDisplayed() // 详情

        // 返回首页
        composeTestRule.onNodeWithTag("go_back").performClick() // 点击
        composeTestRule.waitUntil(timeoutMillis = 3000) { // 等待
            composeTestRule.onAllNodesWithTag("home").fetchSemanticsNodes().isNotEmpty() // 条件
        }
        composeTestRule.onNodeWithTag("home").assertIsDisplayed() // 首页
    }
}

解读: 用 testTag 标记首页和详情页。performClick 触发导航。waitUntil 等待目标页面出现。

练习 7:KMP 共享逻辑测试

要求:在 commonTest 中测试一个价格计算函数。

参考答案:

// commonMain/kotlin/com/example/shared/PriceCalculator.kt
class PriceCalculator { // 价格计算器
    fun calculateTotal(price: Double, quantity: Int): Double { // 计算
        require(price >= 0) { "价格不能为负" } // 验证
        require(quantity >= 0) { "数量不能为负" } // 验证
        return price * quantity // 计算
    }
}

// commonTest/kotlin/com/example/shared/PriceCalculatorTest.kt
class PriceCalculatorTest { // 测试
    private val calculator = PriceCalculator() // 计算器

    @Test // 测试:正常计算
    fun calculateTotal_normalCase() { // 正常
        assertEquals(200.0, calculator.calculateTotal(100.0, 2), 0.01) // 断言
    }

    @Test // 测试:零数量
    fun calculateTotal_zeroQuantity() { // 零数量
        assertEquals(0.0, calculator.calculateTotal(100.0, 0), 0.01) // 断言
    }

    @Test // 测试:负价格
    fun calculateTotal_negativePrice_throws() { // 负价格
        assertFailsWith<IllegalArgumentException> { // 断言异常
            calculator.calculateTotal(-1.0, 1) // 计算
        }
    }
}

解读: commonTest 中的测试在 Android 和 iOS 上都会运行。用 kotlin.test 的 assertEquals、assertFailsWith 等 API。

练习 8:综合运用——完整测试策略

要求:为一个商品列表页面制定测试策略,包括单元测试、UI 测试和集成测试。

参考答案:

// 单元测试:ViewModel
class ProductViewModelTest { // ViewModel 测试
    @Test // 测试:加载商品
    fun loadProducts_updatesState() = runTest { // 加载
        val repository = FakeProductRepository() // 假仓库
        val viewModel = ProductViewModel(repository) // ViewModel
        advanceUntilIdle() // 等待
        assertEquals(3, viewModel.uiState.value.products.size) // 断言
    }
}

// UI 测试:Composable
class ProductListTest { // UI 测试
    @get:Rule // 规则
    val composeTestRule = createComposeRule() // 规则

    @Test // 测试:显示商品
    fun showProducts() { // 显示商品
        composeTestRule.setContent { // 设置内容
            ProductList(products = listOf(Product(1, "A", 99.0))) // 列表
        }
        composeTestRule.onAllNodesWithTag("product").assertCountEquals(1) // 断言
    }
}

// 集成测试:Hilt
@HiltAndroidTest // Hilt
class ProductFlowTest { // 集成测试
    @get:Rule(order = 0) // Hilt 规则
    val hiltRule = HiltAndroidRule(this) // Hilt

    @get:Rule(order = 1) // Compose 规则
    val composeTestRule = createAndroidComposeRule<MainActivity>() // Compose

    @BindValue // 绑定
    @JvmField // JVM
    val repository: ProductRepository = FakeProductRepository() // 假仓库

    @Before // 前置
    fun setup() { hiltRule.inject() } // 注入

    @Test // 测试:完整流程
    fun productFlow() { // 完整流程
        composeTestRule.waitUntil(timeoutMillis = 5000) { // 等待
            composeTestRule.onAllNodesWithTag("product").fetchSemanticsNodes().isNotEmpty() // 条件
        }
        composeTestRule.onAllNodesWithTag("product").assertCountEquals(3) // 断言
    }
}

解读: 完整的测试策略覆盖三层:单元测试验证 ViewModel 逻辑,UI 测试验证 Composable 渲染,集成测试验证 Hilt 注入和端到端流程。

本课总结

第9课聚焦 Compose 测试与质量保证,用八个真实案例深入理解了:

  1. createComposeRule() vs createAndroidComposeRule() :前者用于纯 Composable 测试,后者用于 Activity 级别集成测试。
  2. 语义查找器:onNodeWithText、onNodeWithTag、onNodeWithContentDescription。
  3. testTag :为组件添加稳定的测试标识,不受文本变化影响。
  4. 节点操作:performClick、performTextInput、performTouchInput。
  5. 断言:assertIsDisplayed、assertIsEnabled、assertTextEquals。
  6. 同步机制:waitUntil 等待异步操作完成,不要用 Thread.sleep。
  7. Hilt 集成测试:@HiltAndroidTest + HiltAndroidRule + @TestInstallIn。
  8. 导航测试:用 createAndroidComposeRule + waitUntil 测试页面跳转。
  9. KMP 共享逻辑测试:在 commonTest 中用 kotlin.test 测试跨平台逻辑。
  10. 测试金字塔:单元测试最多,UI 测试中等,集成测试较少。

你需要记住:

  • 测试语义树与可访问性语义树是同一个东西。做好可访问性就等于为测试打好了基础。
  • 用 testTag 为组件添加稳定的测试标识。
  • 用 waitUntil 等待异步操作,不要用 Thread.sleep。
  • Hilt 测试用 @HiltAndroidTest + HiltAndroidRule + @TestInstallIn。
  • 测试覆盖 Happy Path、错误路径、空状态和边界条件。
  • 每个案例后面都列出了常见陷阱,写代码时对照检查。

至此,《用案例学Jetpack Compose UI设计》系列已经完成了九课。从第1课的声明式 UI 认知,到第2课的布局系统,第3课的状态管理,第4课的导航架构,第5课的主题设计系统,第6课的动画过渡,第7课的可访问性与国际化,第8课的性能优化,再到第9课的测试与质量保证——你已经走完了一个 Compose 开发者从入门到精通的全路径。

下一步,建议你选择一个真实项目,把本系列学到的知识应用到实践中。每完成一个页面,都问自己五个问题:这个界面的信息层级是什么?状态在哪里?如果屏幕变宽或变窄,它会怎样变化?这个界面流畅吗?我该怎么测试它?这五个问题,就是 Compose UI 设计的完整闭环。

Logo

一站式 AI 云服务平台

更多推荐