337 lines
11 KiB
Markdown
337 lines
11 KiB
Markdown
# 测试生成
|
||
|
||
> **使用场景**:从交互式 CLI 会话生成 Playwright 测试代码——录制用户操作流程、构建测试脚手架、将手动测试转化为自动化测试。
|
||
> **前置条件**:[core-commands.md](core-commands.md) 基础 CLI 用法
|
||
|
||
## 快速参考
|
||
|
||
```bash
|
||
# 每个 CLI 操作都会输出等价的 Playwright 代码
|
||
playwright-cli open https://example.com/login
|
||
playwright-cli snapshot
|
||
playwright-cli fill e1 "user@example.com"
|
||
# 输出: await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||
|
||
playwright-cli fill e2 "password123"
|
||
# 输出: await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||
|
||
playwright-cli click e3
|
||
# 输出: await page.getByRole('button', { name: 'Sign In' }).click();
|
||
```
|
||
|
||
## 工作原理
|
||
|
||
使用 `playwright-cli` 执行的每个操作都会自动在输出中生成对应的 Playwright TypeScript 代码。这些代码使用了 Playwright 在生产测试中推荐的**基于角色的定位器**。
|
||
|
||
工作流程:
|
||
|
||
1. **打开页面** → 生成 `await page.goto(url)`
|
||
2. **拍摄快照** → 查看元素引用及其可访问角色
|
||
3. **交互** → 每个操作生成一行 Playwright 代码
|
||
4. **收集代码** → 将生成的代码行组装成完整的测试
|
||
|
||
## 录制操作流程
|
||
|
||
### 示例:登录流程
|
||
|
||
```bash
|
||
playwright-cli open https://example.com/login
|
||
# 已执行 Playwright 代码:
|
||
# await page.goto('https://example.com/login');
|
||
|
||
playwright-cli snapshot
|
||
# 输出:
|
||
# e1 [textbox "Email"]
|
||
# e2 [textbox "Password"]
|
||
# e3 [button "Sign In"]
|
||
# e4 [link "Forgot password?"]
|
||
|
||
playwright-cli fill e1 "user@example.com"
|
||
# 已执行 Playwright 代码:
|
||
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||
|
||
playwright-cli fill e2 "password123"
|
||
# 已执行 Playwright 代码:
|
||
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||
|
||
playwright-cli click e3
|
||
# 已执行 Playwright 代码:
|
||
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||
```
|
||
|
||
### 组装成测试用例
|
||
|
||
收集生成的代码并包装成 Playwright 测试:
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("user can log in", async ({ page }) => {
|
||
await page.goto("https://example.com/login")
|
||
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
|
||
await page.getByRole("textbox", { name: "Password" }).fill("password123")
|
||
await page.getByRole("button", { name: "Sign In" }).click()
|
||
|
||
// 添加断言(非自动生成——需手动添加)
|
||
await expect(page).toHaveURL(/.*dashboard/)
|
||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
|
||
})
|
||
```
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("user can log in", async ({ page }) => {
|
||
await page.goto("https://example.com/login")
|
||
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
|
||
await page.getByRole("textbox", { name: "Password" }).fill("password123")
|
||
await page.getByRole("button", { name: "Sign In" }).click()
|
||
|
||
await expect(page).toHaveURL(/.*dashboard/)
|
||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
|
||
})
|
||
```
|
||
|
||
## 生成代码中的定位器策略
|
||
|
||
生成的代码使用 Playwright 推荐的定位器优先级:
|
||
|
||
| 优先级 | 定位器类型 | 示例 | 使用场景 |
|
||
|--------|-----------|----------------------------------------|--------------------------------|
|
||
| 1 | 基于角色 | `getByRole('button', { name: 'Submit' })` | 具有 ARIA 角色的元素 |
|
||
| 2 | 基于标签 | `getByLabel('Email')` | 带标签的表单输入 |
|
||
| 3 | 基于占位符 | `getByPlaceholder('Search...')` | 带占位符文本的输入 |
|
||
| 4 | 基于文本 | `getByText('Welcome back')` | 静态文本内容 |
|
||
| 5 | 测试 ID | `getByTestId('submit-btn')` | 带有 `data-testid` 的元素 |
|
||
|
||
这些定位器**对标记结构变化具有弹性**——它们反映用户感知页面的方式,而不是依赖 CSS 选择器或 XPath。
|
||
|
||
## 录制复杂操作流程
|
||
|
||
### 电商结算
|
||
|
||
```bash
|
||
playwright-cli open https://shop.example.com
|
||
|
||
# 浏览商品
|
||
playwright-cli snapshot
|
||
playwright-cli click e5 # "Add to Cart" 按钮
|
||
# await page.getByRole('button', { name: 'Add to Cart' }).click();
|
||
|
||
playwright-cli click e12 # 购物车图标
|
||
# await page.getByRole('link', { name: 'Cart' }).click();
|
||
|
||
playwright-cli snapshot
|
||
playwright-cli click e3 # "Proceed to Checkout"
|
||
# await page.getByRole('button', { name: 'Proceed to Checkout' }).click();
|
||
|
||
# 配送信息
|
||
playwright-cli snapshot
|
||
playwright-cli fill e1 "Jane Doe"
|
||
# await page.getByRole('textbox', { name: 'Full Name' }).fill('Jane Doe');
|
||
playwright-cli fill e2 "123 Main St"
|
||
# await page.getByRole('textbox', { name: 'Address' }).fill('123 Main St');
|
||
playwright-cli fill e3 "Springfield"
|
||
# await page.getByRole('textbox', { name: 'City' }).fill('Springfield');
|
||
playwright-cli select e4 "IL"
|
||
# await page.getByRole('combobox', { name: 'State' }).selectOption('IL');
|
||
playwright-cli fill e5 "62701"
|
||
# await page.getByRole('textbox', { name: 'ZIP Code' }).fill('62701');
|
||
playwright-cli click e6 # "Continue to Payment"
|
||
```
|
||
|
||
### 多步骤向导
|
||
|
||
```bash
|
||
playwright-cli open https://example.com/onboarding
|
||
|
||
# 步骤 1
|
||
playwright-cli snapshot
|
||
playwright-cli fill e1 "Acme Corp"
|
||
playwright-cli select e2 "technology"
|
||
playwright-cli click e3 # 下一步
|
||
|
||
# 步骤 2
|
||
playwright-cli snapshot
|
||
playwright-cli check e1 # 功能复选框
|
||
playwright-cli check e3 # 另一功能
|
||
playwright-cli click e5 # 下一步
|
||
|
||
# 步骤 3
|
||
playwright-cli snapshot
|
||
playwright-cli click e2 # "Complete Setup"
|
||
```
|
||
|
||
### 带动态结果的搜索
|
||
|
||
```bash
|
||
playwright-cli open https://example.com
|
||
playwright-cli snapshot
|
||
|
||
playwright-cli fill e1 "playwright testing"
|
||
# await page.getByRole('searchbox', { name: 'Search' }).fill('playwright testing');
|
||
|
||
playwright-cli press Enter
|
||
# await page.keyboard.press('Enter');
|
||
|
||
playwright-cli snapshot # 查看搜索结果——分配了新引用
|
||
|
||
playwright-cli click e3 # 第一个结果
|
||
# await page.getByRole('link', { name: 'Getting Started with Playwright' }).click();
|
||
```
|
||
|
||
## 添加断言
|
||
|
||
生成的代码捕获的是**操作**而非**断言**。务必手动添加断言以创建有意义的测试。
|
||
|
||
### 常用断言
|
||
|
||
```typescript
|
||
// 导航后 URL 改变
|
||
await expect(page).toHaveURL(/.*dashboard/)
|
||
await expect(page).toHaveURL("https://example.com/success")
|
||
|
||
// 元素可见
|
||
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
|
||
await expect(page.getByText("Order confirmed")).toBeVisible()
|
||
|
||
// 元素包含文本
|
||
await expect(page.getByTestId("total")).toHaveText("$99.99")
|
||
await expect(page.getByRole("alert")).toContainText("saved")
|
||
|
||
// 元素具有特定属性
|
||
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled()
|
||
await expect(page.getByRole("checkbox")).toBeChecked()
|
||
|
||
// 元素数量
|
||
await expect(page.getByRole("listitem")).toHaveCount(5)
|
||
|
||
// 页面标题
|
||
await expect(page).toHaveTitle(/Dashboard/)
|
||
|
||
// 截图对比
|
||
await expect(page).toHaveScreenshot("checkout.png")
|
||
```
|
||
|
||
### 断言的放置位置
|
||
|
||
```typescript
|
||
test("complete checkout flow", async ({ page }) => {
|
||
await page.goto("https://shop.example.com/products")
|
||
|
||
// 操作:将商品加入购物车
|
||
await page.getByRole("button", { name: "Add to Cart" }).click()
|
||
|
||
// 断言:购物车角标更新
|
||
await expect(page.getByTestId("cart-count")).toHaveText("1")
|
||
|
||
// 操作:前往购物车
|
||
await page.getByRole("link", { name: "Cart" }).click()
|
||
|
||
// 断言:页面正确
|
||
await expect(page).toHaveURL(/.*cart/)
|
||
await expect(page.getByRole("heading", { name: "Your Cart" })).toBeVisible()
|
||
|
||
// 操作:前往结算
|
||
await page.getByRole("button", { name: "Checkout" }).click()
|
||
|
||
// 操作:填写配送信息
|
||
await page.getByRole("textbox", { name: "Full Name" }).fill("Jane Doe")
|
||
await page.getByRole("textbox", { name: "Address" }).fill("123 Main St")
|
||
await page.getByRole("button", { name: "Place Order" }).click()
|
||
|
||
// 断言:订单已确认
|
||
await expect(page.getByText("Order confirmed")).toBeVisible()
|
||
await expect(page.getByTestId("order-number")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
## 最佳实践
|
||
|
||
### 1. 先探索再录制
|
||
|
||
先拍摄快照了解页面结构,不要盲目点击——先弄清有哪些元素可用:
|
||
|
||
```bash
|
||
playwright-cli open https://example.com
|
||
playwright-cli snapshot
|
||
# 审查元素,规划操作流程,然后开始交互
|
||
```
|
||
|
||
### 2. 使用语义化定位器
|
||
|
||
生成的代码已优先使用基于角色的定位器。如果在生成输出中看到 CSS 选择器,请考虑提交 issue——基于角色的定位器更具弹性:
|
||
|
||
```typescript
|
||
// 生成(好——语义化,有弹性)
|
||
await page.getByRole("button", { name: "Submit" }).click()
|
||
|
||
// 避免手动编写(脆弱——CSS 变化即失效)
|
||
await page.locator("#submit-btn").click()
|
||
await page.locator(".btn.btn-primary").click()
|
||
```
|
||
|
||
### 3. 保持测试专注
|
||
|
||
一个测试 = 一个用户行为。不要将整个会话录制进一个测试:
|
||
|
||
```typescript
|
||
// 好:专注的测试
|
||
test("user can add item to cart", async ({ page }) => {
|
||
// 仅限加入购物车流程
|
||
})
|
||
|
||
test("user can complete checkout", async ({ page }) => {
|
||
// 仅限结算流程(使用认证状态跳过登录)
|
||
})
|
||
|
||
// 差:单体测试
|
||
test("user journey", async ({ page }) => {
|
||
// 登录 + 浏览 + 加入购物车 + 结算 + 验证邮件……
|
||
})
|
||
```
|
||
|
||
### 4. 参数化测试数据
|
||
|
||
将录制中的硬编码值替换为变量或测试数据:
|
||
|
||
```typescript
|
||
// 而非录制中的硬编码值
|
||
test("registration", async ({ page }) => {
|
||
const user = {
|
||
name: "Jane Doe",
|
||
email: `test+${Date.now()}@example.com`,
|
||
password: "SecurePass123!",
|
||
}
|
||
|
||
await page.goto("/register")
|
||
await page.getByRole("textbox", { name: "Name" }).fill(user.name)
|
||
await page.getByRole("textbox", { name: "Email" }).fill(user.email)
|
||
await page.getByRole("textbox", { name: "Password" }).fill(user.password)
|
||
await page.getByRole("button", { name: "Create Account" }).click()
|
||
|
||
await expect(page).toHaveURL(/.*welcome/)
|
||
})
|
||
```
|
||
|
||
### 5. 为不稳定步骤添加等待策略
|
||
|
||
如果录制的操作依赖于异步内容加载,请添加显式等待:
|
||
|
||
```typescript
|
||
// 在点击动态加载的元素之前
|
||
await page.waitForSelector(".results-loaded")
|
||
await page.getByRole("link", { name: "First Result" }).click()
|
||
|
||
// 或使用 Playwright 的自动等待(推荐)
|
||
await expect(page.getByRole("link", { name: "First Result" })).toBeVisible()
|
||
await page.getByRole("link", { name: "First Result" }).click()
|
||
```
|
||
|
||
## 提示
|
||
|
||
- **生成的代码只是一个起点**——在提交到测试套件之前,务必审查、添加断言并参数化
|
||
- **动态变化后重新快照**——DOM 更新后引用会改变
|
||
- **结合 `state-save`**——录制一次登录流程,保存状态,然后所有其他录制都从已认证状态开始
|
||
- **为快照使用 `--filename`**——保存复杂流程的前/后快照,以便后续编写断言
|