![[AI 测试实践] 深入理解 a11y 语义标签](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fheader.3ujjiwiamoslv.png&w=3840&q=75)
[AI 测试实践] 深入理解 a11y 语义标签
组件测试独有的前置动作是「定位节点」,而定位锚点同样应独立于实现。本文讲清 a11y 语义标记为何能复用成一份贯穿组件实现、单测、E2E 的语义契约。
上一篇《AI 时代,单测的价值点到底在哪?》聊了一条贯穿全文的判断:一条测试立不立得住,取决于它的验证基准是否独立于被测实现——断言的期望值要是照着实现算出来的结果回填的,那测试只是把实现复述了一遍,什么都没证明。
那篇管的是断言:「什么叫对」要独立于实现。这篇把话题收到一类特定的测试上——组件测试(以及更上层的 E2E)。这类测试和纯逻辑单测有个根本区别:测一个纯函数、一个 reducer,输入输出都在手边,不存在「找不找得到」的问题;可一旦测的是渲染出来的界面,断言任何一个行为之前,都得先在页面里定位到那个节点——点哪个按钮、读哪个文本框的值。定位是组件测试独有的前置动作,这篇要问的就是:这个动作的锚点,该锚在哪?
这不是个小问题。它和上篇其实是同一个命题的两面:上篇要断言基准独立于实现,这篇要定位锚点同样独立于实现。而恰好有一层现成的东西满足这个条件,还顺带把 AI 和真人无障碍用户一起服务了——就是 a11y(accessibility,无障碍;数字 11 代表 accessibility 首尾字母间省略的 11 个字符)语义标记。本节想说清楚:为什么一份原本写给屏幕阅读器的标记,能被复用成一份贯穿组件实现、单测、E2E 的语义契约,以及它管得到什么、管不到什么。
1. 从一个脆测试说起
先看一条很常见、也很脆的组件测试写法:
// 点击工具栏里的第二个按钮
container.querySelector('.toolbar .btn-group > button:nth-child(2)').click()它能跑通,但它锚定的是实现结构:按钮在 .toolbar 里、是 .btn-group 的第二个子节点、class 叫 .btn-group。DOM 是实现里最易变的那一层——今天这块是 div 堆的,明天重构成 <article> 加语义标签;嵌套加一层、顺序调一下、换套 UI 库把 class 全改名,都是常见的重构改动。上面这三条依赖只要动一条,定位就失效,测试跟着崩。你会得到一个「明明没改坏功能、测试却失败了」的假警报,然后花时间去修一条根本不在保护行为的测试。根子在于它黏在了实现最容易抖动的那层——这些改动大多只是在追实现,并没有碰业务行为。
换成基于语义的定位,情况完全不同:
screen.getByRole('button', { name: '加粗' }).click()这条查询说的是「找那个**角色是按钮、名字叫『加粗』**的元素」。它不关心这个按钮在 DOM 里嵌了几层、class 叫什么、排第几个。role(角色)和 name(名字)是纯语义层的概念,和 DOM 结构解耦:DOM 从 div 改成 <button>、从这个容器挪到那个容器,只要它对用户来说还是「那个叫加粗的按钮」,语义没变,查询就照样命中,测试一个字都不用改。
这不是某一个测试库的偏方。Testing Library 把查询方式排了优先级,getByRole 排第一、getByTestId 排最后,理由是「测试应尽量贴近真实用户(包括使用辅助技术的用户)与代码交互的方式」。Playwright 在 E2E 侧给出了同样的建议——优先用 role 定位,并明确点出「CSS 和 XPath 不推荐,因为 DOM 经常变化,会导致测试不稳定」。两个互相独立的测试框架,在「定位锚点该锚在哪」这件事上给出了同一个答案:锚在「这个节点是什么、叫什么」,而不是「它在 DOM 里长什么样」。前者是用户可感知的语义,稳定;后者是实现细节,易变。
值得先埋一句的是:能消费这层语义的,不只有测试框架和真人。AI 现在也能读懂这层「节点是什么、叫什么」的语义——这恰恰是这套实践在 AI 时代真正的分量所在,后面第 3 节会专门展开。
一句话收束:测试应该面向语义结构,而不是面向具体实现。 这和上一篇严丝合缝——上篇要断言基准独立于实现,这篇要定位锚点独立于实现,病根也同源:依赖 DOM 结构定位是「定位抄了实现」,照实现回填断言是「断言抄了实现」,都是把测试黏在了实现的易变层上。

那么问题来了:这个「角色是按钮、名字叫加粗」的语义,是从哪来的?
2. 这层语义,本是无障碍设计的产物
它来自 a11y——具体说,来自两个概念:role(角色) 和 accessible name(可访问名称)。
a11y 最初只是为无障碍阅读而设计。它是为了让屏幕阅读器这类辅助技术能替视障用户理解界面——这个按钮是干什么的、这个输入框对应哪个标签。role 和 name 这套东西,原本就是讲给辅助技术听的。
这里需要解释的是:为了达成无障碍,浏览器必然要把页面解析出一份结构化的语义描述。它会把整个页面解析成一棵 accessibility tree(无障碍树),树上每个节点都带着自己的 role 和 name——屏幕阅读器读的就是这棵树,而不是原始 DOM。用一句话说清这两个概念:role 是这个节点的类型(它是个按钮、链接还是输入框),accessible name 是它的名字(这个按钮叫什么、这个输入框对应哪个标签)。
关键在于,这层语义大多数时候是原生标签白送的。写 <button>提交</button>,它的 role 自动是 button,accessible name 自动是内容文本「提交」,不用加任何额外属性。只有原生标签表达不了时,才需要用 ARIA 属性手动补。比如一个只有图标、没有文字的按钮,内容是个「×」,就得用 aria-label 显式给它一个名字:
<button aria-label="关闭">×</button>这时它的 accessible name 是「关闭」,不是「×」——按 W3C 的 accessible name 计算规则,aria-label 的优先级高于内容文本,会把「×」覆盖掉。屏幕阅读器会念「关闭,按钮」,而不是「乘号,按钮」。
由此可以推导出这层语义的性质:它是一份独立于实现结构、机器可读的语义描述,记的是一个节点「是什么、叫什么」,而不是它在 DOM 里长什么样。 <button>提交</button> 也好,一个包了三层 div、用 role="button" 硬撑起来的自定义组件也好,只要语义标记对,它们在无障碍树上就是同一个「叫提交的按钮」。第 1 节那条语义查询之所以抗重构,根子就在这里——它查的是这份和 DOM 解耦的语义,不是 DOM 本身。

3. 第三个读者:AI 也读语义树
到这里为止,故事只关于两个读者:屏幕阅读器(真人无障碍用户)和测试框架。这份语义本是为前者建的,测试顺手复用了它——这本身已经是个不错的实践,但还谈不上「AI 时代」的新意。
新意在于第三个读者的出现:AI。当 AI 面对一段组件代码或一个渲染出来的页面,它要回答的是同一个问题——这里每个节点是什么、干什么、彼此什么关系。a11y 标记恰好把这些答案显式写了出来:role 告诉它这是个按钮还是输入框,accessible name 告诉它这个按钮叫什么、这个输入框收的是什么。换句话说,a11y 给了 AI 一层可以直接读懂的语义结构,让它不必从一堆 div 和 class 名里去猜「这坨东西大概是干什么的」。
这不是设想。微软的 Playwright MCP 就把这条路走通了:它让 AI 与网页交互时,不去解析截图的像素,而是读一份结构化的 accessibility snapshot——也就是把无障碍树导出来的语义视图。Playwright 底层的 page.ariaSnapshot() 生成的正是这份东西,一段 YAML,每一行就是 - role "name" 的结构:
- search:
- textbox "搜索关键词"
- button "搜索"对照一下就能发现,AI 读到的,和测试用 getByRole('button', { name: '搜索' }) 查的,是同一样东西。 它俩可以消费同一份语义代码。
这就引出本文最想说的判断:a11y 这份语义,原本为无障碍而建,到了 AI 时代恰好又多了 AI 这一个读者。 于是同一份标记,同时被三方消费:屏幕阅读器念给真人无障碍用户,AI 读它理解界面的语义结构,测试查询靠它定位节点。
三个看似毫不相干的收益——无障碍、AI 可操作、测试可复用——底下是同一个第一性原理:它们都在消费那层独立于实现的语义。 谁读这层语义谁受益,和读者是人是机器无关。这层语义的存在本是无障碍设计的功劳,AI 和测试是复用这层语义的后来读者。想明白这个内核,你甚至能预判:将来但凡还有别的什么东西需要「不看实现、只理解界面在干什么」,它多半也会来读这层语义。
4. 同一份契约,贯穿实现与测试
那么,如何定义并消费这份 a11y 语义?
一是在写组件时,就有意识地把 role 和 accessible name 当作组件实现的一部分,和 props、事件处理一起落进代码。二是在测试里复用它——单测用 getByRole 靠这份标记定位元素,E2E 同样靠它在真实页面上找到同一个元素。实现、单测、E2E 三处指向的是同一份标记,它因此成了贯穿实现与测试的语义契约:实现里怎么标,测试就怎么找。

拿一个搜索表单看。写实现的时候,语义标记就该到位:
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('')
const submit = (e) => { e.preventDefault(); onSearch(query.trim()) }
return (
<form role="search" onSubmit={submit}>
<input type="search" aria-label="搜索关键词"
value={query} onChange={e => setQuery(e.target.value)} />
<button type="submit">搜索</button>
</form>
)
}这里没写一行「为了测试」的代码。role="search"、aria-label="搜索关键词"、按钮的文本「搜索」,全是为了让这个表单对屏幕阅读器可用而贴的语义。但正是这份语义,让它同时对 AI 可操作、对测试可定位。
单测阶段(跑在 jsdom 里),用 Testing Library 按语义定位:
render(<SearchForm onSearch={onSearch} />)
await user.type(screen.getByRole('searchbox'), 'react')
await user.click(screen.getByRole('button', { name: '搜索' }))
expect(onSearch).toHaveBeenCalledWith('react')E2E 阶段(跑在真实浏览器里),用 Playwright——定位思路一致:
await page.getByRole('searchbox').fill('react')
await page.getByRole('button', { name: '搜索' }).click()
await expect(page.getByRole('list')).toContainText('react')两段测试跑在完全不同的环境里(一个是模拟 DOM、一个是真浏览器),用的是两个互相独立的框架,但定位一个节点的方式是同一套心智模型:找那个 role、找那个 name。开发者不用为两层测试各记一套选择器,组件的语义契约定下来,两层都照着它写。
既然语义契约要在写组件时就立好,不妨把这条约束直接交给 AI。生成组件和测试时,可以这样约束它:
为这个组件生成实现和测试,遵守以下约束。
实现侧:
- 优先用原生语义标签(button / nav / label / input 等),让 role 和
accessible name 由标签和内容文本自然产生,不要用 div 堆砌可交互元素。
- 原生标签表达不了语义时,才用 ARIA 补(如无文字的图标按钮用 aria-label),
aria-label 必须准确描述这个元素对用户是什么。
测试侧(单测与 E2E 通用):
- 一律用语义查询定位节点(getByRole 配 name,其次 getByLabelText),
禁止用 class 名、nth-child、DOM 结构或 data-testid 定位。
- 定位不到时,先检查是不是实现侧缺了语义标记,而不是退回结构选择器。其中最后一条约束尤其关键。定位不到元素时,常见的做法是加个 data-testid 绕过去,但这只是把问题盖住了。更该做的是回头检查组件——测试难定位,往往是实现的语义没贴好。这时修组件,不光测试能用语义查询定位,无障碍和 AI 可操作性也跟着一起改善。
反过来,标记贴错了,受损的也是这三方。假设图标按钮的 aria-label 写反了,把「清空」按钮标成了「搜索」:
<button aria-label="搜索" onClick={clear}>✕</button> {/* 实际是清空 */}这一处错,三个读者一起被骗:屏幕阅读器念给盲人用户「搜索,按钮」,点下去内容却被清空;浏览器 agent 想点搜索,点成了清空;测试 getByRole('button', { name: '搜索' }) 会同时匹配到真搜索按钮和这个假冒的,要么定位失败、要么定位到错节点。这正是语义契约和「随手加个 class」最本质的区别——class 写错只影响样式,role 和 name 写错,污染的是那层所有人都依赖的语义。

5. 小结
回过头看,a11y 语义标记本是为无障碍而生,它顺带给出了一层独立于 DOM 结构、机器可读的语义——每个节点「是什么、叫什么」。用法上,写组件时把 role 和 accessible name 一起落进代码,单测和 E2E 都靠它定位元素,同一份标记贯穿实现与测试。对 AI 而言,这层语义让它读组件时不用从 div 和 class 里猜结构,而是直接读懂每个节点的角色和用途——这也是为什么在 AI 参与写码、改码、测码的当下,把语义显式标出来比过去更值得做。
但有两点要注意:
-
定位独立不等于断言有效。语义查询只解决「稳稳找到那个节点」,没解决「找到之后断言什么」。用
getByRole精准定位到按钮,照样能写出expect(button).toBeInTheDocument()这种几乎不会失败的空断言。这正是上一篇讲的另一半:定位锚点独立于实现,保证你没测错地方;断言基准独立于实现,才保证你测对了东西。两道关都得过,这篇只讲前一道。 -
别过度堆 ARIA。给纯展示、无交互的
<div>硬加一堆 role 和 aria 属性,不会让它更好测,只会污染无障碍树、误导三方读者。原生语义标签优先,ARIA 是补充——W3C 的原则是「No ARIA is better than bad ARIA」(用错的 ARIA 不如不用)。这层语义的价值建立在准确上,不在多上。
Harness Engineering 实践:构建 AI 自治的代码腐烂防御体系
用 AI 把维护工作做成常驻后台进程,7×24 对抗代码库的自然退化。本文给出调度、选包、质量门禁和反馈回路的实现地图,以及一段可直接发给 Claude Code 的初始化 Prompt。
harness 实践:AI 让编码变快,真正要交出去的却是验证
AI 把编码提速了,但改完还得验证——一旦验证压在人身上,省下的时间又被抵消掉。本文以一个 300+ 包的 Rush monorepo 从 TypeScript 5.9 升到 7.0 为背景,讲怎么搭一套 harness,把「编码 + 验证」的整个闭环交给 AI 自主推进。
