957 lines
30 KiB
Markdown
957 lines
30 KiB
Markdown
# CRUD 测试配方
|
|
|
|
> **使用场景**:你需要测试任意资源的创建、读取、更新或删除操作——包括表单、表格、列表、卡片、内联编辑或批量操作。
|
|
|
|
---
|
|
|
|
## 配方 1:创建资源(填写表单、提交、验证)
|
|
|
|
### 完整示例
|
|
|
|
**TypeScript**
|
|
|
|
```typescript
|
|
import { test, expect } from "@playwright/test"
|
|
|
|
test("通过表单创建新产品", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 点击创建按钮
|
|
await page.getByRole("button", { name: "Add product" }).click()
|
|
|
|
// 填写创建表单
|
|
await page.getByLabel("Product name").fill("Wireless Keyboard")
|
|
await page.getByLabel("SKU").fill("KB-WIRELESS-001")
|
|
await page.getByLabel("Price").fill("79.99")
|
|
await page.getByLabel("Description").fill("Ergonomic wireless keyboard with backlit keys")
|
|
await page.getByLabel("Category").selectOption("Electronics")
|
|
await page.getByLabel("In stock").check()
|
|
|
|
// 提交
|
|
await page.getByRole("button", { name: "Save product" }).click()
|
|
|
|
// 验证成功通知
|
|
await expect(page.getByRole("alert")).toContainText("Product created successfully")
|
|
|
|
// 验证新项目出现在列表中
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toContainText("$79.99")
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toContainText(
|
|
"KB-WIRELESS-001"
|
|
)
|
|
})
|
|
|
|
test("对无效表单数据显示验证错误", async ({ page }) => {
|
|
await page.goto("/products")
|
|
await page.getByRole("button", { name: "Add product" }).click()
|
|
|
|
// 提交空表单
|
|
await page.getByRole("button", { name: "Save product" }).click()
|
|
|
|
// 验证字段级别的验证消息
|
|
await expect(page.getByText("Product name is required")).toBeVisible()
|
|
await expect(page.getByText("Price is required")).toBeVisible()
|
|
|
|
// 填写无效数据
|
|
await page.getByLabel("Product name").fill("A") // 太短
|
|
await page.getByLabel("Price").fill("-5")
|
|
|
|
await page.getByRole("button", { name: "Save product" }).click()
|
|
|
|
await expect(page.getByText("Name must be at least 3 characters")).toBeVisible()
|
|
await expect(page.getByText("Price must be a positive number")).toBeVisible()
|
|
})
|
|
|
|
test("阻止重复创建资源", async ({ page }) => {
|
|
await page.goto("/products")
|
|
await page.getByRole("button", { name: "Add product" }).click()
|
|
|
|
// 尝试使用已存在的 SKU 创建产品
|
|
await page.getByLabel("Product name").fill("Duplicate Product")
|
|
await page.getByLabel("SKU").fill("EXISTING-SKU-001")
|
|
await page.getByLabel("Price").fill("29.99")
|
|
await page.getByRole("button", { name: "Save product" }).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText(/already exists|duplicate/i)
|
|
})
|
|
```
|
|
|
|
**JavaScript**
|
|
|
|
```javascript
|
|
const { test, expect } = require("@playwright/test")
|
|
|
|
test("通过表单创建新产品", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
await page.getByRole("button", { name: "Add product" }).click()
|
|
|
|
await page.getByLabel("Product name").fill("Wireless Keyboard")
|
|
await page.getByLabel("SKU").fill("KB-WIRELESS-001")
|
|
await page.getByLabel("Price").fill("79.99")
|
|
await page.getByLabel("Description").fill("Ergonomic wireless keyboard with backlit keys")
|
|
await page.getByLabel("Category").selectOption("Electronics")
|
|
await page.getByLabel("In stock").check()
|
|
|
|
await page.getByRole("button", { name: "Save product" }).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Product created successfully")
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toContainText("$79.99")
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 配方 2:读取/列出资源(表格、卡片、分页)
|
|
|
|
### 完整示例
|
|
|
|
**TypeScript**
|
|
|
|
```typescript
|
|
import { test, expect } from "@playwright/test"
|
|
|
|
test.describe("在表格中读取资源", () => {
|
|
test("在表格中显示资源,列名正确", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 验证表头
|
|
const headers = page.getByRole("columnheader")
|
|
await expect(headers).toContainText(["Name", "SKU", "Price", "Category", "Status"])
|
|
|
|
// 验证至少存在一行数据
|
|
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
await expect(rows.first()).toBeVisible()
|
|
|
|
// 验证行中特定数据
|
|
const firstRow = rows.first()
|
|
await expect(firstRow.getByRole("cell").nth(0)).not.toBeEmpty()
|
|
await expect(firstRow.getByRole("cell").nth(2)).toContainText("$")
|
|
})
|
|
|
|
test("按列排序表格", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 点击 Price 表头进行排序
|
|
await page.getByRole("columnheader", { name: "Price" }).click()
|
|
|
|
// 获取表格中所有价格
|
|
const priceCells = page
|
|
.getByRole("row")
|
|
.filter({ hasNot: page.getByRole("columnheader") })
|
|
.getByRole("cell")
|
|
.nth(2)
|
|
|
|
const prices = await priceCells.allTextContents()
|
|
const numericPrices = prices.map((p) => parseFloat(p.replace(/[$,]/g, "")))
|
|
|
|
// 验证升序排列
|
|
for (let i = 1; i < numericPrices.length; i++) {
|
|
expect(numericPrices[i]).toBeGreaterThanOrEqual(numericPrices[i - 1])
|
|
}
|
|
|
|
// 再次点击切换为降序
|
|
await page.getByRole("columnheader", { name: "Price" }).click()
|
|
|
|
const pricesDesc = await priceCells.allTextContents()
|
|
const numericDesc = pricesDesc.map((p) => parseFloat(p.replace(/[$,]/g, "")))
|
|
|
|
for (let i = 1; i < numericDesc.length; i++) {
|
|
expect(numericDesc[i]).toBeLessThanOrEqual(numericDesc[i - 1])
|
|
}
|
|
})
|
|
|
|
test("分页浏览结果", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 验证分页控件可见
|
|
const pagination = page.getByRole("navigation", { name: /pagination/i })
|
|
await expect(pagination).toBeVisible()
|
|
|
|
// 获取第一页内容
|
|
const firstPageFirstRow = await page
|
|
.getByRole("row")
|
|
.filter({ hasNot: page.getByRole("columnheader") })
|
|
.first()
|
|
.textContent()
|
|
|
|
// 跳转到第 2 页
|
|
await pagination.getByRole("button", { name: "2" }).click()
|
|
|
|
// 等待数据加载
|
|
await expect(
|
|
page
|
|
.getByRole("row")
|
|
.filter({ hasNot: page.getByRole("columnheader") })
|
|
.first()
|
|
).not.toHaveText(firstPageFirstRow!)
|
|
|
|
// 验证第 2 页处于激活状态
|
|
await expect(pagination.getByRole("button", { name: "2" })).toHaveAttribute(
|
|
"aria-current",
|
|
"page"
|
|
)
|
|
|
|
// 验证 URL 已更新分页参数
|
|
await expect(page).toHaveURL(/[?&]page=2/)
|
|
})
|
|
|
|
test("显示正确的项目计数", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 验证总数显示
|
|
await expect(page.getByText(/showing \d+.+of \d+/i)).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe("以卡片形式读取资源", () => {
|
|
test("以卡片网格形式显示资源", async ({ page }) => {
|
|
await page.goto("/products?view=grid")
|
|
|
|
// 验证卡片已显示
|
|
const cards = page.getByRole("article")
|
|
await expect(cards.first()).toBeVisible()
|
|
|
|
// 验证卡片内容
|
|
const firstCard = cards.first()
|
|
await expect(firstCard.getByRole("heading")).toBeVisible()
|
|
await expect(firstCard.getByText("$")).toBeVisible()
|
|
await expect(firstCard.getByRole("img")).toBeVisible()
|
|
})
|
|
|
|
test("在列表视图和网格视图之间切换", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 以表格/列表视图开始
|
|
await expect(page.getByRole("table")).toBeVisible()
|
|
|
|
// 切换到网格
|
|
await page.getByRole("button", { name: /grid view/i }).click()
|
|
await expect(page.getByRole("article").first()).toBeVisible()
|
|
await expect(page.getByRole("table")).not.toBeVisible()
|
|
|
|
// 切换回列表
|
|
await page.getByRole("button", { name: /list view/i }).click()
|
|
await expect(page.getByRole("table")).toBeVisible()
|
|
})
|
|
})
|
|
```
|
|
|
|
**JavaScript**
|
|
|
|
```javascript
|
|
const { test, expect } = require("@playwright/test")
|
|
|
|
test("在表格中显示资源,列名正确", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const headers = page.getByRole("columnheader")
|
|
await expect(headers).toContainText(["Name", "SKU", "Price", "Category", "Status"])
|
|
|
|
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
await expect(rows.first()).toBeVisible()
|
|
})
|
|
|
|
test("分页浏览结果", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const pagination = page.getByRole("navigation", { name: /pagination/i })
|
|
await expect(pagination).toBeVisible()
|
|
|
|
const firstPageFirstRow = await page
|
|
.getByRole("row")
|
|
.filter({ hasNot: page.getByRole("columnheader") })
|
|
.first()
|
|
.textContent()
|
|
|
|
await pagination.getByRole("button", { name: "2" }).click()
|
|
|
|
await expect(
|
|
page
|
|
.getByRole("row")
|
|
.filter({ hasNot: page.getByRole("columnheader") })
|
|
.first()
|
|
).not.toHaveText(firstPageFirstRow)
|
|
|
|
await expect(page).toHaveURL(/[?&]page=2/)
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 配方 3:更新资源(编辑表单、保存、验证变更)
|
|
|
|
### 完整示例
|
|
|
|
**TypeScript**
|
|
|
|
```typescript
|
|
import { test, expect } from "@playwright/test"
|
|
|
|
test("更新现有产品", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 找到目标行并点击编辑
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await row.getByRole("button", { name: "Edit" }).click()
|
|
|
|
// 验证编辑表单已预填现有数据
|
|
await expect(page.getByLabel("Product name")).toHaveValue("Wireless Keyboard")
|
|
await expect(page.getByLabel("Price")).toHaveValue("79.99")
|
|
|
|
// 更新字段
|
|
await page.getByLabel("Product name").clear()
|
|
await page.getByLabel("Product name").fill("Wireless Keyboard Pro")
|
|
await page.getByLabel("Price").clear()
|
|
await page.getByLabel("Price").fill("99.99")
|
|
|
|
// 保存更改
|
|
await page.getByRole("button", { name: "Save changes" }).click()
|
|
|
|
// 验证成功通知
|
|
await expect(page.getByRole("alert")).toContainText("Product updated successfully")
|
|
|
|
// 验证列表反映了更改
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard Pro/ })).toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard Pro/ })).toContainText("$99.99")
|
|
|
|
// 验证旧名称已消失
|
|
await expect(page.getByRole("row", { name: /^Wireless Keyboard$/ })).not.toBeVisible()
|
|
})
|
|
|
|
test("编辑表单在离开页面再返回后保留数据", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await row.getByRole("button", { name: "Edit" }).click()
|
|
|
|
// 做出更改但不保存
|
|
await page.getByLabel("Product name").clear()
|
|
await page.getByLabel("Product name").fill("Modified Name")
|
|
|
|
// 尝试离开页面
|
|
await page.getByRole("link", { name: "Dashboard" }).click()
|
|
|
|
// 预期出现未保存更改警告
|
|
page.on("dialog", async (dialog) => {
|
|
expect(dialog.message()).toContain("unsaved changes")
|
|
await dialog.dismiss() // 留在当前页面
|
|
})
|
|
|
|
// 验证取消离开后数据仍然存在
|
|
await expect(page.getByLabel("Product name")).toHaveValue("Modified Name")
|
|
})
|
|
|
|
test("取消编辑则丢弃更改", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await row.getByRole("button", { name: "Edit" }).click()
|
|
|
|
// 做出更改
|
|
await page.getByLabel("Product name").clear()
|
|
await page.getByLabel("Product name").fill("Should Not Save")
|
|
|
|
// 取消
|
|
await page.getByRole("button", { name: "Cancel" }).click()
|
|
|
|
// 验证原始数据被保留
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Should Not Save/ })).not.toBeVisible()
|
|
})
|
|
```
|
|
|
|
**JavaScript**
|
|
|
|
```javascript
|
|
const { test, expect } = require("@playwright/test")
|
|
|
|
test("更新现有产品", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await row.getByRole("button", { name: "Edit" }).click()
|
|
|
|
await expect(page.getByLabel("Product name")).toHaveValue("Wireless Keyboard")
|
|
await expect(page.getByLabel("Price")).toHaveValue("79.99")
|
|
|
|
await page.getByLabel("Product name").clear()
|
|
await page.getByLabel("Product name").fill("Wireless Keyboard Pro")
|
|
await page.getByLabel("Price").clear()
|
|
await page.getByLabel("Price").fill("99.99")
|
|
|
|
await page.getByRole("button", { name: "Save changes" }).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Product updated successfully")
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard Pro/ })).toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard Pro/ })).toContainText("$99.99")
|
|
})
|
|
|
|
test("取消编辑则丢弃更改", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await row.getByRole("button", { name: "Edit" }).click()
|
|
|
|
await page.getByLabel("Product name").clear()
|
|
await page.getByLabel("Product name").fill("Should Not Save")
|
|
|
|
await page.getByRole("button", { name: "Cancel" }).click()
|
|
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Should Not Save/ })).not.toBeVisible()
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 配方 4:删除资源(删除、确认对话框、验证移除)
|
|
|
|
### 完整示例
|
|
|
|
**TypeScript**
|
|
|
|
```typescript
|
|
import { test, expect } from "@playwright/test"
|
|
|
|
test("通过确认对话框删除产品", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 统计删除前的项目数
|
|
const rowsBefore = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
const countBefore = await rowsBefore.count()
|
|
|
|
// 点击特定产品的删除按钮
|
|
const targetRow = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await expect(targetRow).toBeVisible()
|
|
await targetRow.getByRole("button", { name: "Delete" }).click()
|
|
|
|
// 确认对话框应出现
|
|
const dialog = page.getByRole("dialog")
|
|
await expect(dialog).toBeVisible()
|
|
await expect(dialog).toContainText("Are you sure you want to delete")
|
|
await expect(dialog).toContainText("Wireless Keyboard")
|
|
await expect(dialog.getByText(/cannot be undone/i)).toBeVisible()
|
|
|
|
// 确认删除
|
|
await dialog.getByRole("button", { name: "Delete" }).click()
|
|
|
|
// 验证对话框关闭
|
|
await expect(dialog).not.toBeVisible()
|
|
|
|
// 验证成功通知
|
|
await expect(page.getByRole("alert")).toContainText("Product deleted")
|
|
|
|
// 验证项目已从列表中移除
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).not.toBeVisible()
|
|
|
|
// 验证计数减少
|
|
const rowsAfter = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
await expect(rowsAfter).toHaveCount(countBefore - 1)
|
|
})
|
|
|
|
test("取消删除则保留资源", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const targetRow = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await targetRow.getByRole("button", { name: "Delete" }).click()
|
|
|
|
const dialog = page.getByRole("dialog")
|
|
await expect(dialog).toBeVisible()
|
|
|
|
// 取消删除
|
|
await dialog.getByRole("button", { name: "Cancel" }).click()
|
|
|
|
// 对话框关闭,项目仍然存在
|
|
await expect(dialog).not.toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toBeVisible()
|
|
})
|
|
|
|
test("优雅处理删除错误", async ({ page }) => {
|
|
// 模拟删除接口返回失败
|
|
await page.route("**/api/products/*", async (route) => {
|
|
if (route.request().method() === "DELETE") {
|
|
await route.fulfill({
|
|
status: 409,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
error: "Cannot delete product with active orders",
|
|
}),
|
|
})
|
|
} else {
|
|
await route.continue()
|
|
}
|
|
})
|
|
|
|
await page.goto("/products")
|
|
|
|
const targetRow = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await targetRow.getByRole("button", { name: "Delete" }).click()
|
|
|
|
const dialog = page.getByRole("dialog")
|
|
await dialog.getByRole("button", { name: "Delete" }).click()
|
|
|
|
// 验证向用户显示了错误信息
|
|
await expect(page.getByRole("alert")).toContainText("Cannot delete product with active orders")
|
|
|
|
// 项目应仍在列表中
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toBeVisible()
|
|
})
|
|
```
|
|
|
|
**JavaScript**
|
|
|
|
```javascript
|
|
const { test, expect } = require("@playwright/test")
|
|
|
|
test("通过确认对话框删除产品", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const rowsBefore = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
const countBefore = await rowsBefore.count()
|
|
|
|
const targetRow = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await targetRow.getByRole("button", { name: "Delete" }).click()
|
|
|
|
const dialog = page.getByRole("dialog")
|
|
await expect(dialog).toBeVisible()
|
|
await expect(dialog).toContainText("Wireless Keyboard")
|
|
|
|
await dialog.getByRole("button", { name: "Delete" }).click()
|
|
|
|
await expect(dialog).not.toBeVisible()
|
|
await expect(page.getByRole("alert")).toContainText("Product deleted")
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).not.toBeVisible()
|
|
|
|
const rowsAfter = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
await expect(rowsAfter).toHaveCount(countBefore - 1)
|
|
})
|
|
|
|
test("取消删除则保留资源", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const targetRow = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
await targetRow.getByRole("button", { name: "Delete" }).click()
|
|
|
|
const dialog = page.getByRole("dialog")
|
|
await dialog.getByRole("button", { name: "Cancel" }).click()
|
|
|
|
await expect(dialog).not.toBeVisible()
|
|
await expect(page.getByRole("row", { name: /Wireless Keyboard/ })).toBeVisible()
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 配方 5:内联编辑
|
|
|
|
### 完整示例
|
|
|
|
**TypeScript**
|
|
|
|
```typescript
|
|
import { test, expect } from "@playwright/test"
|
|
|
|
test("通过双击内联编辑字段", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
const nameCell = row.getByRole("cell").first()
|
|
|
|
// 双击进入编辑模式
|
|
await nameCell.dblclick()
|
|
|
|
// 单元格现在应包含输入框
|
|
const inlineInput = nameCell.getByRole("textbox")
|
|
await expect(inlineInput).toBeVisible()
|
|
await expect(inlineInput).toHaveValue("Wireless Keyboard")
|
|
|
|
// 编辑值
|
|
await inlineInput.clear()
|
|
await inlineInput.fill("Wireless Keyboard v2")
|
|
|
|
// 按 Enter 保存
|
|
await inlineInput.press("Enter")
|
|
|
|
// 验证单元格显示更新后的值(不再有输入框)
|
|
await expect(nameCell.getByRole("textbox")).not.toBeVisible()
|
|
await expect(nameCell).toHaveText("Wireless Keyboard v2")
|
|
|
|
// 验证成功提示出现
|
|
await expect(page.getByRole("alert")).toContainText(/saved|updated/i)
|
|
})
|
|
|
|
test("按 Escape 键取消内联编辑", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
const nameCell = row.getByRole("cell").first()
|
|
|
|
await nameCell.dblclick()
|
|
|
|
const inlineInput = nameCell.getByRole("textbox")
|
|
await inlineInput.clear()
|
|
await inlineInput.fill("Temporary Value")
|
|
|
|
// 按 Escape 取消
|
|
await inlineInput.press("Escape")
|
|
|
|
// 验证原始值已恢复
|
|
await expect(nameCell).toHaveText("Wireless Keyboard")
|
|
await expect(nameCell.getByRole("textbox")).not.toBeVisible()
|
|
})
|
|
|
|
test("点击其他地方保存内联编辑", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
const nameCell = row.getByRole("cell").first()
|
|
|
|
await nameCell.dblclick()
|
|
|
|
const inlineInput = nameCell.getByRole("textbox")
|
|
await inlineInput.clear()
|
|
await inlineInput.fill("Keyboard Updated")
|
|
|
|
// 点击页面上其他地方触发保存
|
|
await page.getByRole("heading").first().click()
|
|
|
|
await expect(nameCell).toHaveText("Keyboard Updated")
|
|
})
|
|
|
|
test("内联编辑显示验证错误", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
const priceCell = row.getByRole("cell").nth(2)
|
|
|
|
await priceCell.dblclick()
|
|
|
|
const inlineInput = priceCell.getByRole("textbox")
|
|
await inlineInput.clear()
|
|
await inlineInput.fill("-10")
|
|
await inlineInput.press("Enter")
|
|
|
|
// 验证错误应以内联方式显示
|
|
await expect(priceCell.getByText(/positive number|invalid/i)).toBeVisible()
|
|
|
|
// 输入框应保持可见以便更正
|
|
await expect(inlineInput).toBeVisible()
|
|
})
|
|
```
|
|
|
|
**JavaScript**
|
|
|
|
```javascript
|
|
const { test, expect } = require("@playwright/test")
|
|
|
|
test("通过双击内联编辑字段", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
const nameCell = row.getByRole("cell").first()
|
|
|
|
await nameCell.dblclick()
|
|
|
|
const inlineInput = nameCell.getByRole("textbox")
|
|
await expect(inlineInput).toBeVisible()
|
|
await expect(inlineInput).toHaveValue("Wireless Keyboard")
|
|
|
|
await inlineInput.clear()
|
|
await inlineInput.fill("Wireless Keyboard v2")
|
|
await inlineInput.press("Enter")
|
|
|
|
await expect(nameCell.getByRole("textbox")).not.toBeVisible()
|
|
await expect(nameCell).toHaveText("Wireless Keyboard v2")
|
|
})
|
|
|
|
test("按 Escape 键取消内联编辑", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
const nameCell = row.getByRole("cell").first()
|
|
|
|
await nameCell.dblclick()
|
|
const inlineInput = nameCell.getByRole("textbox")
|
|
await inlineInput.clear()
|
|
await inlineInput.fill("Temporary Value")
|
|
await inlineInput.press("Escape")
|
|
|
|
await expect(nameCell).toHaveText("Wireless Keyboard")
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 配方 6:批量操作
|
|
|
|
### 完整示例
|
|
|
|
**TypeScript**
|
|
|
|
```typescript
|
|
import { test, expect } from "@playwright/test"
|
|
|
|
test("选择多个项目并执行批量删除", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 通过复选框选择多个项目
|
|
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
|
|
await rows.nth(0).getByRole("checkbox").check()
|
|
await rows.nth(1).getByRole("checkbox").check()
|
|
await rows.nth(2).getByRole("checkbox").check()
|
|
|
|
// 验证已显示选中数量
|
|
await expect(page.getByText("3 items selected")).toBeVisible()
|
|
|
|
// 验证批量操作工具栏出现
|
|
const bulkToolbar = page.getByRole("toolbar", { name: /bulk actions/i })
|
|
await expect(bulkToolbar).toBeVisible()
|
|
|
|
// 点击批量删除
|
|
await bulkToolbar.getByRole("button", { name: "Delete selected" }).click()
|
|
|
|
// 在对话框中确认
|
|
const dialog = page.getByRole("dialog")
|
|
await expect(dialog).toContainText("Delete 3 products")
|
|
await dialog.getByRole("button", { name: "Delete" }).click()
|
|
|
|
// 验证项目已移除
|
|
await expect(page.getByRole("alert")).toContainText("3 products deleted")
|
|
|
|
// 验证选中计数已清除
|
|
await expect(page.getByText("items selected")).not.toBeVisible()
|
|
await expect(bulkToolbar).not.toBeVisible()
|
|
})
|
|
|
|
test("全选和取消全选", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
// 点击表头中的"全选"复选框
|
|
const selectAllCheckbox = page
|
|
.getByRole("row")
|
|
.filter({ has: page.getByRole("columnheader") })
|
|
.getByRole("checkbox")
|
|
|
|
await selectAllCheckbox.check()
|
|
|
|
// 所有行复选框都应被选中
|
|
const rowCheckboxes = page
|
|
.getByRole("row")
|
|
.filter({ hasNot: page.getByRole("columnheader") })
|
|
.getByRole("checkbox")
|
|
|
|
const count = await rowCheckboxes.count()
|
|
for (let i = 0; i < count; i++) {
|
|
await expect(rowCheckboxes.nth(i)).toBeChecked()
|
|
}
|
|
|
|
await expect(page.getByText(`${count} items selected`)).toBeVisible()
|
|
|
|
// 取消全选
|
|
await selectAllCheckbox.uncheck()
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
await expect(rowCheckboxes.nth(i)).not.toBeChecked()
|
|
}
|
|
|
|
await expect(page.getByText("items selected")).not.toBeVisible()
|
|
})
|
|
|
|
test("批量更改状态", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
await rows.nth(0).getByRole("checkbox").check()
|
|
await rows.nth(1).getByRole("checkbox").check()
|
|
|
|
const bulkToolbar = page.getByRole("toolbar", { name: /bulk actions/i })
|
|
await bulkToolbar.getByRole("button", { name: "Change status" }).click()
|
|
|
|
// 从下拉菜单中选择新状态
|
|
await page.getByRole("menuitem", { name: "Archived" }).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("2 products archived")
|
|
|
|
// 验证行中状态已更改
|
|
await expect(rows.nth(0)).toContainText("Archived")
|
|
await expect(rows.nth(1)).toContainText("Archived")
|
|
})
|
|
|
|
test("批量导出选中的项目", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
await rows.nth(0).getByRole("checkbox").check()
|
|
await rows.nth(1).getByRole("checkbox").check()
|
|
|
|
const bulkToolbar = page.getByRole("toolbar", { name: /bulk actions/i })
|
|
|
|
// 在点击前开始等待下载
|
|
const downloadPromise = page.waitForEvent("download")
|
|
await bulkToolbar.getByRole("button", { name: "Export" }).click()
|
|
|
|
const download = await downloadPromise
|
|
expect(download.suggestedFilename()).toMatch(/products.*\.csv$/)
|
|
})
|
|
```
|
|
|
|
**JavaScript**
|
|
|
|
```javascript
|
|
const { test, expect } = require("@playwright/test")
|
|
|
|
test("选择多个项目并执行批量删除", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") })
|
|
await rows.nth(0).getByRole("checkbox").check()
|
|
await rows.nth(1).getByRole("checkbox").check()
|
|
await rows.nth(2).getByRole("checkbox").check()
|
|
|
|
await expect(page.getByText("3 items selected")).toBeVisible()
|
|
|
|
const bulkToolbar = page.getByRole("toolbar", { name: /bulk actions/i })
|
|
await bulkToolbar.getByRole("button", { name: "Delete selected" }).click()
|
|
|
|
const dialog = page.getByRole("dialog")
|
|
await expect(dialog).toContainText("Delete 3 products")
|
|
await dialog.getByRole("button", { name: "Delete" }).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("3 products deleted")
|
|
})
|
|
|
|
test("全选和取消全选", async ({ page }) => {
|
|
await page.goto("/products")
|
|
|
|
const selectAllCheckbox = page
|
|
.getByRole("row")
|
|
.filter({ has: page.getByRole("columnheader") })
|
|
.getByRole("checkbox")
|
|
|
|
await selectAllCheckbox.check()
|
|
|
|
const rowCheckboxes = page
|
|
.getByRole("row")
|
|
.filter({ hasNot: page.getByRole("columnheader") })
|
|
.getByRole("checkbox")
|
|
|
|
const count = await rowCheckboxes.count()
|
|
for (let i = 0; i < count; i++) {
|
|
await expect(rowCheckboxes.nth(i)).toBeChecked()
|
|
}
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 变体
|
|
|
|
### 通过多步骤向导创建
|
|
|
|
```typescript
|
|
test("通过多步骤向导创建资源", async ({ page }) => {
|
|
await page.goto("/products")
|
|
await page.getByRole("button", { name: "Add product" }).click()
|
|
|
|
// 步骤 1:基本信息
|
|
await expect(page.getByText("Step 1 of 3")).toBeVisible()
|
|
await page.getByLabel("Product name").fill("Wireless Keyboard")
|
|
await page.getByLabel("Category").selectOption("Electronics")
|
|
await page.getByRole("button", { name: "Next" }).click()
|
|
|
|
// 步骤 2:定价
|
|
await expect(page.getByText("Step 2 of 3")).toBeVisible()
|
|
await page.getByLabel("Price").fill("79.99")
|
|
await page.getByLabel("Tax rate").selectOption("Standard (20%)")
|
|
await page.getByRole("button", { name: "Next" }).click()
|
|
|
|
// 步骤 3:审核并确认
|
|
await expect(page.getByText("Step 3 of 3")).toBeVisible()
|
|
await expect(page.getByText("Wireless Keyboard")).toBeVisible()
|
|
await expect(page.getByText("$79.99")).toBeVisible()
|
|
await page.getByRole("button", { name: "Create product" }).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Product created")
|
|
})
|
|
```
|
|
|
|
### CRUD 结合 API 验证
|
|
|
|
```typescript
|
|
test("创建并通过 API 验证", async ({ page, request }) => {
|
|
await page.goto("/products")
|
|
await page.getByRole("button", { name: "Add product" }).click()
|
|
|
|
await page.getByLabel("Product name").fill("API Verified Product")
|
|
await page.getByLabel("Price").fill("49.99")
|
|
await page.getByRole("button", { name: "Save product" }).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Product created")
|
|
|
|
// 同时通过 API 验证数据是否正确持久化
|
|
const response = await request.get("/api/products?search=API+Verified+Product")
|
|
const data = await response.json()
|
|
|
|
expect(data.products).toHaveLength(1)
|
|
expect(data.products[0].name).toBe("API Verified Product")
|
|
expect(data.products[0].price).toBe(49.99)
|
|
})
|
|
```
|
|
|
|
### 乐观 UI 更新
|
|
|
|
```typescript
|
|
test("显示乐观更新然后确认", async ({ page }) => {
|
|
// 减慢 API 响应速度以观察乐观更新行为
|
|
await page.route("**/api/products/*", async (route) => {
|
|
if (route.request().method() === "PATCH") {
|
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
await route.continue()
|
|
} else {
|
|
await route.continue()
|
|
}
|
|
})
|
|
|
|
await page.goto("/products")
|
|
const row = page.getByRole("row", { name: /Wireless Keyboard/ })
|
|
|
|
await row.getByRole("button", { name: "Toggle status" }).click()
|
|
|
|
// 验证 UI 立即更新(乐观更新)
|
|
await expect(row.getByText("Active")).toBeVisible()
|
|
|
|
// 验证在 API 响应期间显示加载指示器
|
|
await expect(row.getByRole("progressbar")).toBeVisible()
|
|
|
|
// API 响应后,加载指示器消失
|
|
await expect(row.getByRole("progressbar")).not.toBeVisible({ timeout: 5000 })
|
|
await expect(row.getByText("Active")).toBeVisible()
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 提示
|
|
|
|
1. **始终在变更后验证状态**。在创建、更新或删除之后,断言 UI 反映了更改。不要仅凭成功通知就认为操作已生效。检查列表、表格或卡片是否确实发生了变化。
|
|
|
|
2. **在测试数据中使用唯一标识符**。在测试数据中包含时间戳或随机字符串以防止冲突:`Keyboard-${Date.now()}`。这可以避免因残留数据导致的脆弱测试。
|
|
|
|
3. **测试完整的生命周期**。编写一个创建、读取、更新然后删除资源的完整测试。这能发现独立测试无法捕捉的操作间集成问题。
|
|
|
|
4. **通过 API 清理测试数据**。使用 `test.afterEach` 或 `test.afterAll` 配合 `request` fixture 删除测试期间创建的资源,以保持测试的独立性和可重复性。
|
|
|
|
5. **表格操作优先使用 `getByRole('row', { name: ... })`**。这种方式能定位到可访问的行内容,并且能够抵御列重新排序。避免对特定数据行依赖 `nth()` 索引,因为排序可能会发生变化。
|
|
|
|
---
|
|
|
|
## 相关文档
|
|
|
|
- `recipes/search-and-filter.md` —— 在资源列表中过滤和搜索
|
|
- `recipes/file-upload-download.md` —— 在资源创建时上传文件
|
|
- `patterns/page-objects.md` —— 将 CRUD 表单封装到页面对象中
|
|
- `foundations/selectors.md` —— 选择表格单元格和表单字段的最佳实践
|