幕 II · 测试的形状CH 07进度 07/24

端到端与 UI 测试:像用户一样点一遍

金字塔的塔尖。端到端测试(e2e)是唯一能回答那个终极问题的测试 ——「用户真的能用吗?」它启动真的 App、点真的按钮、填真的表单、等真的结果,穿过 UI、逻辑、数据库、网络的一整条链路。它的信心是所有测试里最高的,因为它最接近真实。代价也是最高的:最慢、最脆、最爱抽风。这一章讲怎么写它(Espresso / Compose Test / Playwright)、为什么它只能少而精、以及所有 UI 测试都绕不开的那个坑 —— 测试的手,总是比界面的反应快。

EspressoCompose TestPlaywright同步问题选择器脆性

端到端是什么:从 UI 穿到底

前面几层都在「隔离」—— 单元隔离掉外部,集成隔离掉无关部件。端到端反过来:什么都不隔离,把整个系统当黑盒,从用户能碰到的入口(屏幕)进去,看最终结果对不对。

◆ 判词 · e2e 测的是「旅程」,不是「零件」

它不关心内部怎么实现,只关心一条用户旅程能不能走通:「打开 App → 输入账号密码 → 点登录 → 看到首页」。中间任何一环断了(按钮没绑事件、接口挂了、页面没跳转),它都会红。

这是它无可替代的价值:前面所有层各自绿了,不代表它们接起来能给用户一个能用的产品。e2e 是唯一验证这件事的测试。

Android:Espresso 与 Compose Test

Android 的 UI 测试跑在 androidTest/(真机或模拟器)。传统 View 体系用 Espresso,Compose 用 Compose Test。写法都是「找到元素 → 操作它 → 断言界面状态」:

// Compose Test —— 登录旅程
@get:Rule val compose = createAndroidComposeRule<MainActivity>()

@Test fun `输入正确账号密码能进首页`() {
    compose.onNodeWithText("邮箱").performTextInput("a@b.com")
    compose.onNodeWithText("密码").performTextInput("12345678")
    compose.onNodeWithText("登录").performClick()

    compose.onNodeWithText("欢迎回来").assertIsDisplayed()   // 断言:跳到首页了
}

三个动词覆盖了大部分场景:onNodeWith...(按文本 / 内容描述 / testTag 找元素)、perform...(点击 / 输入 / 滑动)、assert...(断言显示 / 文本 / 启用状态)。Espresso 换了套 API(onView(withText(...)).perform(click()))但思路一模一样。

Web:Playwright

前端这边,Playwright(和它的前辈 Cypress、Selenium)是同一个模式,只是操作的是浏览器:

test('输入正确账号密码能进首页', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('邮箱').fill('a@b.com')
  await page.getByLabel('密码').fill('12345678')
  await page.getByRole('button', { name: '登录' }).click()

  await expect(page.getByText('欢迎回来')).toBeVisible()
})

注意 getByRole('button', { name: '登录' }) —— 按角色和可见文本找元素,而不是按 CSS class 或 id。这是现代 UI 测试的一条重要原则,下面会讲为什么。

绕不开的坑:测试比界面快

UI 测试最大的头疼,不是 API 难,是时序。你点了「登录」,界面要发网络请求、等响应、刷新 UI —— 这中间有几十到几百毫秒。而测试代码的下一行 assertIsDisplayed("欢迎回来")几微秒后就执行了。这时首页还没渲染出来,测试就红了 —— 不是因为有 bug,是因为测试的手比界面的反应快

⚠ 陷阱 · Thread.sleep(2000) 是 UI 测试里的原罪

新手的第一反应:那就「等一下」呗,加个 Thread.sleep(2000)。这是最坏的解法,两头都输:

  • :每个测试硬等 2 秒,几百个测试就是十几分钟浪费在 sleep 上。
  • 还是脆:今天网络快 2 秒够,明天 CI 机器卡一下 2 秒不够 —— 它变成了一个 flaky 测试(第 17 章)。你只能把 sleep 调更长,进一步拖慢一切。

sleep 是在赌一个固定的时间,而真实世界的耗时是浮动的。赌注下大了浪费,下小了抽风。正确的做法是「等到某个条件成立」,而不是「等固定时长」。

好在成熟框架都内建了「智能等待」机制,把这件事替你办了:

◆ 判词 · 等条件,不等时间
  • Compose Test:自带同步 —— 它知道 Compose 的重组何时空闲,会自动等 UI 稳定再断言。异步数据用 waitUntil { } 等条件。
  • Espresso:内建 IdlingResource —— 一个「我还在忙」的信号。你把网络/后台任务注册进去,Espresso 会等所有 IdlingResource 都空闲了才继续。核心思想:让 App 主动告诉测试「我好了」。
  • Playwright:自动等待(auto-waiting) —— expect(...).toBeVisible() 会自动轮询到元素出现或超时,不用你手动 sleep。

选择器的脆性:别按「长什么样」找元素

UI 测试脆弱的第二大来源,是怎么定位元素。如果你用 CSS 路径 .container > div:nth-child(3) > button 或者内部 id 去找按钮,那么设计师稍微调一下布局,测试就全红 —— 哪怕功能一点没坏。

◆ 判词 · 按用户看得见的东西定位

优先用用户能感知的属性找元素:可见文本、无障碍标签(content description / aria-label)、角色(按钮 / 输入框)。这些是「这个元素对用户意味着什么」,不会因为你重构了 DOM/布局就变。

Android 里,给关键元素挂一个稳定的 Modifier.testTag("login_button") 也是常见做法 —— 一个专门给测试用的、不随布局变的锚点。要躲开的是「按视觉结构定位」,因为视觉结构最爱变。

为什么 e2e 只能少而精

回到金字塔。端到端信心最高,你可能想:那我全用它不就好了?第 4 章的计算器已经算过账 —— 会滑成冰淇淋蛋筒。这里补一个更本质的理由:

⚠ 陷阱 · e2e 的「诊断能力」极差

一个单元测试红了,你几乎立刻知道 bug 在哪(就那一小块)。一个 e2e 红了,你只知道「登录旅程断了」—— 可能是 UI、可能是接口、可能是数据库、可能是那条测试自己抽风。它告诉你「有问题」,但几乎不告诉你「在哪」。

加上它慢、它 flaky,所以 e2e 的正确用法是:只覆盖那几条最关键、断了就是重大事故的用户旅程(登录、下单、支付)。其余的行为,交给下面又快又准的层。

▲ AI 与 e2e:两个坏倾向

一是过度生成。你说「给这个页面加测试」,AI 常常给你一堆端到端式的大测试 —— 它们看起来最"全面"。不加约束,它会亲手把你的金字塔倒过来。要明确告诉它哪些逻辑该下沉到单元/集成层。

二是脆弱选择器。AI 生成 UI 测试时,爱用它在代码里看到的 id、class、xpath 来定位 —— 恰恰是最易碎的那种。审查 AI 写的 UI 测试,重点看它怎么找元素:是按用户可见的文本/角色,还是按内部结构。

✚ 动手

1. 列出你 App 里"断了就是 P0 事故"的旅程(通常 3-5 条)。这些,而且大概只有这些,值得写 e2e。

2. 在你的 UI 测试里搜 sleep。每一处都是一个待爆的 flaky。换成「等条件」(waitUntil / IdlingResource / auto-wait)。

3. 看一个 UI 测试怎么定位元素。如果它按 nth-child、内部 id、xpath 找,试着换成按可见文本或 testTag。

还剩一种测试没归位,它不在金字塔的任何一层里,而是横跨所有层 —— 冒烟测试。下一章:先花 8 秒确认「它根本没炸」,再决定要不要花 8 分钟跑全量。它是 CI 流水线的守门员。