Files
2026-07-13 21:36:47 +08:00

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()
})

提示

  1. 始终在变更后验证状态。在创建、更新或删除之后,断言 UI 反映了更改。不要仅凭成功通知就认为操作已生效。检查列表、表格或卡片是否确实发生了变化。

  2. 在测试数据中使用唯一标识符。在测试数据中包含时间戳或随机字符串以防止冲突:Keyboard-${Date.now()}。这可以避免因残留数据导致的脆弱测试。

  3. 测试完整的生命周期。编写一个创建、读取、更新然后删除资源的完整测试。这能发现独立测试无法捕捉的操作间集成问题。

  4. 通过 API 清理测试数据。使用 test.afterEachtest.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 —— 选择表格单元格和表单字段的最佳实践