Files
2026-07-13 21:36:47 +08:00

1922 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Playwright 错误索引
针对特定 Playwright 错误消息的快速参考手册。找到你的错误,了解原因,应用修复。
> **如何使用**:在此文件中搜索你在终端或测试报告中看到的确切错误文本。每条记录都给出了原因和可行的修复方案。
---
## 定位器与元素错误
---
### "locator.click: Target closed"
**原因**Playwright 完成对元素的操作之前,页面或框架发生了导航跳转(或已被关闭)。
**常见触发场景**
- 点击触发导航的链接时,另一个操作仍在等待中
- 表单提交导致页面重新加载,而后续的 `click()``fill()` 尚未完成
-`finally` 块中调用了 `page.close()``context.close()`,与正在执行的操作产生竞态
- 应用程序中未处理的异常触发了错误页面的重定向
**修复**:在执行下一个操作之前等待导航完成,或者使用 `Promise.all` 协调点击和导航。
```typescript
// TypeScript — 等待由点击链接引起的导航完成
await Promise.all([
page.waitForURL("**/dashboard"),
page.getByRole("link", { name: "Dashboard" }).click(),
])
```
```javascript
// JavaScript — 相同的模式
await Promise.all([
page.waitForURL("**/dashboard"),
page.getByRole("link", { name: "Dashboard" }).click(),
])
```
如果页面是有意关闭的(例如弹窗),请在操作之前捕获引用:
```typescript
// TypeScript — 处理自动关闭的弹窗
const popupPromise = page.waitForEvent("popup")
await page.getByRole("button", { name: "Open popup" }).click()
const popup = await popupPromise
await popup.waitForLoadState()
// 在弹窗关闭前与其交互
await popup.getByRole("button", { name: "Confirm" }).click()
```
```javascript
// JavaScript — 处理自动关闭的弹窗
const popupPromise = page.waitForEvent("popup")
await page.getByRole("button", { name: "Open popup" }).click()
const popup = await popupPromise
await popup.waitForLoadState()
await popup.getByRole("button", { name: "Confirm" }).click()
```
**相关**[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/multi-context-and-popups.md](multi-context-and-popups.md)
---
### "waiting for locator('...') to be visible"
**原因**Playwright 的自动等待超时,因为元素从未出现在 DOM 中或一直处于隐藏状态(例如 `display: none``visibility: hidden`、零尺寸或位于 `aria-hidden` 之后)。
**常见触发场景**
- 元素是条件渲染的,但条件未满足(缺少数据、未认证状态)
- 内容是异步加载的,定位器在 API 响应返回之前就被执行了
- 选择器错误——角色、名称或测试 ID 中有拼写错误
- 元素存在但在屏幕之外或位于折叠的容器内
- CSS 动画或过渡使元素保持 `opacity: 0`,而 Playwright 尚未判定为可见
**修复**:首先使用 Playwright Inspector 确认定位器与你期望的元素匹配。然后确保元素出现的先决条件已满足。
```typescript
// TypeScript — 调试:检查定位器解析出什么
console.log(await page.getByRole("button", { name: "Submit" }).count())
// 如果为 0,则元素不在 DOM 中——检查选择器或页面状态
// 正确做法:先等待数据加载完成
await page.waitForResponse((resp) => resp.url().includes("/api/data") && resp.status() === 200)
await expect(page.getByRole("button", { name: "Submit" })).toBeVisible()
```
```javascript
// JavaScript — 相同的做法
console.log(await page.getByRole("button", { name: "Submit" }).count())
await page.waitForResponse((resp) => resp.url().includes("/api/data") && resp.status() === 200)
await expect(page.getByRole("button", { name: "Submit" })).toBeVisible()
```
**相关**[core/locators.md](locators.md)、[core/assertions-and-waiting.md](assertions-and-waiting.md)
---
### "locator.click: Error: strict mode violation"
**原因**:定位器匹配了多个元素,Playwright 的严格模式(默认启用)拒绝在模棱两可的匹配上执行操作。
**常见触发场景**
- 页面上存在多个按钮时使用 `getByRole('button')`
- "Save" 和 "Save as draft" 同时存在时使用 `getByText('Save')`
- 需要 `exact: true` 时使用了部分文本匹配
- 同一个组件被渲染了多次(例如在列表中)
**修复**:使定位器更加具体,确保解析到唯一一个元素。
```typescript
// TypeScript
// 不好——匹配了多个按钮
await page.getByRole("button", { name: "Save" }).click()
// 好——使用精确匹配
await page.getByRole("button", { name: "Save", exact: true }).click()
// 好——限定到特定容器
await page.getByRole("dialog").getByRole("button", { name: "Save" }).click()
// 好——在顺序有意义时使用 .first()、.last()、.nth()
await page.getByRole("listitem").first().click()
// 好——针对列表项链式使用 filter
await page
.getByRole("listitem")
.filter({ hasText: "Project Alpha" })
.getByRole("button", { name: "Delete" })
.click()
```
```javascript
// JavaScript
// 不好——匹配了多个按钮
await page.getByRole("button", { name: "Save" }).click()
// 好——使用精确匹配
await page.getByRole("button", { name: "Save", exact: true }).click()
// 好——限定到特定容器
await page.getByRole("dialog").getByRole("button", { name: "Save" }).click()
// 好——在顺序有意义时使用 .first()、.last()、.nth()
await page.getByRole("listitem").first().click()
// 好——针对列表项链式使用 filter
await page
.getByRole("listitem")
.filter({ hasText: "Project Alpha" })
.getByRole("button", { name: "Delete" })
.click()
```
**相关**[core/locators.md](locators.md)、[core/locator-strategy.md](locator-strategy.md)
---
### "Error: expect(locator).toBeVisible() — locator resolved to X elements"
**原因**:与上述严格模式违规的根因相同——断言的定位器匹配了多个元素,因此 Playwright 无法确定要对哪个元素进行断言。
**常见触发场景**
- 与上述严格模式违规的触发场景相同
- 当存在多个 `.item` 元素时写 `expect(page.locator('.item')).toBeVisible()`
**修复**:将定位器缩小到单个元素(参见上方严格模式违规的修复方法)。或者,如果你有意要检查所有匹配:
```typescript
// TypeScript — 断言数量而非可见性
await expect(page.getByRole("listitem")).toHaveCount(5)
// 或者对特定元素进行断言
await expect(page.getByRole("listitem").first()).toBeVisible()
// 或者使用循环断言所有元素都可见
for (const item of await page.getByRole("listitem").all()) {
await expect(item).toBeVisible()
}
```
```javascript
// JavaScript — 断言数量而非可见性
await expect(page.getByRole("listitem")).toHaveCount(5)
// 或者对特定元素进行断言
await expect(page.getByRole("listitem").first()).toBeVisible()
// 或者使用循环断言所有元素都可见
for (const item of await page.getByRole("listitem").all()) {
await expect(item).toBeVisible()
}
```
**相关**[core/locators.md](locators.md)、[core/assertions-and-waiting.md](assertions-and-waiting.md)
---
### "locator.fill: Error: Element is not an <input>, <textarea> or [contenteditable] element"
**原因**`fill()` 只能用于 `<input>``<textarea>` 或带有 `contenteditable` 属性的元素。定位器解析到了不同的元素类型(例如 `<div>``<label>``<form>` 本身)。
**常见触发场景**
- 按标签文本定位,但标签和输入框没有通过 `for`/`id` 正确关联
- 使用了 `getByText()` 而不是 `getByLabel()``getByRole('textbox')`
- 自定义组件将实际的 `<input>` 包裹在样式化的 `<div>`
- 富文本编辑器在内部元素上使用了 `contenteditable`,而不是外层包装器
**修复**:定位到实际的输入元素。
```typescript
// TypeScript
// 不好——定位到了标签,而不是输入框
await page.getByText("Email").fill("user@example.com")
// 好——getByLabel 找到关联的输入框
await page.getByLabel("Email").fill("user@example.com")
// 好——按角色定位
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
// 对于 contenteditable 富文本编辑器
await page.locator('[contenteditable="true"]').fill("Hello world")
// 如果 fill() 仍然不起作用(例如自定义组件),使用键盘输入
await page.getByRole("textbox", { name: "Email" }).click()
await page.keyboard.type("user@example.com")
```
```javascript
// JavaScript
// 不好——定位到了标签,而不是输入框
await page.getByText("Email").fill("user@example.com")
// 好——getByLabel 找到关联的输入框
await page.getByLabel("Email").fill("user@example.com")
// 好——按角色定位
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
// 对于 contenteditable 富文本编辑器
await page.locator('[contenteditable="true"]').fill("Hello world")
// 如果 fill() 仍然不起作用(例如自定义组件),使用键盘输入
await page.getByRole("textbox", { name: "Email" }).click()
await page.keyboard.type("user@example.com")
```
**相关**[core/locators.md](locators.md)、[core/forms-and-validation.md](forms-and-validation.md)
---
### "Error: elementHandle.click: Node is not an Element"
**原因**:你正在使用旧的 ElementHandle API,并且该句柄已经过期——底层的 DOM 节点已被移除或被重新渲染所替换。
**常见触发场景**
-`elementHandle` 存储在变量中,并在导航或 React/Vue 重新渲染后使用它
- 使用 `page.$()``page.$$()` 而不是 Locator API
- 框架的水合(hydration)过程用客户端渲染的节点替换了服务端渲染的节点
**修复**:从 ElementHandle 切换到 Locator。Locator 在每次操作时都会重新查询 DOM,永不过期。
```typescript
// TypeScript
// 不好——过期的句柄
const button = await page.$("button.submit")
// ... 页面重新渲染 ...
await button!.click() // Node is not an Element
// 好——locator 始终重新查询
const button = page.getByRole("button", { name: "Submit" })
// ... 页面重新渲染 ...
await button.click() // 正常运行
```
```javascript
// JavaScript
// 不好——过期的句柄
const button = await page.$("button.submit")
// ... 页面重新渲染 ...
await button.click() // Node is not an Element
// 好——locator 始终重新查询
const button = page.getByRole("button", { name: "Submit" })
// ... 页面重新渲染 ...
await button.click() // 正常运行
```
**相关**[core/locators.md](locators.md)
---
### "Error: locator.click: Timeout 30000ms exceeded"
**原因**:元素已在 DOM 中找到,但在超时时间内未能变得可操作。"可操作"意味着可见、稳定(无动画)、已启用且未被其他元素遮挡。
**常见触发场景**
- 遮罩层、模态框或提示框覆盖了元素
- 元素处于禁用状态(`disabled` 属性或 `aria-disabled="true"`
- CSS 动画尚未完成(元素仍在移动)
- 加载中的旋转图标覆盖在按钮上方
- 元素位于视口之外,Playwright 的自动滚动失败
**修复**:使用 trace 识别是什么阻止了操作,然后解决问题。
```typescript
// TypeScript
// 第 1 步:启用 tracing 以进行诊断
// 在 playwright.config.ts 中:
// use: { trace: 'on-first-retry' }
// 然后运行:npx playwright show-trace trace.zip
// 常见修复:等待遮罩层消失
await expect(page.locator(".loading-overlay")).toBeHidden()
await page.getByRole("button", { name: "Submit" }).click()
// 常见修复:当你确认遮罩层无害时使用强制点击
// (谨慎使用——这会跳过可操作性检查)
await page.getByRole("button", { name: "Submit" }).click({ force: true })
// 常见修复:等待元素变为可用
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
await page.getByRole("button", { name: "Submit" }).click()
```
```javascript
// JavaScript
// 常见修复:等待遮罩层消失
await expect(page.locator(".loading-overlay")).toBeHidden()
await page.getByRole("button", { name: "Submit" }).click()
// 常见修复:强制点击(谨慎使用)
await page.getByRole("button", { name: "Submit" }).click({ force: true })
// 常见修复:等待元素变为可用
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
await page.getByRole("button", { name: "Submit" }).click()
```
**相关**[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/debugging.md](debugging.md)
---
## 导航与页面错误
---
### "page.goto: net::ERR_CONNECTION_REFUSED"
**原因**Playwright 无法连接到目标 URL。服务器未运行,或未在预期的主机/端口上监听。
**常见触发场景**
- 运行测试前忘记启动开发服务器
- 开发服务器端口与配置中的 `baseURL` 不一致
- 服务器在 `127.0.0.1` 上监听,但测试使用的是 `localhost`(或反之),且操作系统解析方式不同
- 在 CI 中,应用构建/启动步骤静默失败
- Docker 容器网络:应用在容器内运行,但测试试图访问 `localhost`
**修复**:使用 `webServer` 配置选项让 Playwright 自动启动和管理开发服务器。
```typescript
// TypeScript — playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000, // 服务器启动等待 2 分钟
},
use: {
baseURL: "http://localhost:3000",
},
})
```
```javascript
// JavaScript — playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: {
baseURL: "http://localhost:3000",
},
})
```
**相关**[core/configuration.md](configuration.md)、[ci/ci-github-actions.md](../ci/ci-github-actions.md)
---
### "page.goto: Timeout 30000ms exceeded"
**原因**:页面未能在导航超时时间内达到 `load` 状态(默认情况下)。服务器有响应,但页面加载完整花费的时间过长。
**常见触发场景**
- 大型资源(图片、字体、脚本)拖慢了页面加载
- 第三方脚本(分析工具、广告)阻塞了加载事件
- 页面在变得可交互之前发出了大量 API 调用
- 服务器响应了一个耗时的重定向链
**修复**:使用更合适的 `waitUntil` 选项或增大导航超时时间。
```typescript
// TypeScript
// 如果不需要所有资源,使用 'domcontentloaded' 代替 'load'
await page.goto("/dashboard", { waitUntil: "domcontentloaded" })
// 或者为已知的慢页面增加导航超时时间
await page.goto("/dashboard", { timeout: 60_000 })
// 或者在配置中全局设置
// playwright.config.ts
export default defineConfig({
use: {
navigationTimeout: 60_000,
},
})
```
```javascript
// JavaScript
await page.goto("/dashboard", { waitUntil: "domcontentloaded" })
await page.goto("/dashboard", { timeout: 60_000 })
// playwright.config.js
module.exports = defineConfig({
use: {
navigationTimeout: 60_000,
},
})
```
**相关**[core/configuration.md](configuration.md)
---
### "Error: page.goto: Navigation failed because page was closed!"
**原因**`goto()` 还在进行中时,页面或浏览器上下文就被关闭了。这通常发生在拆卸/清理的竞态条件下。
**常见触发场景**
- `afterEach` 或 fixture 在导航完成前关闭了页面/上下文
- 另一个测试或钩子过早调用了 `browser.close()`
- 测试失败,异步导航仍在等待时清理逻辑执行了
- 在异步操作开始后使用了 `test.fixme()``test.skip()`
**修复**:确保所有页面操作完成后才执行清理。使用 fixtures 进行生命周期管理,而不是手动 `afterEach`
```typescript
// TypeScript
// 不好——测试体与清理之间存在竞态条件
test.afterEach(async ({ page }) => {
await page.close() // 可能与正在进行的导航产生竞态
})
// 好——让 Playwright 通过 fixtures 管理页面生命周期
// Playwright 会自动为每个测试创建和销毁页面
// 无需手动清理
// 如果需要自定义清理,请确保先等待所有导航完成
test("navigates to dashboard", async ({ page }) => {
const responsePromise = page.waitForResponse("**/api/user")
await page.goto("/dashboard")
await responsePromise // 确保所有异步操作完成
// 测试断言在此
})
```
```javascript
// JavaScript — 相同的模式
test("navigates to dashboard", async ({ page }) => {
const responsePromise = page.waitForResponse("**/api/user")
await page.goto("/dashboard")
await responsePromise
// 测试断言在此
})
```
**相关**[core/fixtures-and-hooks.md](fixtures-and-hooks.md)
---
### "page.waitForNavigation: Timeout 30000ms exceeded"
**原因**`waitForNavigation()` 等待一个完整的页面导航,但该导航从未发生。在 SPA 中,客户端路由不会触发导航事件。
**常见触发场景**
- 在使用 React Router、Vue Router 或 Next.js 客户端导航的 SPA 中使用了 `waitForNavigation()`
-`waitForNavigation()` 被调用之前导航已经发生(竞态条件)
- 使用了已废弃的模式,而 `waitForURL()` 才是正确的替代方法
**修复**:将 `waitForNavigation()` 替换为 `waitForURL()`,后者同时适用于 SPA 和传统的页面加载。
```typescript
// TypeScript
// 不好——已废弃、易产生竞态、不支持 SPA 路由
await page.waitForNavigation()
// 好——同时适用于 SPA 和完整导航
await page.getByRole("link", { name: "Dashboard" }).click()
await page.waitForURL("**/dashboard")
// 好——结合 glob 模式使用
await page.waitForURL(/\/dashboard\/\d+/)
// 好——对于 SPA 路由变化,断言可见内容
await page.getByRole("link", { name: "Settings" }).click()
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
```
```javascript
// JavaScript
// 不好——已废弃、易产生竞态
await page.waitForNavigation()
// 好——同时适用于 SPA 和完整导航
await page.getByRole("link", { name: "Dashboard" }).click()
await page.waitForURL("**/dashboard")
// 好——结合 glob 模式使用
await page.waitForURL(/\/dashboard\/\d+/)
// 好——对于 SPA 路由变化,断言可见内容
await page.getByRole("link", { name: "Settings" }).click()
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
```
**相关**[core/assertions-and-waiting.md](assertions-and-waiting.md)
---
### "Error: frame.goto: Frame was detached"
**原因**Playwright 正在导航或与该 iframe 交互时,它被从 DOM 中移除了(已分离)。
**常见触发场景**
- 父页面重新渲染并重新创建了 iframe 元素
- 父页面上的 JavaScript 移除后又重新添加了 iframe
- SPA 路由变化卸载了包含 iframe 的组件
- 广告 iframe 自行重新加载
**修复**:在父页面更新后重新获取 frame 引用。
```typescript
// TypeScript
// 不好——frame 引用变得过期
const frame = page.frameLocator("#my-iframe")
// ... 父页面重新渲染 ...
await frame.getByRole("button").click() // Frame was detached
// 好——使用 frameLocator(每次重新求值)
// frameLocator 本身是惰性的,但它指向的 frame 必须存在
await expect(page.frameLocator("#my-iframe").getByRole("button")).toBeVisible()
await page.frameLocator("#my-iframe").getByRole("button").click()
// 好——等待 iframe 在重新渲染后出现
await expect(page.locator("#my-iframe")).toBeAttached()
await page.frameLocator("#my-iframe").getByRole("button").click()
```
```javascript
// JavaScript
// 好——frameLocator 惰性重新求值
await expect(page.frameLocator("#my-iframe").getByRole("button")).toBeVisible()
await page.frameLocator("#my-iframe").getByRole("button").click()
// 好——等待 iframe 在重新渲染后出现
await expect(page.locator("#my-iframe")).toBeAttached()
await page.frameLocator("#my-iframe").getByRole("button").click()
```
**相关**[core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md)
---
## 测试框架错误
---
### "Error: Test timeout of 30000ms exceeded"
**原因**:整个测试(包括所有钩子和断言)未能在配置的测试超时时间内完成。
**常见触发场景**
- 等待一个永不出现的元素(错误的定位器、缺少数据)
- 未解决的 `page.waitForEvent()`——预期的事件从未触发
- 缓慢的测试环境(资源受限的 CI
- 单个测试中包含过多顺序操作
- `beforeEach` 钩子执行了昂贵的设置(登录、数据填充)
**修复**:使用 `test.step()` 和 traces 识别慢速步骤,然后修复根本原因或调整超时时间。
```typescript
// TypeScript
// 为特定的慢速测试增加超时时间
test("generates large report", async ({ page }) => {
test.setTimeout(120_000) // 仅此测试 2 分钟
// ...
})
// 在配置中全局增加超时时间
// playwright.config.ts
export default defineConfig({
timeout: 60_000, // 每个测试 60 秒
})
// 更好的做法:使用 test.step() 诊断哪些步骤慢
test("checkout flow", async ({ page }) => {
await test.step("add item to cart", async () => {
await page.goto("/products")
await page.getByRole("button", { name: "Add to Cart" }).click()
})
await test.step("complete checkout", async () => {
await page.getByRole("link", { name: "Cart" }).click()
await page.getByRole("button", { name: "Checkout" }).click()
})
})
```
```javascript
// JavaScript
test("generates large report", async ({ page }) => {
test.setTimeout(120_000)
// ...
})
// playwright.config.js
module.exports = defineConfig({
timeout: 60_000,
})
test("checkout flow", async ({ page }) => {
await test.step("add item to cart", async () => {
await page.goto("/products")
await page.getByRole("button", { name: "Add to Cart" }).click()
})
await test.step("complete checkout", async () => {
await page.getByRole("link", { name: "Cart" }).click()
await page.getByRole("button", { name: "Checkout" }).click()
})
})
```
**相关**[core/configuration.md](configuration.md)、[core/debugging.md](debugging.md)
---
### "Error: expect(received).toMatchSnapshot()"
**原因**:当前的截图或文本与存储的基线快照不匹配。
**常见触发场景**
- 合法的 UI 更改需要更新快照
- 本地与 CI 之间的渲染差异(字体、抗锯齿、操作系统级渲染)
- 动态内容(时间戳、随机 ID、广告)在运行之间发生变化
- 不同环境之间的视口大小或设备模拟设置不同
**修复**:如果更改是有意的,则更新快照;或者遮罩/隐藏动态区域。
```typescript
// TypeScript
// 在有意更改后更新快照
// 运行:npx playwright test --update-snapshots
// 在截图前遮罩动态内容
await expect(page).toHaveScreenshot("dashboard.png", {
mask: [page.locator(".timestamp"), page.locator(".random-ad")],
maxDiffPixelRatio: 0.01, // 允许 1% 的像素差异
})
// 使用带清理器的文本快照处理动态值
const content = await page.getByRole("main").textContent()
const sanitized = content!.replace(/\d{4}-\d{2}-\d{2}/g, "DATE")
expect(sanitized).toMatchSnapshot("dashboard-content.txt")
```
```javascript
// JavaScript
// 更新快照:npx playwright test --update-snapshots
await expect(page).toHaveScreenshot("dashboard.png", {
mask: [page.locator(".timestamp"), page.locator(".random-ad")],
maxDiffPixelRatio: 0.01,
})
const content = await page.getByRole("main").textContent()
const sanitized = content.replace(/\d{4}-\d{2}-\d{2}/g, "DATE")
expect(sanitized).toMatchSnapshot("dashboard-content.txt")
```
**相关**[core/visual-regression.md](visual-regression.md)
---
### "Error: browserType.launch: Executable doesn't exist"
**原因**Playwright 浏览器二进制文件未在此机器上安装,或者安装的版本与当前的 Playwright 版本不匹配。
**常见触发场景**
- 新克隆项目后未运行安装步骤
- 升级了 `@playwright/test` 但没有重新安装浏览器
- CI 环境缺少安装步骤
- 使用 `npm ci` 会清理 `node_modules`,但不会触发浏览器安装的 postinstall 脚本
- 安装了多个 Playwright 版本(全局与本地冲突)
**修复**:安装浏览器。
```bash
# 安装所有浏览器
npx playwright install
# 只安装需要的浏览器(CI 中更快)
npx playwright install chromium
# 安装浏览器及操作系统依赖(CI/Docker 中需要)
npx playwright install --with-deps
# 如果在 CI Dockerfile 中,将此添加到 Dockerfile
# RUN npx playwright install --with-deps chromium
```
对于 CI 流水线,始终包含安装步骤:
```yaml
# GitHub Actions 示例
- name: Install Playwright Browsers
run: npx playwright install --with-deps
```
**相关**[ci/ci-github-actions.md](../ci/ci-github-actions.md)、[ci/docker-and-containers.md](../ci/docker-and-containers.md)
---
### "Error: Cannot use import statement outside a module"
**原因**Node.js 以 CommonJS 模式运行测试文件,但文件使用了 ES 模块的 `import` 语法,且没有正确的配置。
**常见触发场景**
- 缺少或配置错误的 `tsconfig.json`
- 使用了 `.ts` 文件,但 `@playwright/test` 未能正确解析 TypeScript
- `package.json` 没有 `"type": "module"` 设置,但测试文件使用了 `import`
- 直接使用 `node` 而不是通过 `npx playwright test` 运行测试文件
- 冲突的 Babel 或 ts-node 配置
**修复**:确保 TypeScript 配置正确,并始终通过 Playwright CLI 运行测试。
```bash
# 始终通过 Playwright CLI 运行测试——绝不要直接使用 node
npx playwright test
# 不要这样:
# node tests/example.spec.ts <-- 会失败
```
```typescript
// TypeScript — tsconfig.json(最小工作配置)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
```
如果使用 JavaScript 而非 TypeScript
```javascript
// JavaScript — 确保 package.json 有 "type": "module"
// 或将文件重命名为 .mjs
// 在 playwright.config.js 中——如果 package.json 缺少 "type": "module",则使用 require
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
/* ... */
})
```
**相关**[core/configuration.md](configuration.md)
---
### "Error: fixture \"xxx\" has already been registered"
**原因**:在多个 `test.extend()` 调用中定义了同名的自定义 fixture,且这些调用被合并了;或者同一 fixture 名称在同一个扩展中被注册了两次。
**常见触发场景**
- 两个不同的 fixture 文件都定义了同名 fixture
- 从多个文件中导入扩展后的 `test` 对象,这些文件各自添加了重叠的 fixture
- 复制粘贴 fixture 定义时没有重命名
**修复**:确保每个 fixture 名称在你合并后的 fixture 链中是唯一的。使用一个统一的 fixture 文件来组合所有扩展。
```typescript
// TypeScript
// 不好——两个独立的扩展包含同名的 fixture
// fixtures/auth.ts
export const test = base.extend<{ user: User }>({
user: async ({}, use) => {
/* ... */
},
})
// fixtures/data.ts — 冲突:"user" 已存在
export const test = base.extend<{ user: User }>({
user: async ({}, use) => {
/* ... */
},
})
// 好——单个合并后的 fixture 文件
// fixtures/index.ts
import { test as base } from "@playwright/test"
type MyFixtures = {
authenticatedUser: User
testData: TestData
}
export const test = base.extend<MyFixtures>({
authenticatedUser: async ({}, use) => {
const user = await createUser()
await use(user)
await deleteUser(user)
},
testData: async ({}, use) => {
const data = await seedData()
await use(data)
await cleanupData(data)
},
})
export { expect } from "@playwright/test"
```
```javascript
// JavaScript
// 好——单个合并后的 fixture 文件
// fixtures/index.js
const { test: base } = require("@playwright/test")
const test = base.extend({
authenticatedUser: async ({}, use) => {
const user = await createUser()
await use(user)
await deleteUser(user)
},
testData: async ({}, use) => {
const data = await seedData()
await use(data)
await cleanupData(data)
},
})
module.exports = { test }
```
**相关**[core/fixtures-and-hooks.md](fixtures-and-hooks.md)
---
## 网络与 API 错误
---
### "page.route: Pattern should start with..."
**原因**:传递给 `page.route()` 的 URL 模式不符合预期格式。Playwright 期望一个 glob 模式(以 `**/``http` 开头)、一个正则表达式或一个谓词函数。
**常见触发场景**
- 传递裸路径如 `/api/users` 而不是 `**/api/users`
- 对于相对模式缺少 `**` glob 前缀
- 传递了对象或无效类型而不是字符串/正则表达式
**修复**:使用正确的模式格式。
```typescript
// TypeScript
// 不好——没有 glob 前缀的裸路径
await page.route("/api/users", (route) => route.fulfill({ body: "[]" }))
// 好——glob 模式
await page.route("**/api/users", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Alice" }]),
})
)
// 好——正则表达式模式
await page.route(/\/api\/users\/\d+/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: 1, name: "Alice" }),
})
)
// 好——用于复杂匹配的谓词函数
await page.route(
(url) => url.pathname.startsWith("/api/") && url.searchParams.has("filter"),
(route) => route.fulfill({ body: "[]" })
)
```
```javascript
// JavaScript
// 好——glob 模式
await page.route("**/api/users", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Alice" }]),
})
)
// 好——正则表达式模式
await page.route(/\/api\/users\/\d+/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: 1, name: "Alice" }),
})
)
// 好——谓词函数
await page.route(
(url) => url.pathname.startsWith("/api/") && url.searchParams.has("filter"),
(route) => route.fulfill({ body: "[]" })
)
```
**相关**[core/network-mocking.md](network-mocking.md)
---
### "Error: apiRequestContext.get: connect ECONNREFUSED"
**原因**:通过 `request.get()`(或 `.post()``.put()` 等)发出的 API 请求无法连接到目标服务器。与 `page.goto: net::ERR_CONNECTION_REFUSED` 的根本原因相同,但针对 API 测试。
**常见触发场景**
- 使用 `request` fixture 时 API 服务器未运行
- 配置或 API 请求上下文中的 `baseURL` 错误
- 对尚未启动的服务运行 API 测试
- 在 CI 中,API 服务容器尚未就绪
**修复**:确保 API 服务器正在运行。使用 `webServer` 配置自动启动它,或添加健康检查。
```typescript
// TypeScript — playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
webServer: [
{
command: "npm run start:api",
url: "http://localhost:4000/health",
reuseExistingServer: !process.env.CI,
},
{
command: "npm run start:web",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
],
use: {
baseURL: "http://localhost:3000",
},
})
// 在测试中,使用带有显式 baseURL 的 request fixture 进行 API 调用
test("fetch users", async ({ request }) => {
const response = await request.get("http://localhost:4000/api/users")
expect(response.ok()).toBeTruthy()
})
```
```javascript
// JavaScript — playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
webServer: [
{
command: "npm run start:api",
url: "http://localhost:4000/health",
reuseExistingServer: !process.env.CI,
},
{
command: "npm run start:web",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
],
use: {
baseURL: "http://localhost:3000",
},
})
```
**相关**[core/api-testing.md](api-testing.md)、[core/configuration.md](configuration.md)
---
### "Request was not handled: GET https://..."
**原因**:使用 `page.route()` 注册了路由处理器并设置了 `{ times: N }`,或者调用了 `page.unrouteAll()`,之后一个匹配该模式的后续请求未被拦截——Playwright 警告你有一个请求未被处理。
**常见触发场景**
- 使用 `page.route()` 时设置了 `{ times: 1 }`,但应用发出了多次相同请求
- 调用了 `route.fallback()``route.continue()`,但没有处理器来捕获该请求
- 在测试中途调用了 `page.unrouteAll({ behavior: 'wait' })`,而应用又发出了另一个请求
**修复**:确保你的路由处理器覆盖所有预期的请求,或者让未处理的请求正常通过。
```typescript
// TypeScript
// 不好——只处理第一次请求,第二次请求未处理
await page.route("**/api/data", (route) => route.fulfill({ body: "{}" }), { times: 1 })
// 好——处理所有到此 URL 的请求
await page.route("**/api/data", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
})
)
// 好——如果只想拦截一次,后续请求使用 waitForResponse
await page.route("**/api/data", (route) => route.fulfill({ body: '{"items": []}' }), { times: 1 })
// 对于第二次调用,让它去真实服务器(不需要路由)
```
```javascript
// JavaScript
// 好——处理所有到此 URL 的请求
await page.route("**/api/data", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
})
)
```
**相关**[core/network-mocking.md](network-mocking.md)
---
## 认证与状态错误
---
### "Error: browserContext.storageState: No such file"
**原因**:配置或测试中引用的 `storageState` 路径指向一个不存在的文件。认证状态文件尚未生成。
**常见触发场景**
- 在运行认证设置项目之前就运行了测试
- `globalSetup` 或认证设置项目未成功完成
- 配置中的文件路径与设置中使用的路径不匹配
- `.gitignore` 排除了 storage state 文件,新克隆后未重新生成
- CI 缓存过期,状态文件未被重新创建
**修复**:确保认证设置先运行,并且文件路径保持一致。
```typescript
// TypeScript — playwright.config.ts
import { defineConfig } from "@playwright/test"
const authFile = "playwright/.auth/user.json"
export default defineConfig({
projects: [
// 设置项目先运行,创建认证状态文件
{
name: "setup",
testMatch: /.*\.setup\.ts/,
},
{
name: "chromium",
use: {
storageState: authFile,
},
dependencies: ["setup"], // 确保 setup 先运行
},
],
})
// auth.setup.ts
import { test as setup, expect } from "@playwright/test"
const authFile = "playwright/.auth/user.json"
setup("authenticate", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
// 保存认证后的状态
await page.context().storageState({ path: authFile })
})
```
```javascript
// JavaScript — playwright.config.js
const { defineConfig } = require("@playwright/test")
const authFile = "playwright/.auth/user.json"
module.exports = defineConfig({
projects: [
{
name: "setup",
testMatch: /.*\.setup\.js/,
},
{
name: "chromium",
use: {
storageState: authFile,
},
dependencies: ["setup"],
},
],
})
// auth.setup.js
const { test: setup, expect } = require("@playwright/test")
const authFile = "playwright/.auth/user.json"
setup("authenticate", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await page.context().storageState({ path: authFile })
})
```
确保认证目录存在并已添加到 `.gitignore`
```bash
mkdir -p playwright/.auth
echo "playwright/.auth/" >> .gitignore
```
**相关**[core/authentication.md](authentication.md)、[core/auth-flows.md](auth-flows.md)
---
### "Error: Target page, context or browser has been closed"
**原因**:代码尝试使用一个已经关闭或销毁的 `page``context``browser` 对象。
**常见触发场景**
-`page.close()` 被调用后使用了 `page` 引用
-`page``context` 存储在模块级变量中并在多个测试间重用
- 在异步回调仍在运行时,fixture 拆除了浏览器上下文
- `beforeAll` 创建了一个上下文,但该上下文在所有测试完成使用之前就被关闭了
- 手动使用 `browser.newContext()` 但忘记管理其生命周期
**修复**:绝不要在共享的可变状态中存储 page/context 引用。使用 Playwright fixtures 进行生命周期管理。
```typescript
// TypeScript
// 不好——共享的模块级变量
let sharedPage: Page
test.beforeAll(async ({ browser }) => {
sharedPage = await browser.newPage()
})
test.afterAll(async () => {
await sharedPage.close()
})
test("first", async () => {
await sharedPage.goto("/") // 如果另一个 describe 的 afterAll 先运行,可能会失败
})
// 好——使用内置的 page fixture(每个测试一个,自动管理)
test("first", async ({ page }) => {
await page.goto("/") // 始终是一个全新的、打开的页面
})
// 好——对于跨测试的共享状态,使用 worker 作用域的 fixture
import { test as base } from "@playwright/test"
export const test = base.extend<{}, { adminContext: BrowserContext }>({
adminContext: [
async ({ browser }, use) => {
const context = await browser.newContext()
await use(context)
await context.close()
},
{ scope: "worker" },
],
})
```
```javascript
// JavaScript
// 好——使用内置的 page fixture
test("first", async ({ page }) => {
await page.goto("/")
})
// 好——对于共享状态,使用 worker 作用域的 fixture
const { test: base } = require("@playwright/test")
const test = base.extend({
adminContext: [
async ({ browser }, use) => {
const context = await browser.newContext()
await use(context)
await context.close()
},
{ scope: "worker" },
],
})
```
**相关**[core/fixtures-and-hooks.md](fixtures-and-hooks.md)
---
## CI 特定错误
---
### "Error: browserType.launch: Browser closed unexpectedly"
**原因**:浏览器进程在启动后立即崩溃,通常是因为 CI 环境中缺少所需的操作系统级依赖。
**常见触发场景**
- 在最小化 Docker 镜像(如 `node:alpine`)上运行,缺少浏览器依赖
- Linux CI 上缺少共享库(`libgbm``libnss3``libatk-bridge` 等)
- 共享内存不足(`/dev/shm` 太小)——浏览器使用共享内存进行渲染
- 在 Docker 中以 root 身份运行,没有 `--no-sandbox` 参数(Chromium 拒绝以 root 身份运行沙箱)
- CI 中因内存不足被杀死(浏览器进程被操作系统 OOM 杀死)
**修复**:安装操作系统级依赖并配置环境。
```bash
# 安装浏览器及其操作系统依赖(推荐)
npx playwright install --with-deps chromium
# 如果使用 Docker,使用官方的 Playwright 镜像
# Dockerfile
FROM mcr.microsoft.com/playwright:v1.50.0-noble
WORKDIR /app
COPY . .
RUN npm ci
RUN npx playwright install chromium
CMD ["npx", "playwright", "test"]
```
针对共享内存问题:
```yaml
# GitHub Actions — 增加 /dev/shm
jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.50.0-noble
options: --shm-size=2gb
# Docker run — 增加共享内存
# docker run --shm-size=2gb my-test-image
```
针对在 Docker 中以 root 身份运行:
```typescript
// TypeScript — playwright.config.ts(仅用于 Docker/CI
export default defineConfig({
use: {
launchOptions: {
args: process.env.CI ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
},
},
})
```
```javascript
// JavaScript — playwright.config.js
module.exports = defineConfig({
use: {
launchOptions: {
args: process.env.CI ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
},
},
})
```
**相关**[ci/ci-github-actions.md](../ci/ci-github-actions.md)、[ci/docker-and-containers.md](../ci/docker-and-containers.md)
---
### "Error: Playwright Test needs to be invoked via 'npx playwright test'"
**原因**:测试文件是直接通过 `node``ts-node` 执行的,而不是通过 Playwright 测试运行器。
**常见触发场景**
- 运行 `node tests/example.spec.ts``ts-node tests/example.spec.ts`
- 配置错误的 IDE 运行配置直接调用了 `node`
- 使用 Jest 或 Mocha 来运行从 `@playwright/test` 导入的 Playwright 测试文件
- CI 脚本调用了错误的命令
**修复**:始终使用 Playwright CLI 来运行测试。
```bash
# 正确的调用方式
npx playwright test # 运行所有测试
npx playwright test tests/login.spec.ts # 运行特定文件
npx playwright test --grep "login" # 运行匹配模式的测试
npx playwright test --project=chromium # 运行特定项目
npx playwright test --debug # 使用检查器运行
# 如果使用 package.json 脚本
# package.json
# "scripts": { "test:e2e": "playwright test" }
npm run test:e2e
```
针对 IDE 配置(VS Code):
```json
// .vscode/settings.json — 安装 Playwright VS Code 扩展而非自定义配置
{
"playwright.reuseBrowser": true
}
```
**相关**[core/configuration.md](configuration.md)
---
## 其他常见错误
---
### "Error: page.evaluate: Execution context was destroyed"
**原因**:在 `page.evaluate()` 正在浏览器上下文中执行 JavaScript 时,页面发生了导航或重新加载。
**常见触发场景**
- 在页面处于导航过程中时调用 `evaluate()`
- 页面上的定时器或事件在评估期间触发了重定向
-`beforeEach` 中运行 `evaluate()`,与 `page.goto()` 产生竞态
**修复**:确保页面在评估前处于稳定状态。
```typescript
// TypeScript
// 不好——evaluate 与导航产生竞态
await page.goto("/dashboard")
const title = await page.evaluate(() => document.title) // 上下文可能已被销毁
// 好——先等待加载状态
await page.goto("/dashboard")
await page.waitForLoadState("domcontentloaded")
const title = await page.evaluate(() => document.title)
// 好——优先使用基于定位器的断言(不需要 evaluate)
await page.goto("/dashboard")
await expect(page).toHaveTitle("Dashboard")
```
```javascript
// JavaScript
// 好——先等待加载状态
await page.goto("/dashboard")
await page.waitForLoadState("domcontentloaded")
const title = await page.evaluate(() => document.title)
// 好——优先使用基于定位器的断言
await page.goto("/dashboard")
await expect(page).toHaveTitle("Dashboard")
```
**相关**[core/assertions-and-waiting.md](assertions-and-waiting.md)
---
### "Error: page.screenshot: Cannot take a screenshot larger than..."
**原因**:请求的截图尺寸超过了 Playwright 的限制(通常为 16384x16384 像素)。在对非常长的页面进行全页截图时会发生这种情况。
**常见触发场景**
- 在无限滚动的页面上调用 `page.screenshot({ fullPage: true })`
- 内容极高的页面(长数据表格、聊天记录)
- CSS 错误导致元素高度巨大
**修复**:将截图裁剪到特定区域或限制视口大小。
```typescript
// TypeScript
// 不好——在很高的页面上可能超出尺寸限制
await page.screenshot({ fullPage: true, path: "full.png" })
// 好——裁剪到特定区域
await page.screenshot({
path: "clipped.png",
clip: { x: 0, y: 0, width: 1280, height: 2000 },
})
// 好——改为截取特定元素
await page.getByRole("main").screenshot({ path: "main-content.png" })
// 好——在全页截图前设置合理的视口大小
await page.setViewportSize({ width: 1280, height: 720 })
await page.screenshot({ fullPage: true, path: "full.png" })
```
```javascript
// JavaScript
// 好——裁剪到特定区域
await page.screenshot({
path: "clipped.png",
clip: { x: 0, y: 0, width: 1280, height: 2000 },
})
// 好——截取特定元素
await page.getByRole("main").screenshot({ path: "main-content.png" })
```
**相关**[core/visual-regression.md](visual-regression.md)
---
### "Error: waiting for locator('...').toBeAttached()"
**原因**:元素在断言超时时间内从未进入 DOM。与 `toBeVisible()` 不同,`toBeAttached()` 只检查 DOM 存在性——但该元素根本从未被渲染过。
**常见触发场景**
- 组件是条件渲染的,但条件未满足
- 提供数据的网络请求尚未完成
- 错误的选择器,不与任何元素匹配
- 元素由延迟加载的代码块渲染,但该代码块尚未下载完成
**修复**:验证元素渲染的先决条件。
```typescript
// TypeScript
// 在断言之前确保数据已加载
await page.goto("/users")
await page.waitForResponse((resp) => resp.url().includes("/api/users"))
await expect(page.getByRole("table")).toBeAttached()
// 对于延迟加载的内容,等待更长时间或触发加载
await page.getByRole("tab", { name: "Advanced" }).click()
await expect(page.getByTestId("advanced-panel")).toBeAttached({ timeout: 10_000 })
```
```javascript
// JavaScript
await page.goto("/users")
await page.waitForResponse((resp) => resp.url().includes("/api/users"))
await expect(page.getByRole("table")).toBeAttached()
await page.getByRole("tab", { name: "Advanced" }).click()
await expect(page.getByTestId("advanced-panel")).toBeAttached({ timeout: 10_000 })
```
**相关**[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/locators.md](locators.md)
---
### "Error: protocol error: Target.createTarget: Failed to create target"
**原因**:浏览器无法创建新的标签页或上下文,通常是由于资源耗尽。
**常见触发场景**
- 打开了太多页面或上下文而没有关闭它们
- 测试中的内存泄漏——每个测试都打开一个上下文但从未关闭
- 在资源受限的 CI 机器上运行大量并行 worker
- 浏览器进程在上一次崩溃后处于不稳定状态
**修复**:降低并行度、关闭未使用的上下文,或使用更少的 worker。
```typescript
// TypeScript — playwright.config.ts
export default defineConfig({
// 如果资源有限,降低并行度
workers: process.env.CI ? 2 : undefined,
// 限制每个 worker 一个浏览器上下文
fullyParallel: false, // 在 worker 内串行运行测试文件
})
```
```javascript
// JavaScript — playwright.config.js
module.exports = defineConfig({
workers: process.env.CI ? 2 : undefined,
fullyParallel: false,
})
```
如果你手动创建上下文,务必关闭它们:
```typescript
// TypeScript
test("multi-user test", async ({ browser }) => {
const userContext = await browser.newContext()
const adminContext = await browser.newContext()
try {
const userPage = await userContext.newPage()
const adminPage = await adminContext.newPage()
// ... 测试逻辑
} finally {
await userContext.close()
await adminContext.close()
}
})
```
```javascript
// JavaScript
test("multi-user test", async ({ browser }) => {
const userContext = await browser.newContext()
const adminContext = await browser.newContext()
try {
const userPage = await userContext.newPage()
const adminPage = await adminContext.newPage()
// ... 测试逻辑
} finally {
await userContext.close()
await adminContext.close()
}
})
```
**相关**[ci/parallel-and-sharding.md](../ci/parallel-and-sharding.md)、[core/multi-user-and-collaboration.md](multi-user-and-collaboration.md)
---
### "Error: expect(locator).toHaveText() — expected string but received array"
**原因**:定位器匹配了多个元素,`toHaveText()` 返回了一个字符串数组而不是单个字符串。
**常见触发场景**
- 使用了宽泛的定位器如 `page.locator('.item')`,匹配了一个列表
- 期望一个单独的标题,但选择器匹配了多个
- 没有将定位器限定到特定容器
**修复**:要么将定位器缩小到一个元素,要么使用 `toHaveText()` 的数组形式。
```typescript
// TypeScript
// 不好——定位器匹配了多个元素
await expect(page.locator(".item")).toHaveText("First Item")
// 好——缩小到一个元素
await expect(page.locator(".item").first()).toHaveText("First Item")
// 好——在期望多个元素时使用数组断言
await expect(page.locator(".item")).toHaveText(["First Item", "Second Item", "Third Item"])
// 好——使用更具体的定位器
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Welcome")
```
```javascript
// JavaScript
// 好——缩小到一个元素
await expect(page.locator(".item").first()).toHaveText("First Item")
// 好——使用数组断言
await expect(page.locator(".item")).toHaveText(["First Item", "Second Item", "Third Item"])
// 好——使用更具体的定位器
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Welcome")
```
**相关**[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/locators.md](locators.md)
---
### "Error: page.waitForSelector: Timeout 30000ms exceeded (deprecated)"
**原因**`page.waitForSelector()` 超时。此方法已废弃,推荐使用基于定位器的断言。
**常见触发场景**
- 从旧教程或从 Puppeteer 迁移时使用了旧的 `page.waitForSelector()`
- 选择器不匹配 DOM 中的任何元素
- 元素存在但不符合状态要求(例如 `{ state: 'visible' }` 但元素处于隐藏状态)
**修复**:将 `waitForSelector()` 替换为基于定位器的断言。
```typescript
// TypeScript
// 不好——已废弃、无自动重试、不可组合
await page.waitForSelector(".loading", { state: "hidden" })
await page.waitForSelector(".content", { state: "visible" })
// 好——基于定位器的断言,带自动重试
await expect(page.locator(".loading")).toBeHidden()
await expect(page.locator(".content")).toBeVisible()
// 好——使用基于角色的定位器以获得更强的鲁棒性
await expect(page.getByRole("progressbar")).toBeHidden()
await expect(page.getByRole("main")).toBeVisible()
```
```javascript
// JavaScript
// 不好——已废弃
await page.waitForSelector(".loading", { state: "hidden" })
await page.waitForSelector(".content", { state: "visible" })
// 好——基于定位器的断言
await expect(page.locator(".loading")).toBeHidden()
await expect(page.locator(".content")).toBeVisible()
// 好——基于角色的定位器
await expect(page.getByRole("progressbar")).toBeHidden()
await expect(page.getByRole("main")).toBeVisible()
```
**相关**[core/locators.md](locators.md)、[core/assertions-and-waiting.md](assertions-and-waiting.md)、[migration/from-selenium.md](../migration/from-selenium.md)
---
### "Error: Test was expected to have a title matching /.../"
**原因**`expect(page).toHaveTitle()` 断言失败,因为页面标题在超时时间内未匹配期望的值或模式。
**常见触发场景**
- 页面尚未完成加载,标题仍是初始的空值或默认值
- SPA 在渲染后异步更新文档标题
- 期望的标题有拼写错误或大小写不匹配
- 页面重定向到了具有不同标题的错误页面
**修复**:确保页面已完全加载,并使用正确的期望值。
```typescript
// TypeScript
// toHaveTitle 会自动重试,但要确保页面已加载
await page.goto("/dashboard")
await expect(page).toHaveTitle("Dashboard | MyApp")
// 使用正则表达式进行灵活匹配
await expect(page).toHaveTitle(/Dashboard/)
// 调试:查看实际标题是什么
console.log(await page.title())
```
```javascript
// JavaScript
await page.goto("/dashboard")
await expect(page).toHaveTitle("Dashboard | MyApp")
// 使用正则表达式进行灵活匹配
await expect(page).toHaveTitle(/Dashboard/)
// 调试:查看实际标题是什么
console.log(await page.title())
```
**相关**[core/assertions-and-waiting.md](assertions-and-waiting.md)
---
### "Error: page.type: Element is not focusable"
**原因**`page.type()``locator.type()` 在输入前无法聚焦目标元素。该元素可能处于隐藏、禁用状态,或者不是一个输入元素。
**常见触发场景**
- 元素被遮罩层或模态框覆盖
- 元素设置了 `tabindex="-1"`,本身不可聚焦
- 使用了 `type()` 而不是 `fill()`——在几乎所有情况下都应优先使用 `fill()`
- 元素位于 Shadow DOM 内部,定位器无法穿透
**修复**:使用 `fill()` 代替 `type()`。仅在确实需要模拟逐个按键时才使用 `type()`
```typescript
// TypeScript
// 不好——type() 更慢且更脆弱
await page.locator("#search").type("hello")
// 好——fill() 直接清除并设置值
await page.getByRole("searchbox").fill("hello")
// 当真正需要逐键输入时(例如触发自动补全)
await page.getByRole("searchbox").pressSequentially("hello", { delay: 100 })
```
```javascript
// JavaScript
// 好——fill() 是首选
await page.getByRole("searchbox").fill("hello")
// 需要时逐键输入(例如自动补全)
await page.getByRole("searchbox").pressSequentially("hello", { delay: 100 })
```
**相关**[core/locators.md](locators.md)、[core/forms-and-validation.md](forms-and-validation.md)
---
### "Error: page.setInputFiles: Non-multiple file input can only accept single file"
**原因**:尝试向没有 `multiple` 属性的 `<input type="file">` 上传多个文件。
**常见触发场景**
- 向单文件上传输入框传递了一个文件数组
- 应用使用了一个自定义的上传组件,每次只能处理一个文件
**修复**:一次只上传一个文件,或者确保输入框支持多个文件。
```typescript
// TypeScript
// 不好——输入框没有 multiple 属性
await page.getByLabel("Upload file").setInputFiles([
"file1.pdf",
"file2.pdf", // 错误:非多文件输入
])
// 好——单个文件
await page.getByLabel("Upload file").setInputFiles("file1.pdf")
// 好——对于多文件输入(HTML 中必须有 multiple 属性)
await page.getByLabel("Upload files").setInputFiles(["file1.pdf", "file2.pdf"])
// 清除文件输入
await page.getByLabel("Upload file").setInputFiles([])
```
```javascript
// JavaScript
// 好——单个文件
await page.getByLabel("Upload file").setInputFiles("file1.pdf")
// 好——多个文件(输入框必须有 multiple 属性)
await page.getByLabel("Upload files").setInputFiles(["file1.pdf", "file2.pdf"])
// 清除文件输入
await page.getByLabel("Upload file").setInputFiles([])
```
**相关**[core/file-operations.md](file-operations.md)、[core/file-upload-download.md](file-upload-download.md)
---
### "Error: browser.newContext: Could not parse content-type application/json"
**原因**`storageState` 选项指向了一个包含无效 JSON 内容的文件,或者该文件不是有效的 Playwright storage state 格式。
**常见触发场景**
- 认证设置写入了一个空文件或不完整的 JSON
- storage state 文件被损坏或截断
- 手动编辑 storage state 文件引入了语法错误
- 认证设置静默失败(登录未完成)并保存了无效状态
**修复**:删除 storage state 文件并重新生成。
```bash
# 删除损坏的文件
rm -f playwright/.auth/user.json
# 重新运行设置
npx playwright test --project=setup
```
为防止此问题,在认证设置中添加错误检查:
```typescript
// TypeScript — auth.setup.ts
import { test as setup, expect } from "@playwright/test"
const authFile = "playwright/.auth/user.json"
setup("authenticate", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
// 在保存状态前验证登录确实成功
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await page.context().storageState({ path: authFile })
})
```
```javascript
// JavaScript — auth.setup.js
const { test: setup, expect } = require("@playwright/test")
const authFile = "playwright/.auth/user.json"
setup("authenticate", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
// 在保存状态前验证登录确实成功
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await page.context().storageState({ path: authFile })
})
```
**相关**[core/authentication.md](authentication.md)、[core/auth-flows.md](auth-flows.md)
---
## 快速诊断清单
当你遇到上面未列出的错误时,请逐一检查此清单:
1. **阅读完整的错误信息**——Playwright 错误信息包含定位器、操作和页面状态的片段。答案往往就在细节中。
2. **启用 traces**——临时添加 `trace: 'on'` 或在配置中添加 `trace: 'on-first-retry'`。运行 `npx playwright show-trace trace.zip` 查看失败时的截图、DOM、网络和控制台信息。
3. **使用 Playwright Inspector**——运行 `npx playwright test --debug` 以交互方式逐步执行测试。
4. **检查定位器**——运行 `npx playwright codegen <url>` 验证定位器是否与实时页面匹配。
5. **检查 Playwright 版本**——运行 `npx playwright --version`。确保 `@playwright/test` 和浏览器二进制文件版本一致。
6. **搜索 Playwright 问题追踪器**——许多错误在 [github.com/microsoft/playwright/issues](https://github.com/microsoft/playwright/issues) 上有已知的解决方案。