很多应届生第一次写 UI 自动化,代码是能跑,但活不长。今天页面调了一下布局、把某个 div 换成了 section,第二天回归一跑,用例红了一片。你去问「是不是登录坏了」,结果点进去一看,功能好好的,是定位找不着元素了。
这就是「选择器地狱」:用例的正确性和页面的实现细节死死绑在一起,页面一动,用例就碎。面试官让你现场看一段自动化代码,第一眼看的往往不是你断言了多少东西,而是你怎么定位元素——因为定位方式,直接暴露了你会不会写能长期维护的用例。
这篇只钉死一个场景:给一个登录页,写第一条能扛住小改版的自动化用例。 讲清三件事——选择器该按什么优先级选、一条会「自愈」的登录用例怎么写、断言到底该断什么。全篇用 Playwright 原生 API,不引入任何 AI 框架,先把地基打稳。
一、先看一段「地狱长什么样」
假设登录页里有用户名输入框、密码输入框、登录按钮。新手最常见的写法是直接从浏览器里「复制 XPath」,或者盯着 DOM 结构写 CSS:
/html/body/div[2]/div/div/form/div[1]/input ← 复制来的绝对 XPath
app > div.login-wrap > form > div:nth-child(1) > input ← 盯结构写的 CSS
这两行现在能跑。但只要发生下面任意一件事,它们就会红:
登录卡片外面多包了一层容器 → div[2] 变成 div[3];
用户名和密码的先后顺序调了一下 → nth-child(1) 指到了密码框;
某个 div 换成了 form 或 section → 路径中间断掉。
问题的根子在于:这些选择器描述的是「元素在 DOM 树里的位置和长相」,而不是「元素是什么、给用户干什么用」。 位置和长相是实现的副产品,是最容易变的东西;而「这是用户名输入框」「这是登录按钮」是语义,是相对稳定的东西。
判断一个选择器稳不稳,有个很土但很好用的自测法:假设前端同学做了一次「不改功能、只调样式和结构」的小重构,你这行定位会不会红? 会红的,就是绑在了实现细节上;不会红的,才是绑在了语义上。登录页几乎是所有系统里最稳定的页面之一,功能几年不变,但视觉改版、组件库升级、无障碍改造却很频繁——它恰好是最适合练「写稳定选择器」的场景,也是面试里最常被拿来考的一条用例。
二、选择器优先级:能表达语义的排前面
Playwright 官方的最佳实践口径很清楚:优先用面向用户、语义化的定位方式,把基于 DOM 结构的 CSS、尤其是 XPath 当作兜底。把它排成一个阶梯,从上到下依次降级:
优先级
定位方式
例子
稳定性
为什么
1(首选)
role + 可读名称
getByRole("button", { name: "登录" })
高
绑定「这是什么、叫什么」,与 DOM 结构解耦
2
label / placeholder
getByLabel("用户名")
、getByPlaceholder("请输入密码")
高
表单控件天然有可见文字,用户看得见的东西不常改
3
团队约定 testid
getByTestId("login-submit")
中高
专为自动化留的钩子,改版不会顺手删
4
文本
getByText("登录")
中
文案可能微调(「登录」改「立即登录」)
5
语义化 CSS
css=form .submit-btn
低中
依赖 class,重构易变,仅作兜底
6(最后)
结构型 CSS / 绝对 XPath
nth-child(1)
、/html/body/div[2]/...
低
与实现细节死绑,页面一动就碎
这张表最该记住的一行是「首选」:优先按 role 加可读名称定位。getByRole("textbox", { name: "用户名" }) 说的是「那个叫用户名的输入框」,无论它外面包了几层 div、class 叫什么、用的是 input 还是新组件,只要它在页面上还是「用户名输入框」,就能找到。
testid 排第三,是因为它需要前端配合埋 data-testid。理想情况下首选 role/label,因为那是用户真正看得见的语义,不需要额外约定;但当元素没有可读文本(比如一个纯图标按钮),或者 role/label 会命中多个时,团队约定的 testid 就是最稳的补丁。
一句话原则:先问「这个元素对用户是什么」,再退而问「它在 DOM 里长什么样」。 顺序反了,你就掉进地狱了。
还有一层容易被应届生忽略的细节:Playwright 默认是「严格模式」,一个定位命中多个元素时会直接报错,而不是随便挑第一个。这在登录页很常见——比如页面同时有「登录」按钮和顶部导航里一个叫「登录」的链接,getByRole("button", { name: "登录" }) 就可能命中两个。遇到这种情况不要退回去写 XPath 数第几个,而是加限定:把范围收窄到登录表单里,用 page.locator("form.login-form").getByRole("button", { name: "登录" })。收窄作用域用的那层容器,本身也要挑稳定的(表单、语义区块),而不是挑层级路径。严格模式报错其实是好事,它逼你把定位写得更精确。
三、一条会自愈的登录用例
「自愈」听起来玄,拆开其实很朴素:首选定位找不到时,不立刻报错,而是按预定义的备用定位顺序往下试,命中任意一个就继续,全都没命中才失败,并且给出可读的错误。
注意,这里的自愈不是偷偷改测试意图,而是「同一个语义目标,多几条通往它的路」。目标始终是「那个登录按钮」,只是当 role 定位因为改版失效时,退到 testid、再退到语义 CSS 去找它。
先看一个登录页的示例结构(本文设定值):
用户名
密码
下面是一个定位回退的小封装。它接收一组「候选定位器」,依次尝试,返回第一个可见(或存在)的那个:
// helpers/locator.ts
import { Page, Locator } from "@playwright/test";
/**
- 自愈定位:按优先级依次尝试候选定位器,命中第一个就返回。
- candidates 必须从「最语义化」排到「最兜底」,顺序就是降级策略。
全部未命中时抛出可读错误,并附上每个候选失败的原因。
*/
export async function resilientLocator(
page: Page,
candidates: { name: string; build: (p: Page) => Locator }[],
opts: { state?: "visible" | "attached"; timeout?: number } = {}
): Promise {
const state = opts.state ?? "visible";
const timeout = opts.timeout ?? 3000;
const misses: string[] = [];for (const c of candidates) {
const loc = c.build(page);
try {
await loc.first().waitFor({ state, timeout });
// 命中非首选定位时记一笔,方便事后把首选补回来
if (c !== candidates[0]) {console.warn(`[self-heal] 首选未命中,回退到: ${c.name}`);}
return loc.first();
} catch {
misses.push(${c.name}(未${state === "visible" ? "可见" : "找到"}));
}
}
throw new Error(
所有候选定位均失败:\n - ${misses.join("\n - ")}\n+
请检查页面是否改版,并把新的首选定位补回候选列表。
);
}
有了这个封装,登录用例就可以这么写——每一步都先给语义首选,再挂上兜底:
// tests/login.spec.ts 运行:npx playwright test tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { resilientLocator } from "../helpers/locator";
test("用户名密码正确应登录成功并进入首页", async ({ page }) => {
await page.goto("http://localhost:3000/login");
// 用户名:首选 label,兜底 placeholder / name 属性
const username = await resilientLocator(page, [
{ name: "label=用户名", build: (p) => p.getByLabel("用户名") },
{ name: "placeholder", build: (p) => p.getByPlaceholder("请输入用户名") },
{ name: "css[name=username]", build: (p) => p.locator('input[name="username"]') },
]);
// 密码:同理
const password = await resilientLocator(page, [
{ name: "label=密码", build: (p) => p.getByLabel("密码") },
{ name: "placeholder", build: (p) => p.getByPlaceholder("请输入密码") },
{ name: "css[type=password]", build: (p) => p.locator('input[type="password"]') },
]);
// 登录按钮:首选 role+name,兜底 testid,再兜底 type=submit
const submit = await resilientLocator(page, [
{ name: "role=button 登录", build: (p) => p.getByRole("button", { name: "登录" }) },
{ name: "testid=login-submit", build: (p) => p.getByTestId("login-submit") },
{ name: "css[type=submit]", build: (p) => p.locator('button[type="submit"]') },
]);
await username.fill("alice");
await password.fill("Passw0rd!"); // 本文示例设定值
await submit.click();
// 断言:见第四节,不要只断「没报错」
await expect(page).toHaveURL(/\/(home|dashboard)/);
await expect(page.getByRole("navigation")).toContainText("退出");
});
依赖与运行方式:Node.js 18+,npm init playwright@latest 建项目(或 npm i -D @playwright/test && npx playwright install),把两个文件放进对应目录,npx playwright test tests/login.spec.ts 即可跑。localhost:3000/login 换成你自己的被测地址。
这里有个关键点:自愈不等于「随便命中一个就行」。 封装里那句 console.warn 是特意留的——一旦回退到非首选定位,就要在日志里留痕。因为回退只是「让用例这次没红」,它同时也在告诉你:首选定位已经失效了,页面大概率改版了,你得抽空把新的首选补回候选列表。如果只看用例绿不绿、不看这条 warn,你会慢慢退回到「全靠兜底定位硬撑」的状态,那还是地狱,只是红得晚一点。
候选列表怎么排,还有两条要守住的边界。一是降级要「同语义、跨实现」,不能「跨语义」:登录按钮的候选里可以放 role、testid、button[type=submit],因为它们指向的都是同一个「提交登录」的动作;但绝不能把「随便页面上第一个 button」塞进候选,那会在改版后悄悄点错东西,比用例直接红更危险。二是候选不宜太多,两三条足够,排太多会让每次超时等待叠加、拖慢用例,也说明你的首选本来就没选对。自愈是「保险丝」,不是让你从此不管定位质量。
四、断言该断什么:别只断「没报错」
新手用例最常见的第二个坑,是断言写得太轻。很多人只写一句「点完登录没抛异常就算过」,或者只断一个状态码、只断页面没白屏。这种用例的问题是:它只能证明「程序没崩」,证明不了「业务对了」。 登录接口返回 500 但页面做了兜底跳转、或者密码错了却还是跳到了首页——这些真正的 bug,轻断言一个都抓不到。
一条登录成功用例,至少要断到三层:
断言层次
断什么
例子
结果状态
到了该到的地方
URL 跳到 /home;出现「退出」入口
关键数据
身份带对了
页面显示当前登录用户名 alice
反向验证
错误路径真的被拦
密码错时不跳转,且出现「用户名或密码错误」提示
正向用例断前两层,反向用例专门断第三层——「登错了应该拦住」和「登对了应该放行」是同等重要的两条用例。 只写正向,等于默认了「无论输什么都放行」也能过测。反向用例可以直接复用前面的自愈封装,只是填错密码、断言停留在登录页并出现错误提示:
test("密码错误应停留在登录页并提示", async ({ page }) => {
await page.goto("http://localhost:3000/login");
await page.getByLabel("用户名").fill("alice");
await page.getByLabel("密码").fill("wrong-pass"); // 故意填错
await page.getByRole("button", { name: "登录" }).click();
await expect(page).toHaveURL(/\/login/); // 没被放行
await expect(page.getByText(/用户名或密码错误|账号或密码/)).toBeVisible();
});
断言的原则一句话:断「用户能感知的业务结果」,别断「代码有没有抛异常」。 URL、可见文案、身份数据这些是用户真能看见的东西,也是最该被守住的东西;而「没报错」只是最低门槛,远不足以说明这条流程是对的。
补一个应届生常踩的时序坑:登录是异步的,点完按钮页面不会瞬间跳转。如果你用 page.url() 立刻取值再断言,很可能取到跳转前的旧地址,用例就会「时对时错」地闪。expect(locator).toBeVisible()、expect(page).toHaveURL() 这类 Playwright 断言自带「轮询重试」,会在超时时间内反复检查直到条件成立,这正是它们的价值——所以断言一律用 expect(...),别自己写 if (page.url() === ...) 这种一次性判断。这也是「用例时好时坏」最常见的根因之一,而它和选择器写得好不好无关,纯粹是断言姿势的问题。
五、写在最后
选择器地狱不是工具的问题,是思路的问题:你把用例绑在了页面最容易变的那一层(DOM 结构与长相)上,而不是绑在它最稳定的那一层(元素对用户的语义)上。 优先 role/label、testid 兜底、CSS/XPath 收尾,再配一层会留痕的自愈回退,最后把断言写到「用户能感知的业务结果」——这几件事各花不了多少时间,但决定了你的第一条 UI 自动化是能用一个月,还是页面一动就报废。
定位绑语义、回退要留痕、断言断结果——这三条做到了,你的用例才算真的能扛改版。