# 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` —— 选择表格单元格和表单字段的最佳实践