Playwright ARIA 快照怎么读:YAML 结构与截图快照的边界
写页面结构的回归测试,多数人第一反应是截图。截图有个绕不开的麻烦:基线文件是按浏览器和平台分开存的,本机改一版、CI 上再改一版,最后没人敢按更新键。ARIA 快照走的是另一条路——它把页面的无障碍树序列化成 YAML,断言的是结构而不是像素。
这篇不铺 API 目录,就跟着一次 toMatchAriaSnapshot 走完:模板里那几行 YAML 是怎么跟页面对上的,中间经过了哪些函数。
一个节点只有三段
docs/src/aria-snapshots.md 把节点格式写得很干脆:
- role "name" [attribute=value]
三段各有出处。role 是元素的 ARIA 或 HTML role;"name" 是 accessible name,加引号表示精确匹配,写成 /pattern/ 就按正则匹配;方括号里是 ARIA 属性与取值,文档列举了 checked、disabled、expanded、invalid、level、pressed、selected 这几个。缩进表示嵌套关系。
所以下面这段 HTML 与这段快照是对应的(例子原样抄自该文档):
<h1>Title</h1>
<h2>Subtitle</h2>
- heading "Title" [level=1]
- heading "Subtitle" [level=2]
role 与 name 是算出来的,不是读属性读出来的
真正决定快照长相的代码在 packages/injected/src/ariaSnapshot.ts。生成树的函数是 generateAriaTree,每个元素转成节点时做两件事:
const role = roleUtils.getAriaRole(element) ?? defaultRole;
if (!role || role === 'presentation' || role === 'none')
return null;
const name = roleUtils.getElementAccessibleName(element, false);
第一件事解释了一个常见困惑:为什么有些 DOM 节点在快照里根本找不到。getAriaRole 定义在 packages/injected/src/roleUtils.ts,内部的 computeAriaRole 先取显式 role(getExplicitAriaRole),没有才回落到隐式 role(getImplicitAriaRole)。而一旦算出来是 presentation 或 none,ariaSnapshot.ts 这里直接返回 null,这个节点不进树。你在快照里看不到它,不是没抓到,是它按规则被排除了。
第二件事是名称。getElementAccessibleName 走的是 computeAccessibleNameComposite,源码里有一段值得留意:如果元素的 role 落在一份「禁止命名」的角色列表里(源码中的 elementProhibitsNaming 判断,包含 generic、paragraph、code、emphasis、strong、time 等),直接返回空名称。也就是说,给一个 <p> 加 aria-label 并指望它出现在快照里,方向就错了。相邻的 getElementAccessibleDescription 里还留着 precedence 1 / precedence 2 / precedence 4 的注释,以及一条 TODO: handle precedence 3——这类 html-aam 的特例并没有全部实现,源码自己标出来了。
名称写进节点前还过一道 normalizeWhiteSpace。这和文档那句「比较会折叠空白」是同一件事的两端:一端在生成时归一化,另一端在比较时忽略缩进与换行。
方括号里的属性也不是无差别附加的。源码用 kAriaCheckedRoles、kAriaDisabledRoles、kAriaExpandedRoles、kAriaInvalidRoles、kAriaLevelRoles、kAriaPressedRoles、kAriaSelectedRoles 这几组角色白名单判断,角色不在对应白名单里,属性就不会出现。invalid 还多一层转换:'false' 转成 false、'true' 转成 true、其它字符串原样保留——这正好对上文档里 aria-invalid="spelling" 渲染成 [invalid=spelling]、值为 false 时整个属性被省略的写法。
模板那一侧:以斜杠开头的键是特殊键
页面这边生成一棵树,模板这边也要解析成一棵树,解析器在 packages/isomorphic/ariaSnapshot.ts。它把 YAML 的键分成几类处理:
text键:值必须是字符串,转成一个文本子节点;/children键:值只允许contain、equal、deep-equal三个之一,否则报错Strict value should be "contain", "equal" or "deep-equal",命中后写进containerMode;- 其它以
/开头的键:值必须是字符串,去掉前导斜杠后存进props。/url就是走这条路径进去的; - 剩下的按
role "name"解析成子节点。
看懂这个分类,docs/src/aria-snapshots.md 里链接那段写法就不神秘了:
- link "Read more about Accessibility":
- /url: "#more-info"
/url 不是 link 的子节点,它是挂在这个节点上的属性。
/children 的三个取值决定子元素怎么比:contain 是默认,只要模板里列出的子项都在、顺序对就算过;equal 要求子项与模板完全一致且顺序一致;deep-equal 再加上嵌套层。文档明说默认行为是「包含子集即匹配」,所以省略属性、省略名称、省略某些列表项做部分匹配,是默认就成立的,不需要打开什么开关。
全局改这个默认值可以写在配置里:
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toMatchAriaSnapshot: {
children: 'equal',
},
},
});
有意思的是它的实现。packages/playwright/src/matchers/toMatchAriaSnapshot.ts 里,全局配置并不是走一条独立的比较分支,而是在模板文本里没匹配到 ^- \/children: 时,直接往模板字符串最前面拼一行 - /children: <值>。文档说「单个快照可以用显式 /children 覆盖全局设置」,看到这段拼接就知道覆盖是怎么发生的:模板里已经有这行,就不再拼。
生成与更新基线
三种生成路径,文档都写明了。第一种是给断言传空模板,让它当场生成:
await expect(locator).toMatchAriaSnapshot('');
第二种是 @playwright/test 的 --update-snapshots(简写 -u),只更新没匹配上的快照,已匹配的不动:
npx playwright test --update-snapshots
这一节在文档里标了 * langs: js,即只对 JS 的 test runner 成立。更新时源码怎么改,由 --update-source-method 控制,文档给了三个取值:patch(默认,生成可用 git apply 应用的 unified diff)、3way(在源码里生成合并冲突标记)、overwrite(直接覆写)。这里的「默认是 patch」是仓库当前文档里的默认值,随版本可能变动。
npx playwright test --update-snapshots --update-source-method=3way
第三种是把快照存成独立文件,用 name 选项指定 .aria.yml 扩展名:
await expect(page.getByRole('main')).toMatchAriaSnapshot({ name: 'main.aria.yml' });
matcher 源码里还留着一条兼容处理,注释逐字写的是「in 1.51, we changed the default template to use .aria.yml extension」,所以它会先查新路径、不存在再回落查旧的 .yml 路径。另外,生成基线时它把待比较值换成常量 kImpossibleAriaMatch(源码里定义为 - none "Generating new baseline"),让整条管线照常跑一遍必然不匹配的比较。文档这一侧的说法可以对着看:Playwright 会等到配置的最大 expect 超时以确保页面稳定后再取快照,必要时需要调整 --timeout。
和截图快照的分界
docs/src/test-snapshots-js.md 讲的是 toHaveScreenshot() 那条线,两个细节值得对着看。
一是基线文件名。截图基线形如 example-test-1-chromium-darwin.png,后半段是浏览器名与平台;文档明说不同浏览器与平台的渲染、字体等不同,需要各自的基线。这意味着 Windows 上生成的截图基线和 Linux CI 上跑出来的不是同一个文件。文档开头还挂了一条警告,列出渲染结果可能受宿主操作系统、版本、设置、硬件、供电方式(电池还是电源适配器)、headless 模式等因素影响,建议在生成基线的同一环境里跑。
二是 ARIA 快照这边的说法正好相反:docs/src/aria-snapshots.md 写明「快照在各浏览器间应当是一致的,因此即使用多个浏览器测试也只保存一份快照」。默认存放目录是测试文件同名的 example.spec.ts-snapshots,路径模板可以用 snapshotPathTemplate 或 expect.toMatchAriaSnapshot.pathTemplate 改。
由此,选哪种其实不用纠结:要验的是「结构还在不在、层级和顺序有没有乱、控件状态对不对」,用 ARIA 快照;要验的是「像素级外观有没有变」,那只能截图,并接受它跨平台要分别维护基线。文档在「何时使用」一节给的划分是:快照测试适合整页与组件的结构性检查、结构很少变的回归;断言测试适合核心逻辑、计算值和需要精确条件的细粒度检查。它同时列了快照测试的短处,其中一条是「容易在没完全理解差异的情况下就接受快照变更,从而掩盖 bug」。
比较语义还有两条容易踩:比较区分大小写、折叠空白,缩进和换行被忽略;比较对顺序敏感,模板里元素的顺序必须和页面无障碍树里的顺序一致。
语言绑定不要串
字符串模板形式的断言四种绑定都有,docs/src/api/class-locatorassertions.md 里 LocatorAssertions.toMatchAriaSnapshot 标了 since: v1.49,Java 绑定的别名是 matchesAriaSnapshot;直接对 page 断言的 PageAssertions.toMatchAriaSnapshot 标的是 since: v1.60。对着看还有一处细节:class-locatorassertions.md 里这个方法的示例写的是 expect(page.locator('body')).toMatchAriaSnapshot(...),走的是 locator 而不是直接对 page 断言。
而存成独立文件的那个重载 LocatorAssertions.toMatchAriaSnapshot#2,文档明确标了 * langs: js——Python、Java、C# 绑定这边没有这个形态,别照着 JS 的写法找对应方法。--update-snapshots 那一节同样标了 langs: js。
程序化取快照的 Locator.ariaSnapshot() 四种绑定都有(Python 是 aria_snapshot(),C# 是 AriaSnapshotAsync()),返回 YAML 字符串。它还有几个后来加的选项:mode 取 "ai" 时会带上 [ref=e2] 这样的元素引用、不再等待元素匹配(无匹配直接抛错)、并包含目标内 <iframe> 的快照;depth 限制层数;boxes 为 true 时给每个元素追加 [box=x,y,width,height],坐标相对视口、单位是 CSS 像素,默认 false(这是仓库当前文档里的默认值,随版本可能变动)。另有 Locator.ariaSnapshotJSON(),返回同一棵树的 JSON 形式,节点带 role、name、text、children 以及状态字段——这个方法在文档里也标了 langs: js。
需要按角色和名称反查页面时,配套的入口是 getByRole。docs/src/locators.md 提醒了一句:role 定位器不能替代无障碍审计与合规测试,它只是就 ARIA 指南给出早期反馈。同一句话套在 ARIA 快照上也成立——它是结构回归的手段,不是无障碍达标的证明。
想看清一棵树到底长什么样,文档推荐的办法是用 Chrome DevTools 的 Accessibility 面板;codegen 里也有「Assert snapshot」动作和「Aria snapshot」标签页,可以对选中的 locator 直接看它的快照。
上面配置块与断言写法均原样取自仓库文档。以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。