compose编码13- @compose 组件单元测试

官方文档与代码

Codelab-在 Jetpack Compose 中进行测试

codelab-android-compose/TestingCodelab/app/src/androidTest/java/com/example/compose/rally/AnimatingCircleTests.kt at main · android/codelab-android-compose
代码库中 在android stuido 2026.1.2

import androidx.compose.ui.test.junit4.createComposeRule
改为
imp

注意,有的设备要设置才可以运行 测试用例

比如小米的note13 我运行测试用例就一直卡死

用了android studio 自带的模拟器 pixel 10 才成功把 单元测试运行到手机上

运行效果

直接测试Text
测试RallyTopAppBar

代码


package com.example.compose.rally

import androidx.compose.material.Text
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import com.example.compose.rally.ui.components.RallyTopAppBar
import org.junit.Rule
import org.junit.Test

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    @Test
    fun myTest() {
        composeTestRule.setContent {
            Text("You can set any Compose content!")
        }
        Thread.sleep(5000)
    }

    @Test
    fun rallyTopAppBarTest_currentTabSelected() {
        composeTestRule.setContent {
            RallyTopAppBar(
                allScreens = RallyScreen.entries,
                onTabSelected = { },
                currentScreen = RallyScreen.Accounts
            )
        }

        composeTestRule.onRoot().printToLog("currentLabelExists")  // 在logat输出语义树
        Thread.sleep(5000)
        composeTestRule
            .onNodeWithContentDescription(RallyScreen.Accounts.name)
            .assertIsSelected()
    }

    @Test
    fun rallyTopAppBarTest_currentLabelExists() {
        composeTestRule.setContent {
            RallyTopAppBar(
                allScreens = RallyScreen.entries,
                onTabSelected = { },
                currentScreen = RallyScreen.Accounts
            )
        }

        composeTestRule
            .onNode(
                hasText(RallyScreen.Accounts.name.uppercase()) and
                    hasParent(
                        hasContentDescription(RallyScreen.Accounts.name)
                    ),
                useUnmergedTree = true
            )
            .assertExists()
    }
}

logcat的日志情况

image.png
     D  printToLog:
        Printing with useUnmergedTree = 'false'
        Node #1 at (l=0.0, t=142.0, r=1080.0, b=289.0)px
         |-Node #2 at (l=0.0, t=142.0, r=1080.0, b=289.0)px
           IsContainer = 'true'
           Shape = 'RectangleShape'
            |-Node #3 at (l=0.0, t=142.0, r=1080.0, b=289.0)px
              [SelectableGroup]
               |-Node #4 at (l=42.0, t=184.0, r=105.0, b=247.0)px
               | ContentDescription = '[Overview]'
               | Focused = 'false'
               | Role = 'Tab'
               | Selected = 'false'
               | Shape = 'RectangleShape'
               | Actions = [OnClick, RequestFocus]
               | MergeDescendants = 'true'
               | ClearAndSetSemantics = 'true'
               |-Node #6 at (l=189.0, t=184.0, r=469.0, b=247.0)px
               | ContentDescription = '[Accounts]'
               | Focused = 'false'
               | Role = 'Tab'
               | Selected = 'true'
               | Shape = 'RectangleShape'
               | Actions = [OnClick, RequestFocus]
               | MergeDescendants = 'true'
               | ClearAndSetSemantics = 'true'
               |-Node #10 at (l=553.0, t=184.0, r=616.0, b=247.0)px
                 ContentDescription = '[Bills]'
                 Focused = 'false'
                 Role = 'Tab'
                 Selected = 'false'
                 Shape = 'RectangleShape'
                 Actions = [OnClick, RequestFocus]
                 MergeDescendants = 'true'
                 ClearAndSetSemantics = 'true'

这是 Compose 的语义树(Semantics Tree),是 UI 测试的核心。我来逐层解析:


整体结构

Node #1 (根) → 整个屏幕
  └── Node #2 → 内容区域
        └── Node #3 → SelectableGroup (Tab 容器)
              ├── Node #4  → Overview Tab
              ├── Node #6  → Accounts Tab  ← 当前选中
              └── Node #10 → Bills Tab

关键属性解读

Accounts Tab (Node #6) 为例:

属性 含义
ContentDescription = '[Accounts]' Accounts 节点的无障碍描述,测试用 onNodeWithContentDescription("Accounts") 找的就是它
Role = 'Tab' Tab 组件角色,说明这是个 Tab 组件
Selected = 'true' true 当前选中的 Tab
Focused = 'false' false 没有输入焦点
MergeDescendants = 'true' true 子节点的语义合并到父节点
ClearAndSetSemantics = 'true' true 清除了默认语义,手动设置了上面的属性
Actions = [OnClick, RequestFocus] 支持的操作:点击、请求焦点

三个 Tab 对比

Tab ContentDescription Selected 状态
Overview [Overview] false 未选中
Accounts [Accounts] true 当前选中
Bills [Bills] false 未选中

为什么测试能通过

composeTestRule
    .onNodeWithContentDescription(RallyScreen.Accounts.name)  // → 找到 Node #6
    .assertIsSelected()                                        // → 检查 Selected = 'true'

完全匹配! Accounts 节点的 Selected 确实是 true


MergeDescendants = 'true' 的含义

Node #6 (Accounts Tab)  ← 合并了所有子节点语义
  └── 子节点(图标 + 文字)
        └── 语义被合并到父节点,不单独显示

所以你在语义树里看不到 Accounts 内部的 Text 节点,因为它们被合并到 Node #6 了。

这也是第二个测试需要 useUnmergedTree = true 的原因:

composeTestRule.onNode(
    hasText("ACCOUNTS") and hasParent(hasContentDescription("Accounts")),
    useUnmergedTree = true  // ← 不合并,才能看到内部的 Text 节点
)

坐标信息

Node #6 at (l=189.0, t=184.0, r=469.0, b=247.0)px
           ↑left  ↑top    ↑right  ↑bottom

计算:

  • 宽度 = 469 - 189 = 280px
  • 高度 = 247 - 184 = 63px

这是 Accounts Tab 在屏幕上的实际位置和大小。


一句话总结

语义树 = Compose UI 的"无障碍视图",测试框架通过它查找和验证节点,而不是直接操作像素。printToLog 把这个树打印出来,让你看到每个节点的属性和层级关系。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容