30 KiB
30 KiB
CRUD 测试配方
使用场景:你需要测试任意资源的创建、读取、更新或删除操作——包括表单、表格、列表、卡片、内联编辑或批量操作。
配方 1:创建资源(填写表单、提交、验证)
完整示例
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
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
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
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
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
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
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
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
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
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
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
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()
}
})
变体
通过多步骤向导创建
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 验证
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 更新
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()
})
提示
-
始终在变更后验证状态。在创建、更新或删除之后,断言 UI 反映了更改。不要仅凭成功通知就认为操作已生效。检查列表、表格或卡片是否确实发生了变化。
-
在测试数据中使用唯一标识符。在测试数据中包含时间戳或随机字符串以防止冲突:
Keyboard-${Date.now()}。这可以避免因残留数据导致的脆弱测试。 -
测试完整的生命周期。编写一个创建、读取、更新然后删除资源的完整测试。这能发现独立测试无法捕捉的操作间集成问题。
-
通过 API 清理测试数据。使用
test.afterEach或test.afterAll配合requestfixture 删除测试期间创建的资源,以保持测试的独立性和可重复性。 -
表格操作优先使用
getByRole('row', { name: ... })。这种方式能定位到可访问的行内容,并且能够抵御列重新排序。避免对特定数据行依赖nth()索引,因为排序可能会发生变化。
相关文档
recipes/search-and-filter.md—— 在资源列表中过滤和搜索recipes/file-upload-download.md—— 在资源创建时上传文件patterns/page-objects.md—— 将 CRUD 表单封装到页面对象中foundations/selectors.md—— 选择表格单元格和表单字段的最佳实践