Playwright 报 strict mode violation:定位器怎么收窄

2026-08-18

写测试时最常见的一类中断,是昨天还好好的一行 click(),今天页面多渲染了一个同名按钮,就开始报 strict mode violation。这篇把这条报错拆开:它在哪个时机判定、报错里那几行怎么读、四种收窄手段在代码里分别做了什么。

一、报错长什么样

Playwright 仓库的测试文件 tests/page/expect-misc.spec.ts 里有一个专门断言错误格式的用例,页面内容是 <div>a</div><div>b</div>,断言写的是 expect(page.locator('div')).toHaveText('foo'),期望的报错文本是:

expect(locator).toHaveText(expected) failed

Locator: locator('div')
Expected: "foo"
Error: strict mode violation: locator('div') resolved to 2 elements:
    1) <div>a</div> aka getByText('a')
    2) <div>b</div> aka getByText('b')

Call log:

这段文本的生成处在 packages/injected/src/injectedScript.tsstrictModeViolationError():它把命中的元素逐个做一次预览(previewNode)并顺手生成一个可用的定位器,拼成 序号) 元素预览 aka 定位器 这样一行。同一个文件里的 _generateSelectors() 对参与生成的元素条数设了上限,超出的部分会在末尾补一行省略号——所以报错里列出来的元素不一定是全部,resolved to N elements 里的 N 才是真实数量。

那几行 aka 后面的东西值得多看两眼:它是 Playwright 自己给这个元素生成的定位器建议,你要收窄的时候可以直接拿它当起点。

二、怎么确认确实是它

第一步,看关键字。 报错正文里必须出现 strict mode violation: <定位器> resolved to N elements。同一个测试文件里紧挨着还有一个「invalid selector error format」用例,page.locator('##') 报的是 Unexpected token "#" while parsing css selector "##"——两者的报错骨架长得很像,都带 Locator:Call log:,别只扫一眼开头就下结论。

第二步,数一下到底命中几个。 Locator.count() 在文档里明确是「返回匹配元素的数量」,docs/src/locators.md 的 Strictness 一节把它列为「多元素操作」的例子,写明这类调用在定位器命中多个元素时也能正常工作。所以拿 count() 去数是安全的,它不会被 strict 拦住。不过 docs/src/api/class-locator.mdcount() 上挂了一条警告:如果目的是断言页面上的元素个数,应优先用 toHaveCount(),以避免不稳定。

第三步,确认判定时机。 这一步最容易想岔。strict 不是在你写下 page.getByRole('button') 那一刻判定的,而是在这个定位器被用于某个操作、真正去页面里解析的时候判定的。docs/src/locators.md 写明:每次定位器被用于动作,都会在页面里重新定位一次最新的 DOM 元素。落到代码上,packages/playwright-core/src/client/locator.ts 里每个动作方法(clickfillhover……)都是把 { strict: true, ... } 一并传给 frame 层;服务端 packages/playwright-core/src/server/frameSelectors.ts 在页面内先 querySelectorAll,再判断 if (params.info.strict && elements.length > 1) 才抛错。也就是说:同一个定位器变量,前一次动作没报错、后一次报错,是完全正常的,因为判定发生在每次动作时的那次解析上。

第四步,看你用的是哪套 API。 docs/src/api/params.mdstrictSelectors 这个 context 选项的说明写得很清楚:开启后,所有「隐含单个目标 DOM 元素」的 selector 操作在命中多个时会抛错;并且这个选项不影响任何 Locator API,Locator 永远是 strict 的;文档标注它默认为 false(这是仓库当前文档里的默认值,随版本可能变动)。此外 page.click(selector, { strict }) 这类基于 selector 的旧接口本身也带一个 strict 布尔选项,文档对它的描述是「为 true 时要求 selector 解析到单个元素,否则抛异常」。

第五步,如果是断言报的错,注意断言这侧有例外。 packages/playwright-core/src/server/frames.ts 里有一行注释和它下面那行代码值得放在一起看:注释写着「非数组类断言是 strict 的(callOnSelector 在多元素时抛错),数组类不是」,代码是 const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'),随后传的是 { strict: !isArray, ... }。这解释了为什么 toHaveCount() 和数组形式的 toHaveText([...]) 面对多个匹配不报 strict 错,而 toHaveText('foo') 会报。

三、四种收窄手段,各自改了什么

看懂了判定时机,收窄手段的差别就清楚了:它们都不是「关掉检查」,而是改变解析结果的元素集合。packages/playwright-core/src/client/locator.ts 里这几个方法的实现几乎是逐字可读的——都是往选择器字符串上追加一段。

filter():返回一个新的 Locator,选择器不变,把选项翻译成后缀。构造函数里 hasText 追加 >> internal:has-text=hasNotText 追加 >> internal:has-not-text=has / hasNot 追加 >> internal:has= / >> internal:has-not=visible 追加 >> visible=truefalse。语义在 docs/src/api/params.md 里:hasText 传字符串时是大小写不敏感的子串匹配,且会匹配到后代元素里的文本;has 传的内层定位器必须是相对的,从外层匹配处开始查询而不是从文档根开始,内外层还必须属于同一个 frame。docs/src/locators.md 专门给了一个反例,把 has: page.getByRole('list').getByText('Product 2') 标为 ✖ WRONG,原因就是内层从 <ul> 开始匹配,跑到了外层 <li> 之外。

filter()hasNot / hasNotText 标注自 v1.33 起可用,visible 标注自 v1.51 起可用——老版本上写了不认,这一点在跨项目改测试时容易踩。

链式locator() 的实现是 this._selector + ' >> ' + selectorOrLocator,传入的是另一个 Locator 时则拼 >> internal:chain=。这里有个容易忽略的签名细节:Locator.locator() 的选项类型写的是 Omit<LocatorOptions, 'visible'>,也就是链式那一步不接受 visible,要按可见性收窄只能走 filter({ visible: true })

first() / last() / nth():实现分别是追加 >> nth=0 >> nth=-1 >> nth=${index}。注意它们的定位过程一点没变,只是在匹配集合出来之后按序号取一个,所以严格说这是「让最终结果只剩一个」,不是「让定位器更准」。docs/src/api/class-locator.md 写明 nth() 是零基的,nth(0) 选第一个。docs/src/locators.md 对这组方法的态度是明写出来的:称其为显式 opt-out,标注不推荐,理由写着页面变化时 Playwright 可能点到你没打算点的元素,并建议改为构造一个唯一命中的定位器。

and() / or():分别追加 >> internal:and= >> internal:or=,两边必须属于同一个 frame,否则客户端直接抛 Locators must belong to the same frame.or() 需要特别小心:docs/src/locators.mddocs/src/api/class-locator.md 都挂了同一条提示——如果两个候选同时出现在页面上,or 定位器会同时命中两者,仍可能触发 strict mode violation,文档给的处理是接一个 first()

取舍上,仓库文档的倾向不含糊:优先用 filter() 和链式把范围收到唯一,nth 这一族当兜底。一个来自 docs/src/locators.md 的组合写法是先取行、按文本过滤、再按内含元素过滤:

const rowLocator = page.getByRole('listitem');

await rowLocator
    .filter({ hasText: 'Mary' })
    .filter({ has: page.getByRole('button', { name: 'Say goodbye' }) })
    .screenshot({ path: 'screenshot.png' });

语言绑定别串。同样的写法在 Python 侧是 page.get_by_role("listitem").filter(has_text="Mary"),选项名走下划线;or / and 因为撞关键字写作 or_ / and_docs/src/locators.md 的 Python 代码块里 first不带括号的(new_email.or_(dialog).first),C# 侧同样是 First 属性,而 JS 与 Java 侧是 first() 方法调用。另外 docs/src/api/class-locator.mdnth 的 JS 用法示例写成 const banana = await page.getByRole('listitem').nth(2);,前面带了 await,但同一文件里这个方法的签名标的是 ## method(非 async)、返回 Locator——同一页里这两处对不齐,照着签名理解即可。还有一处语言限制:docs/src/locators.md 的 Page-free locators 一节(by 对象)整节标了 * langs: js

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。

四、改完怎么验证

不要只看那行动作不再报错就收工——first() 也能让报错消失,但问题没解决。可验证的做法是给收窄后的定位器加一条计数断言,docs/src/locators.md 里就是这么演示的:

await expect(page
    .getByRole('listitem')
    .filter({ has: page.getByRole('heading', { name: 'Product 2' }) }))
    .toHaveCount(1);

这条断言之所以能和 strict 共存,就是上面 isArray 那段代码的缘故:to.have.count 走的是非 strict 分支。如果它稳定通过,说明收窄后确实只剩一个匹配。另外 Locator.describe()(自 v1.53 起可用)可以给定位器挂一个描述,文档说明它会用在 trace viewer 与报告里,排查时能让这一步在记录里认得出来。

顺带一提,本篇讲的都是 API 语义,仓库文档在这一块没有按操作系统区分说明,Windows 与 Linux/macOS 上写法一致。唯一和「文本长什么样」有关的坑在 docs/src/locators.md 的一条注记里:按文本匹配总是会规范化空白,多个空格合成一个、换行变成空格、首尾空白被忽略,即便用了精确匹配也是如此——从 HTML 模板里复制带缩进的文案来写 hasText 时,记着这一条。

五、什么情况说明不是这个原因

  • 报错正文里没有 strict mode violation 超时找不到元素、选择器语法错误都是另一类错误,处置方式完全不同。
  • 报错写的是 resolved to 1 element,或者压根没有 resolved 这一行。 那说明定位器已经唯一,失败点在可操作性检查或超时上,收窄再多也没用。
  • 你用的是 selector API 且没有开 strict。 page.textContent('span', { strict: false }) 在仓库测试里是能正常返回第一个匹配的文本的;同理,未开启 strictSelectors 的 context 里,selector 操作命中多个不会因此抛错。这种情形下你遇到的「点错了元素」不会以 strict 报错的形式出现。
  • 失败的是 toHaveCount() 或数组形式断言。 这两类走的是非 strict 分支,它们报错说明的是数量或文本数组对不上,不是多匹配问题。
  • 错误发生在构造定位器的那一行,而不是动作那一行。 例如 has 传了别的 frame 的定位器,客户端会立刻抛 Inner "has" locator must belong to the same frame.,这是构造期的参数校验,和页面里有几个元素无关。

最后补一句机制层面的观察,只说源码里写了什么:strict 的错误是在页面内解析阶段抛出的(injectedScript.ts),而 frames.ts 的重试循环 retryWithProgressAndTimeouts 捕获异常后,会交给 isNonRetriableError() 判断是继续轮询还是直接抛出——这两处放在一起,就是「这条报错要不要等到超时才出现」的答案所在。我们没有跑过,这里只把代码路径指给你,具体行为请以你所用版本的代码为准。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。