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

39 KiB
Raw Permalink Blame History

表单与校验

使用场景:测试表单填写、提交、校验信息、多步骤向导、动态字段和自动补全交互。 前置知识core/locators.mdcore/assertions-and-waiting.md

快速参考

// 文本输入
await page.getByLabel("Name").fill("Jane Doe")

// 下拉选择
await page.getByLabel("Country").selectOption("US")
await page.getByLabel("Country").selectOption({ label: "United States" })

// 复选框和单选按钮
await page.getByLabel("Remember me").check()
await page.getByLabel("Express shipping").click()

// 日期输入
await page.getByLabel("Start date").fill("2025-03-15")

// 清空字段
await page.getByLabel("Name").clear()

// 提交
await page.getByRole("button", { name: "Submit" }).click()

// 验证校验错误
await expect(page.getByText("Email is required")).toBeVisible()

模式

填写基本表单字段

使用场景:测试任何包含标准 HTML 输入(文本、邮箱、密码、数字、文本域、下拉选择、复选框、单选按钮)的表单。 避免场景:无。这是基础模式。

TypeScript

import { test, expect } from "@playwright/test"

test("fill and submit a registration form", async ({ page }) => {
  await page.goto("/register")

  // 文本输入——使用 fill()(会先清空),而非 type()
  await page.getByLabel("First name").fill("Jane")
  await page.getByLabel("Last name").fill("Doe")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Password", { exact: true }).fill("S3cureP@ss!")
  await page.getByLabel("Confirm password").fill("S3cureP@ss!")

  // 文本域
  await page.getByLabel("Bio").fill("Software engineer with 10 years of experience.")

  // 数字输入
  await page.getByLabel("Age").fill("32")

  // 原生 <select>
  await page.getByLabel("Country").selectOption("US")

  // 按可见标签文本选择(当值与显示文本不同时)
  await page.getByLabel("State").selectOption({ label: "California" })

  // 多选
  await page.getByLabel("Interests").selectOption(["coding", "testing", "devops"])

  // 复选框——使用 check() 而非 click()(幂等:如果已勾选则不会取消勾选)
  await page.getByLabel("I agree to the terms").check()
  await expect(page.getByLabel("I agree to the terms")).toBeChecked()

  // 单选按钮
  await page.getByLabel("Monthly billing").check()
  await expect(page.getByLabel("Monthly billing")).toBeChecked()

  // 提交
  await page.getByRole("button", { name: "Create account" }).click()
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("fill and submit a registration form", async ({ page }) => {
  await page.goto("/register")

  await page.getByLabel("First name").fill("Jane")
  await page.getByLabel("Last name").fill("Doe")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Password", { exact: true }).fill("S3cureP@ss!")
  await page.getByLabel("Confirm password").fill("S3cureP@ss!")

  await page.getByLabel("Bio").fill("Software engineer with 10 years of experience.")
  await page.getByLabel("Age").fill("32")
  await page.getByLabel("Country").selectOption("US")
  await page.getByLabel("State").selectOption({ label: "California" })
  await page.getByLabel("Interests").selectOption(["coding", "testing", "devops"])

  await page.getByLabel("I agree to the terms").check()
  await expect(page.getByLabel("I agree to the terms")).toBeChecked()

  await page.getByLabel("Monthly billing").check()
  await expect(page.getByLabel("Monthly billing")).toBeChecked()

  await page.getByRole("button", { name: "Create account" }).click()
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
})

日期和时间输入

使用场景:测试原生 <input type="date"><input type="time"><input type="datetime-local"> 或第三方日期选择器。 避免场景:日期选择器是普通文本字段,没有特殊输入类型。直接使用 fill() 即可。

TypeScript

import { test, expect } from "@playwright/test"

test("fill native date and time inputs", async ({ page }) => {
  await page.goto("/booking")

  // 原生日期输入——使用 ISO 格式 YYYY-MM-DD
  await page.getByLabel("Check-in date").fill("2025-06-15")
  await expect(page.getByLabel("Check-in date")).toHaveValue("2025-06-15")

  // 原生时间输入——使用 HH:MM 格式
  await page.getByLabel("Arrival time").fill("14:30")

  // datetime-local——使用 YYYY-MM-DDTHH:MM 格式
  await page.getByLabel("Event start").fill("2025-06-15T09:00")
})

test("interact with a third-party date picker", async ({ page }) => {
  await page.goto("/booking")

  // 点击打开日期选择器
  await page.getByLabel("Departure date").click()

  // 如有需要,切换月份
  await page.getByRole("button", { name: "Next month" }).click()

  // 选择特定日期
  await page.getByRole("gridcell", { name: "20" }).click()

  // 验证选中日期已出现在输入框中
  await expect(page.getByLabel("Departure date")).toHaveValue(/2025/)
})

JavaScript

const { test, expect } = require("@playwright/test")

test("fill native date and time inputs", async ({ page }) => {
  await page.goto("/booking")

  await page.getByLabel("Check-in date").fill("2025-06-15")
  await expect(page.getByLabel("Check-in date")).toHaveValue("2025-06-15")

  await page.getByLabel("Arrival time").fill("14:30")
  await page.getByLabel("Event start").fill("2025-06-15T09:00")
})

test("interact with a third-party date picker", async ({ page }) => {
  await page.goto("/booking")

  await page.getByLabel("Departure date").click()
  await page.getByRole("button", { name: "Next month" }).click()
  await page.getByRole("gridcell", { name: "20" }).click()

  await expect(page.getByLabel("Departure date")).toHaveValue(/2025/)
})

必填字段校验

使用场景:测试当必填字段为空时,表单是否显示适当的错误信息。 避免场景:你只关心正常流程。校验测试应补充而非取代成功路径测试。

TypeScript

import { test, expect } from "@playwright/test"

test("shows validation errors for empty required fields", async ({ page }) => {
  await page.goto("/contact")

  // 不填写任何内容直接提交
  await page.getByRole("button", { name: "Send message" }).click()

  // 验证所有必填字段错误均已出现
  await expect(page.getByText("Name is required")).toBeVisible()
  await expect(page.getByText("Email is required")).toBeVisible()
  await expect(page.getByText("Message is required")).toBeVisible()

  // 验证表单未提交(仍停留在同一页面)
  await expect(page).toHaveURL(/\/contact/)
})

test("clears validation errors when fields are filled", async ({ page }) => {
  await page.goto("/contact")

  // 触发错误
  await page.getByRole("button", { name: "Send message" }).click()
  await expect(page.getByText("Name is required")).toBeVisible()

  // 填写字段——错误应消失
  await page.getByLabel("Name").fill("Jane Doe")

  // 用 Tab 或点击别处触发失焦校验
  await page.getByLabel("Email").focus()

  await expect(page.getByText("Name is required")).not.toBeVisible()
})

test("native HTML5 validation with required attribute", async ({ page }) => {
  await page.goto("/simple-form")

  await page.getByRole("button", { name: "Submit" }).click()

  // 通过 :invalid 伪类检查原生校验信息
  const emailInput = page.getByLabel("Email")
  const validationMessage = await emailInput.evaluate(
    (el: HTMLInputElement) => el.validationMessage
  )
  expect(validationMessage).toBeTruthy()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("shows validation errors for empty required fields", async ({ page }) => {
  await page.goto("/contact")

  await page.getByRole("button", { name: "Send message" }).click()

  await expect(page.getByText("Name is required")).toBeVisible()
  await expect(page.getByText("Email is required")).toBeVisible()
  await expect(page.getByText("Message is required")).toBeVisible()

  await expect(page).toHaveURL(/\/contact/)
})

test("clears validation errors when fields are filled", async ({ page }) => {
  await page.goto("/contact")

  await page.getByRole("button", { name: "Send message" }).click()
  await expect(page.getByText("Name is required")).toBeVisible()

  await page.getByLabel("Name").fill("Jane Doe")
  await page.getByLabel("Email").focus()

  await expect(page.getByText("Name is required")).not.toBeVisible()
})

test("native HTML5 validation with required attribute", async ({ page }) => {
  await page.goto("/simple-form")

  await page.getByRole("button", { name: "Submit" }).click()

  const emailInput = page.getByLabel("Email")
  const validationMessage = await emailInput.evaluate((el) => el.validationMessage)
  expect(validationMessage).toBeTruthy()
})

格式校验与自定义规则

使用场景:测试邮箱格式、电话号码格式、密码强度以及业务特定的校验规则。 避免场景:校验完全是服务端进行的,没有客户端反馈。应通过 API 测试。

TypeScript

import { test, expect } from "@playwright/test"

test("validates email format", async ({ page }) => {
  await page.goto("/register")

  const emailField = page.getByLabel("Email")

  // 无效格式
  const invalidEmails = ["not-an-email", "missing@", "@no-local.com", "spaces in@email.com"]

  for (const email of invalidEmails) {
    await emailField.fill(email)
    await emailField.blur()
    await expect(page.getByText("Please enter a valid email")).toBeVisible()
  }

  // 有效格式清除错误
  await emailField.fill("valid@example.com")
  await emailField.blur()
  await expect(page.getByText("Please enter a valid email")).not.toBeVisible()
})

test("validates password strength rules", async ({ page }) => {
  await page.goto("/register")

  const passwordField = page.getByLabel("Password", { exact: true })

  // 太短
  await passwordField.fill("Ab1!")
  await passwordField.blur()
  await expect(page.getByText("At least 8 characters")).toBeVisible()

  // 缺少大写字母
  await passwordField.fill("abcdefg1!")
  await passwordField.blur()
  await expect(page.getByText("At least one uppercase letter")).toBeVisible()

  // 强密码——所有检查通过
  await passwordField.fill("Str0ngP@ss!")
  await passwordField.blur()
  await expect(page.getByText(/At least/)).not.toBeVisible()
})

test("validates custom business rule — age range", async ({ page }) => {
  await page.goto("/insurance/quote")

  await page.getByLabel("Age").fill("15")
  await page.getByLabel("Age").blur()
  await expect(page.getByText("Must be 18 or older")).toBeVisible()

  await page.getByLabel("Age").fill("150")
  await page.getByLabel("Age").blur()
  await expect(page.getByText("Please enter a valid age")).toBeVisible()

  await page.getByLabel("Age").fill("30")
  await page.getByLabel("Age").blur()
  await expect(page.getByText(/Must be|valid age/)).not.toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("validates email format", async ({ page }) => {
  await page.goto("/register")

  const emailField = page.getByLabel("Email")

  const invalidEmails = ["not-an-email", "missing@", "@no-local.com", "spaces in@email.com"]

  for (const email of invalidEmails) {
    await emailField.fill(email)
    await emailField.blur()
    await expect(page.getByText("Please enter a valid email")).toBeVisible()
  }

  await emailField.fill("valid@example.com")
  await emailField.blur()
  await expect(page.getByText("Please enter a valid email")).not.toBeVisible()
})

test("validates password strength rules", async ({ page }) => {
  await page.goto("/register")

  const passwordField = page.getByLabel("Password", { exact: true })

  await passwordField.fill("Ab1!")
  await passwordField.blur()
  await expect(page.getByText("At least 8 characters")).toBeVisible()

  await passwordField.fill("abcdefg1!")
  await passwordField.blur()
  await expect(page.getByText("At least one uppercase letter")).toBeVisible()

  await passwordField.fill("Str0ngP@ss!")
  await passwordField.blur()
  await expect(page.getByText(/At least/)).not.toBeVisible()
})

test("validates custom business rule — age range", async ({ page }) => {
  await page.goto("/insurance/quote")

  await page.getByLabel("Age").fill("15")
  await page.getByLabel("Age").blur()
  await expect(page.getByText("Must be 18 or older")).toBeVisible()

  await page.getByLabel("Age").fill("150")
  await page.getByLabel("Age").blur()
  await expect(page.getByText("Please enter a valid age")).toBeVisible()

  await page.getByLabel("Age").fill("30")
  await page.getByLabel("Age").blur()
  await expect(page.getByText(/Must be|valid age/)).not.toBeVisible()
})

多步骤表单与向导

使用场景:表单跨多个页面或步骤,包含上一步/下一步导航和每步校验。 避免场景:表单是单页的。使用基本表单填写模式。

TypeScript

import { test, expect } from "@playwright/test"

test("complete a multi-step checkout wizard", async ({ page }) => {
  await page.goto("/checkout")

  // 步骤 1:收货信息
  await test.step("fill shipping information", async () => {
    await expect(page.getByRole("heading", { name: "Shipping" })).toBeVisible()

    await page.getByLabel("Address").fill("123 Main St")
    await page.getByLabel("City").fill("Portland")
    await page.getByLabel("State").selectOption("OR")
    await page.getByLabel("ZIP code").fill("97201")

    await page.getByRole("button", { name: "Continue" }).click()
  })

  // 步骤 2:支付
  await test.step("fill payment details", async () => {
    await expect(page.getByRole("heading", { name: "Payment" })).toBeVisible()

    await page.getByLabel("Card number").fill("4242424242424242")
    await page.getByLabel("Expiration").fill("12/28")
    await page.getByLabel("CVC").fill("123")

    await page.getByRole("button", { name: "Continue" }).click()
  })

  // 步骤 3:确认
  await test.step("review and confirm order", async () => {
    await expect(page.getByRole("heading", { name: "Review" })).toBeVisible()

    // 验证前面步骤的数据已显示
    await expect(page.getByText("123 Main St")).toBeVisible()
    await expect(page.getByText("ending in 4242")).toBeVisible()

    await page.getByRole("button", { name: "Place order" }).click()
  })

  // 确认
  await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible()
})

test("wizard validates each step before proceeding", async ({ page }) => {
  await page.goto("/checkout")

  // 尝试不填必填字段跳过步骤 1
  await page.getByRole("button", { name: "Continue" }).click()

  // 应停留在步骤 1 并显示校验错误
  await expect(page.getByRole("heading", { name: "Shipping" })).toBeVisible()
  await expect(page.getByText("Address is required")).toBeVisible()
})

test("wizard supports going back without losing data", async ({ page }) => {
  await page.goto("/checkout")

  // 填写步骤 1
  await page.getByLabel("Address").fill("123 Main St")
  await page.getByLabel("City").fill("Portland")
  await page.getByLabel("State").selectOption("OR")
  await page.getByLabel("ZIP code").fill("97201")
  await page.getByRole("button", { name: "Continue" }).click()

  // 从步骤 2 返回
  await page.getByRole("button", { name: "Back" }).click()

  // 验证步骤 1 的数据得以保留
  await expect(page.getByLabel("Address")).toHaveValue("123 Main St")
  await expect(page.getByLabel("City")).toHaveValue("Portland")
})

JavaScript

const { test, expect } = require("@playwright/test")

test("complete a multi-step checkout wizard", async ({ page }) => {
  await page.goto("/checkout")

  await test.step("fill shipping information", async () => {
    await expect(page.getByRole("heading", { name: "Shipping" })).toBeVisible()

    await page.getByLabel("Address").fill("123 Main St")
    await page.getByLabel("City").fill("Portland")
    await page.getByLabel("State").selectOption("OR")
    await page.getByLabel("ZIP code").fill("97201")

    await page.getByRole("button", { name: "Continue" }).click()
  })

  await test.step("fill payment details", async () => {
    await expect(page.getByRole("heading", { name: "Payment" })).toBeVisible()

    await page.getByLabel("Card number").fill("4242424242424242")
    await page.getByLabel("Expiration").fill("12/28")
    await page.getByLabel("CVC").fill("123")

    await page.getByRole("button", { name: "Continue" }).click()
  })

  await test.step("review and confirm order", async () => {
    await expect(page.getByRole("heading", { name: "Review" })).toBeVisible()

    await expect(page.getByText("123 Main St")).toBeVisible()
    await expect(page.getByText("ending in 4242")).toBeVisible()

    await page.getByRole("button", { name: "Place order" }).click()
  })

  await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible()
})

test("wizard validates each step before proceeding", async ({ page }) => {
  await page.goto("/checkout")

  await page.getByRole("button", { name: "Continue" }).click()

  await expect(page.getByRole("heading", { name: "Shipping" })).toBeVisible()
  await expect(page.getByText("Address is required")).toBeVisible()
})

test("wizard supports going back without losing data", async ({ page }) => {
  await page.goto("/checkout")

  await page.getByLabel("Address").fill("123 Main St")
  await page.getByLabel("City").fill("Portland")
  await page.getByLabel("State").selectOption("OR")
  await page.getByLabel("ZIP code").fill("97201")
  await page.getByRole("button", { name: "Continue" }).click()

  await page.getByRole("button", { name: "Back" }).click()

  await expect(page.getByLabel("Address")).toHaveValue("123 Main St")
  await expect(page.getByLabel("City")).toHaveValue("Portland")
})

自动补全与输入提示字段

使用场景:测试搜索字段、地址查找、提及选择器,或任何在用户输入时显示建议的输入框。 避免场景:字段是普通文本输入框,没有建议功能。

TypeScript

import { test, expect } from "@playwright/test"

test("select from auto-complete suggestions", async ({ page }) => {
  await page.goto("/search")

  const searchField = page.getByRole("combobox", { name: "Search" })

  // 以足够慢的速度输入,让建议出现
  // pressSequentially 模拟真实按键(触发 keydown/keyup/input 事件)
  await searchField.pressSequentially("playw", { delay: 100 })

  // 等待建议列表出现
  const suggestions = page.getByRole("listbox")
  await expect(suggestions).toBeVisible()

  // 选择特定建议
  await suggestions.getByRole("option", { name: "Playwright Testing" }).click()

  // 验证选择已填入字段
  await expect(searchField).toHaveValue("Playwright Testing")
})

test("auto-complete with API-driven suggestions", async ({ page }) => {
  await page.goto("/address-form")

  const addressField = page.getByLabel("Address")
  await addressField.pressSequentially("123 Ma", { delay: 50 })

  // 等待 API 驱动的建议列表
  const responsePromise = page.waitForResponse("**/api/address-suggest*")
  await responsePromise

  await page.getByRole("option", { name: /123 Main St/ }).click()

  // 验证依赖字段已自动填充
  await expect(page.getByLabel("City")).toHaveValue("Portland")
  await expect(page.getByLabel("State")).toHaveValue("OR")
  await expect(page.getByLabel("ZIP code")).toHaveValue("97201")
})

test("dismiss auto-complete and use custom value", async ({ page }) => {
  await page.goto("/tags")

  const tagInput = page.getByLabel("Add tag")
  await tagInput.pressSequentially("custom-tag")

  // 按 Escape 关闭建议
  await tagInput.press("Escape")
  await expect(page.getByRole("listbox")).not.toBeVisible()

  // 按 Enter 提交自定义值
  await tagInput.press("Enter")
  await expect(page.getByText("custom-tag")).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("select from auto-complete suggestions", async ({ page }) => {
  await page.goto("/search")

  const searchField = page.getByRole("combobox", { name: "Search" })
  await searchField.pressSequentially("playw", { delay: 100 })

  const suggestions = page.getByRole("listbox")
  await expect(suggestions).toBeVisible()

  await suggestions.getByRole("option", { name: "Playwright Testing" }).click()
  await expect(searchField).toHaveValue("Playwright Testing")
})

test("auto-complete with API-driven suggestions", async ({ page }) => {
  await page.goto("/address-form")

  const addressField = page.getByLabel("Address")
  await addressField.pressSequentially("123 Ma", { delay: 50 })

  const responsePromise = page.waitForResponse("**/api/address-suggest*")
  await responsePromise

  await page.getByRole("option", { name: /123 Main St/ }).click()

  await expect(page.getByLabel("City")).toHaveValue("Portland")
  await expect(page.getByLabel("State")).toHaveValue("OR")
  await expect(page.getByLabel("ZIP code")).toHaveValue("97201")
})

test("dismiss auto-complete and use custom value", async ({ page }) => {
  await page.goto("/tags")

  const tagInput = page.getByLabel("Add tag")
  await tagInput.pressSequentially("custom-tag")

  await tagInput.press("Escape")
  await expect(page.getByRole("listbox")).not.toBeVisible()

  await tagInput.press("Enter")
  await expect(page.getByText("custom-tag")).toBeVisible()
})

动态表单——条件字段

使用场景:表单字段根据其他字段的值显示、隐藏或变化。 避免场景:所有字段始终可见。使用基本表单填写模式。

TypeScript

import { test, expect } from "@playwright/test"

test("conditional fields appear based on selection", async ({ page }) => {
  await page.goto("/insurance/apply")

  // 选择"Business"会显示额外字段
  await page.getByLabel("Account type").selectOption("business")

  // 等待条件字段出现
  await expect(page.getByLabel("Company name")).toBeVisible()
  await expect(page.getByLabel("Tax ID")).toBeVisible()

  await page.getByLabel("Company name").fill("Acme Corp")
  await page.getByLabel("Tax ID").fill("12-3456789")

  // 切回"Personal"会隐藏它们
  await page.getByLabel("Account type").selectOption("personal")
  await expect(page.getByLabel("Company name")).not.toBeVisible()
  await expect(page.getByLabel("Tax ID")).not.toBeVisible()
})

test("checkbox toggles additional section", async ({ page }) => {
  await page.goto("/shipping")

  // "Use different billing address"会显示账单地址字段
  await page.getByLabel("Use different billing address").check()

  const billingSection = page.getByRole("group", { name: "Billing address" })
  await expect(billingSection).toBeVisible()

  await billingSection.getByLabel("Street").fill("456 Oak Ave")
  await billingSection.getByLabel("City").fill("Seattle")

  // 取消勾选会隐藏该区域
  await page.getByLabel("Use different billing address").uncheck()
  await expect(billingSection).not.toBeVisible()
})

test("dependent dropdown chains", async ({ page }) => {
  await page.goto("/location-picker")

  // 选择国家会填充州下拉列表
  await page.getByLabel("Country").selectOption("US")

  // 等待依赖的下拉列表填充
  const stateDropdown = page.getByLabel("State")
  await expect(stateDropdown.getByRole("option")).not.toHaveCount(0)

  await stateDropdown.selectOption("CA")

  // 选择州会填充城市下拉列表
  const cityDropdown = page.getByLabel("City")
  await expect(cityDropdown.getByRole("option")).not.toHaveCount(0)

  await cityDropdown.selectOption({ label: "Los Angeles" })
})

JavaScript

const { test, expect } = require("@playwright/test")

test("conditional fields appear based on selection", async ({ page }) => {
  await page.goto("/insurance/apply")

  await page.getByLabel("Account type").selectOption("business")

  await expect(page.getByLabel("Company name")).toBeVisible()
  await expect(page.getByLabel("Tax ID")).toBeVisible()

  await page.getByLabel("Company name").fill("Acme Corp")
  await page.getByLabel("Tax ID").fill("12-3456789")

  await page.getByLabel("Account type").selectOption("personal")
  await expect(page.getByLabel("Company name")).not.toBeVisible()
  await expect(page.getByLabel("Tax ID")).not.toBeVisible()
})

test("checkbox toggles additional section", async ({ page }) => {
  await page.goto("/shipping")

  await page.getByLabel("Use different billing address").check()

  const billingSection = page.getByRole("group", { name: "Billing address" })
  await expect(billingSection).toBeVisible()

  await billingSection.getByLabel("Street").fill("456 Oak Ave")
  await billingSection.getByLabel("City").fill("Seattle")

  await page.getByLabel("Use different billing address").uncheck()
  await expect(billingSection).not.toBeVisible()
})

test("dependent dropdown chains", async ({ page }) => {
  await page.goto("/location-picker")

  await page.getByLabel("Country").selectOption("US")

  const stateDropdown = page.getByLabel("State")
  await expect(stateDropdown.getByRole("option")).not.toHaveCount(0)

  await stateDropdown.selectOption("CA")

  const cityDropdown = page.getByLabel("City")
  await expect(cityDropdown.getByRole("option")).not.toHaveCount(0)

  await cityDropdown.selectOption({ label: "Los Angeles" })
})

表单提交与响应处理

使用场景:测试表单提交后的行为——成功消息、重定向、服务器返回的错误响应以及提交过程中的加载状态。 避免场景:你只关心客户端校验。将提交测试与校验分开。

TypeScript

import { test, expect } from "@playwright/test"

test("successful form submission shows confirmation", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane Doe")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Hello from Playwright")

  // 在提交时等待 API 响应
  const responsePromise = page.waitForResponse("**/api/contact")
  await page.getByRole("button", { name: "Send message" }).click()
  const response = await responsePromise

  expect(response.status()).toBe(200)
  await expect(page.getByText("Message sent successfully")).toBeVisible()
})

test("form submission shows server-side validation errors", async ({ page }) => {
  await page.goto("/register")

  await page.getByLabel("Email").fill("taken@example.com")
  await page.getByLabel("Password", { exact: true }).fill("ValidP@ss1")
  await page.getByRole("button", { name: "Register" }).click()

  // 服务器返回 409——邮箱已被注册
  await expect(page.getByText("An account with this email already exists")).toBeVisible()
})

test("form shows loading state during submission", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")

  await page.getByRole("button", { name: "Send message" }).click()

  // 提交过程中按钮应禁用
  await expect(page.getByRole("button", { name: /Sending/ })).toBeDisabled()

  // 完成后,按钮恢复为可用状态
  await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled()
})

test("form redirects after successful submission", async ({ page }) => {
  await page.goto("/login")

  await page.getByLabel("Email").fill("user@example.com")
  await page.getByLabel("Password").fill("password123")
  await page.getByRole("button", { name: "Sign in" }).click()

  // 验证重定向
  await page.waitForURL("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("successful form submission shows confirmation", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane Doe")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Hello from Playwright")

  const responsePromise = page.waitForResponse("**/api/contact")
  await page.getByRole("button", { name: "Send message" }).click()
  const response = await responsePromise

  expect(response.status()).toBe(200)
  await expect(page.getByText("Message sent successfully")).toBeVisible()
})

test("form submission shows server-side validation errors", async ({ page }) => {
  await page.goto("/register")

  await page.getByLabel("Email").fill("taken@example.com")
  await page.getByLabel("Password", { exact: true }).fill("ValidP@ss1")
  await page.getByRole("button", { name: "Register" }).click()

  await expect(page.getByText("An account with this email already exists")).toBeVisible()
})

test("form shows loading state during submission", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")

  await page.getByRole("button", { name: "Send message" }).click()

  await expect(page.getByRole("button", { name: /Sending/ })).toBeDisabled()
  await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled()
})

test("form redirects after successful submission", async ({ page }) => {
  await page.goto("/login")

  await page.getByLabel("Email").fill("user@example.com")
  await page.getByLabel("Password").fill("password123")
  await page.getByRole("button", { name: "Sign in" }).click()

  await page.waitForURL("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})

表单重置测试

使用场景:测试"清空表单"或"重置"功能,验证字段是否恢复为默认值。 避免场景:表单没有重置机制。

TypeScript

import { test, expect } from "@playwright/test"

test("reset button clears all fields to defaults", async ({ page }) => {
  await page.goto("/settings")

  // 修改字段默认值
  await page.getByLabel("Display name").fill("New Name")
  await page.getByLabel("Theme").selectOption("dark")
  await page.getByLabel("Notifications").uncheck()

  // 点击重置
  await page.getByRole("button", { name: "Reset" }).click()

  // 验证字段已恢复为原始值
  await expect(page.getByLabel("Display name")).toHaveValue("")
  await expect(page.getByLabel("Theme")).toHaveValue("light")
  await expect(page.getByLabel("Notifications")).toBeChecked()
})

test("confirmation dialog before resetting a dirty form", async ({ page }) => {
  await page.goto("/editor")

  await page.getByLabel("Title").fill("Draft post")

  // 重置触发确认对话框
  page.on("dialog", (dialog) => dialog.accept())
  await page.getByRole("button", { name: "Discard changes" }).click()

  await expect(page.getByLabel("Title")).toHaveValue("")
})

JavaScript

const { test, expect } = require("@playwright/test")

test("reset button clears all fields to defaults", async ({ page }) => {
  await page.goto("/settings")

  await page.getByLabel("Display name").fill("New Name")
  await page.getByLabel("Theme").selectOption("dark")
  await page.getByLabel("Notifications").uncheck()

  await page.getByRole("button", { name: "Reset" }).click()

  await expect(page.getByLabel("Display name")).toHaveValue("")
  await expect(page.getByLabel("Theme")).toHaveValue("light")
  await expect(page.getByLabel("Notifications")).toBeChecked()
})

test("confirmation dialog before resetting a dirty form", async ({ page }) => {
  await page.goto("/editor")

  await page.getByLabel("Title").fill("Draft post")

  page.on("dialog", (dialog) => dialog.accept())
  await page.getByRole("button", { name: "Discard changes" }).click()

  await expect(page.getByLabel("Title")).toHaveValue("")
})

决策指南

场景 方法 关键 API
标准文本输入 fill()(先清空再输入) page.getByLabel('Name').fill('Jane')
需要按键事件(自动补全) pressSequentially() 带延迟 locator.pressSequentially('text', { delay: 100 })
原生 <select> 下拉 selectOption() 按值或标签 locator.selectOption('US'){ label: 'United States' }
自定义下拉(ARIA listbox 点击触发,然后选择 option 角色 getByRole('option', { name: '...' }).click()
复选框 check() / uncheck()(幂等) locator.check()——即使已勾选也可安全调用
单选按钮 在目标单选按钮上调用 check() page.getByLabel('Express').check()
日期输入(原生) fill() 使用 ISO 格式 locator.fill('2025-03-15')
日期选择器(第三方) 点击打开,导航,选择日期 getByRole('gridcell', { name: '15' }).click()
校验错误 提交,然后断言错误文本 expect(page.getByText('Required')).toBeVisible()
多步骤向导 每步使用 test.step(),断言标题 await test.step('Step 1', async () => { ... })
条件/动态字段 更改触发字段,断言新字段可见性 expect(locator).toBeVisible() / .not.toBeVisible()
表单提交 waitForResponse + 点击提交 在点击前注册响应监听器
自动补全 pressSequentially(),等待 listbox,选择选项 getByRole('option', { name }).click()
表单重置 点击重置,断言默认值 expect(locator).toHaveValue('')

反模式

不要这样做 问题 应改为
await page.getByLabel('Name').type('Jane') type() 会追加到已有内容后面,不会先清空 await page.getByLabel('Name').fill('Jane')
await page.getByLabel('Agree').click() click() 会切换状态——如果已勾选,则会取消勾选 await page.getByLabel('Agree').check()
await page.fill('#email', 'test@test.com') CSS 选择器很脆弱 await page.getByLabel('Email').fill('test@test.com')
await page.selectOption('select', 'US') 未指定标签 默认选中页面上第一个 <select>,有歧义 await page.getByLabel('Country').selectOption('US')
在一个测试中测试所有无效输入 测试变得庞大、缓慢且难以调试 每个校验规则或相关规则分组各用一个测试
expect(await input.inputValue()).toBe('Jane') 只解析一次——无重试。存在竞态条件。 await expect(input).toHaveValue('Jane')
使用 page.evaluate() 填写字段 绕过事件处理器(不会触发 inputchange 事件) 使用 fill()pressSequentially()
填写前未等待条件字段 fill() 在隐藏/已分离的元素上会失败 await expect(field).toBeVisible()
选择下拉后硬编码等待 waitForTimeout(500) 不稳定且慢 等待依赖元素出现
跳过服务端校验测试 客户端校验可以被绕过 同时测试客户端 UX 和服务器响应

故障排除

fill() 无效果,或清空了但未输入

原因:输入字段使用的是 contenteditable div(富文本编辑器),而非真正的 <input><textarea>

// 检查是否为 contenteditable
const isContentEditable = await page
  .getByTestId("editor")
  .evaluate((el) => el.getAttribute("contenteditable"))

// 对于 contenteditable,使用 pressSequentially 或 type
if (isContentEditable) {
  await page.getByTestId("editor").click()
  await page.getByTestId("editor").pressSequentially("Hello world")
}

日期选择器不接受 fill() 的值

原因:第三方日期选择器通常在隐藏输入之上渲染自定义 UI。fill() 设置了隐藏输入的值,但 UI 不会更新。

// 改为与日期选择器 UI 交互
await page.getByLabel("Date").click() // 打开选择器
await page.getByRole("button", { name: "Next month" }).click()
await page.getByRole("gridcell", { name: "15" }).click()

// 或者,如果库在 change 事件时读取输入值:
await page.getByLabel("Date").fill("2025-06-15")
await page.getByLabel("Date").dispatchEvent("change")

selectOption() 抛出"not a element"错误 原因:下拉列表是自定义组件(ARIA listbox),而非原生 <select>。 // 对于自定义下拉:点击打开,然后从 listbox 中选择 await page.getByRole("combobox", { name: "Country" }).click() await page.getByRole("option", { name: "United States" }).click() fill() 并提交后校验错误未出现 原因:校验在 blur(焦点离开字段)时触发,但 fill() 不会自动触发 blur。 // 显式触发 blur await page.getByLabel("Email").fill("invalid") await page.getByLabel("Email").blur() await expect(page.getByText("Please enter a valid email")).toBeVisible() // 或者将焦点移到下一个字段 await page.getByLabel("Password").focus() 相关文档 core/locators.md——定位表单元素的定位器策略 core/assertions-and-waiting.md——验证表单状态的断言模式 core/file-operations.md——表单中的文件上传字段 core/error-and-edge-cases.md——测试表单错误状态和边界情况 core/accessibility.md——确保表单可访问(标签关联、ARIA 属性) core/network-mocking.md——模拟表单提交 API 响应