1922 lines
58 KiB
Markdown
1922 lines
58 KiB
Markdown
# 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) 上有已知的解决方案。
|