单元测试测的是"逻辑",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)——每个可交互的组件(按钮、输入框、文本)在语义树里都有一个节点,按文本/标签/描述就能找到它。
二、环境准备(一次性)
- 测试代码放
src/androidTest/(不是src/test/)——instrumented 测试要装到设备/模拟器上跑,不是 JVM 上跑 - build.gradle 加依赖:
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")
}- 模拟器/真机:
adb devices能看到设备,跑./gradlew :app:connectedDebugAndroidTest
三、核心三步:Rule → 找节点 → 操作断言
① 规则(Rule)——告诉测试"启动哪个 Activity":
@RunWith(AndroidJUnit4::class)
class LoginTest {
@get:Rule
val rule = createAndroidComposeRule<MainActivity>() // 启动 App 的 MainActivity
}② 找节点——在语义树里定位元素:
rule.onNodeWithText("登录") // 按文本找(按钮/标题)
rule.onNodeWithTag("usernameInput") // 按 testTag 找(推荐:加 testTag 不怕文案改)
rule.onAllNodesWithText("待办") // 找所有匹配的(同文本出现多处时)给关键控件加 testTag(测试专用标识,和 UI 文案解耦):
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.testTag("usernameInput") // 测试通过它找,文案改了也不怕
)③ 操作 + 断言:
rule.onNodeWithTag("usernameInput").performTextInput("admin") // 输入
rule.onNodeWithTag("passwordInput").performTextInput("123456")
rule.onNodeWithText("登录").performClick() // 点击
rule.onNodeWithText("工作台").assertIsDisplayed() // 断言页面跳转成功四、完整例子:登录测试
@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 个坑(都是踩过的)
等网络异步别用
waitForIdle:表单提交是网络请求,waitForIdle只等 UI 空闲、不等网络。要等"关键节点消失/出现"(如登录页消失、工作台出现),用waitUntil+ 节点查询。无限动画会让
waitUntil超时:页面有加载转圈(无限动画)时,Compose 的 idle 机制永不结束,waitUntil直接超时。这种场景用while循环 +Thread.sleep(200)轮询代替。Toast 不进语义树:原生 Toast 找不到节点,别断言 Toast 文本。要么改用 Snackbar(进语义树),要么断言 Toast 出现后页面的结果(如弹窗关闭、列表刷新)。
系统弹窗会盖住 Compose:第一次请求相机/蓝牙权限时,系统权限弹窗盖住页面,测试报 "No compose hierarchies found"。解决:跑测试前
adb shell pm grant <包名> android.permission.CAMERA预授权。按钮禁用 ≠ 没有点击行为:Compose 里 disabled 按钮的
hasClickAction()仍然返回 true,判断禁用要用SemanticsProperties.Disabled in node.config,别用"没有 click action"来判断。硬件依赖不写测试:相机扫码、蓝牙打印这类依赖真机硬件的操作不做自动化(每次结果不稳定),页面加载和防御路径(如"未连接提示")保留。
六、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 快速入门》。
