Skip to content

单元测试测的是"逻辑",UI 测试测的是"界面真的能点"——真实启动 App、真实点击、真实填表、断言页面反应。这篇讲 Android 的 UI 测试:Compose 项目怎么测、核心 API 是什么、有哪些真坑。

文中 API 用法来自传翔固定资产 App(Jetpack Compose)instrumented 测试的实跑验证;本文代码为静态核对整理,未在本地重跑。

一、Android UI 测试三件套

框架测什么适用
Compose UI 测试Compose 界面(语义树)新项目(Compose 写的界面)✅ 推荐
Espresso传统 View 体系(XML 布局)老项目(View 写的界面)
UI Automator跨应用、系统级(状态栏、设置)少用,测试与其他 App 交互

Compose 项目用 Compose UI 测试androidx.compose.ui.test):它不找坐标、不找 DOM,而是操作 Compose 的语义树(Semantics)——每个可交互的组件(按钮、输入框、文本)在语义树里都有一个节点,按文本/标签/描述就能找到它。

二、环境准备(一次性)

  1. 测试代码放 src/androidTest/(不是 src/test/)——instrumented 测试要装到设备/模拟器上跑,不是 JVM 上跑
  2. build.gradle 加依赖
groovy
android {
    defaultConfig {
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
}
dependencies {
    androidTestImplementation("androidx.test.ext:junit:1.1.5")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    // debugImplementation 是为了 createComposeRule 在 debug 包可用
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}
  1. 模拟器/真机adb devices 能看到设备,跑 ./gradlew :app:connectedDebugAndroidTest

三、核心三步:Rule → 找节点 → 操作断言

① 规则(Rule)——告诉测试"启动哪个 Activity":

kotlin
@RunWith(AndroidJUnit4::class)
class LoginTest {

    @get:Rule
    val rule = createAndroidComposeRule<MainActivity>()   // 启动 App 的 MainActivity
}

② 找节点——在语义树里定位元素:

kotlin
rule.onNodeWithText("登录")            // 按文本找(按钮/标题)
rule.onNodeWithTag("usernameInput")    // 按 testTag 找(推荐:加 testTag 不怕文案改)
rule.onAllNodesWithText("待办")        // 找所有匹配的(同文本出现多处时)

给关键控件加 testTag(测试专用标识,和 UI 文案解耦):

kotlin
OutlinedTextField(
    value = username,
    onValueChange = { username = it },
    modifier = Modifier.testTag("usernameInput")   // 测试通过它找,文案改了也不怕
)

③ 操作 + 断言

kotlin
rule.onNodeWithTag("usernameInput").performTextInput("admin")   // 输入
rule.onNodeWithTag("passwordInput").performTextInput("123456")
rule.onNodeWithText("登录").performClick()                       // 点击
rule.onNodeWithText("工作台").assertIsDisplayed()               // 断言页面跳转成功

四、完整例子:登录测试

kotlin
@RunWith(AndroidJUnit4::class)
class LoginTest {

    @get:Rule
    val rule = createAndroidComposeRule<MainActivity>()

    @Test
    fun 空账号密码_登录按钮不可点() {
        // 输入框留空 → 登录按钮应处于禁用状态
        rule.onNodeWithText("登录").assertIsNotEnabled()
    }

    @Test
    fun 输入正确账号密码_跳转工作台() {
        rule.onNodeWithTag("usernameInput").performTextInput("admin")
        rule.onNodeWithTag("passwordInput").performTextInput("123456")
        rule.onNodeWithText("登录").performClick()
        // 提交是网络异步:等"登录页标志消失"而不是固定 sleep
        rule.waitUntil(10_000) {
            rule.onAllNodesWithText("工作台").fetchSemanticsNodes().isNotEmpty()
        }
    }
}

五、真实项目的 5 个坑(都是踩过的)

  1. 等网络异步别用 waitForIdle:表单提交是网络请求,waitForIdle 只等 UI 空闲、不等网络。要等"关键节点消失/出现"(如登录页消失、工作台出现),用 waitUntil + 节点查询。

  2. 无限动画会让 waitUntil 超时:页面有加载转圈(无限动画)时,Compose 的 idle 机制永不结束,waitUntil 直接超时。这种场景用 while 循环 + Thread.sleep(200) 轮询代替。

  3. Toast 不进语义树:原生 Toast 找不到节点,别断言 Toast 文本。要么改用 Snackbar(进语义树),要么断言 Toast 出现后页面的结果(如弹窗关闭、列表刷新)。

  4. 系统弹窗会盖住 Compose:第一次请求相机/蓝牙权限时,系统权限弹窗盖住页面,测试报 "No compose hierarchies found"。解决:跑测试前 adb shell pm grant <包名> android.permission.CAMERA 预授权。

  5. 按钮禁用 ≠ 没有点击行为:Compose 里 disabled 按钮的 hasClickAction() 仍然返回 true,判断禁用要用 SemanticsProperties.Disabled in node.config,别用"没有 click action"来判断。

  6. 硬件依赖不写测试:相机扫码、蓝牙打印这类依赖真机硬件的操作不做自动化(每次结果不稳定),页面加载和防御路径(如"未连接提示")保留。

六、Web vs Android:UI 测试对比

维度Playwright(Web)Compose UI 测试(Android)
找元素CSS 选择器(DOM)语义树节点(文本/testTag)
启动浏览器打开 URL真机/模拟器启动 Activity
异步等待waitForSelector 自动等待waitUntil + 节点查询
运行环境本机 Node设备 + Gradle connected 测试
跑批npx playwright test./gradlew connectedDebugAndroidTest

共同点:都是真实启动应用、真实点击、断言结果,都是"用户视角"的测试——Web 端用 Playwright、Android 端用 Compose UI 测试,一套思路。

小结

  • Android UI 测试三件套:Compose(新)/ Espresso(旧 View)/ UI Automator(系统级)
  • 核心三步:createAndroidComposeRule 启动 → onNodeWithText/WithTag 找节点 → performClick/assertIsDisplayed 断言
  • 关键控件加 testTag,和 UI 文案解耦
  • 异步等待用节点查询,别依赖 waitForIdle;Toast 不进语义树;系统权限弹窗要预授权

想了解 Web 端怎么做 UI 测试,看《Playwright 快速入门》;想先搞清楚测试的基本概念和断言,看《JUnit 5 快速入门》。