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

1073 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 表单与校验
> **使用场景**:测试表单填写、提交、校验信息、多步骤向导、动态字段和自动补全交互。
> **前置知识**[core/locators.md](locators.md)、[core/assertions-and-waiting.md](assertions-and-waiting.md)
## 快速参考
```typescript
// 文本输入
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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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()` 填写字段 | 绕过事件处理器(不会触发 `input``change` 事件) | 使用 `fill()``pressSequentially()` |
| 填写前未等待条件字段 | `fill()` 在隐藏/已分离的元素上会失败 | 先 `await expect(field).toBeVisible()` |
| 选择下拉后硬编码等待 | `waitForTimeout(500)` 不稳定且慢 | 等待依赖元素出现 |
| 跳过服务端校验测试 | 客户端校验可以被绕过 | 同时测试客户端 UX 和服务器响应 |
## 故障排除
### `fill()` 无效果,或清空了但未输入
**原因**:输入字段使用的是 contenteditable div(富文本编辑器),而非真正的 `<input>``<textarea>`
```typescript
// 检查是否为 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 不会更新。
```typescript
// 改为与日期选择器 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 <select> element"错误
**原因**:下拉列表是自定义组件(ARIA listbox),而非原生 `<select>`
```typescript
// 对于自定义下拉:点击打开,然后从 listbox 中选择
await page.getByRole("combobox", { name: "Country" }).click()
await page.getByRole("option", { name: "United States" }).click()
```
### `fill()` 并提交后校验错误未出现
**原因**:校验在 `blur`(焦点离开字段)时触发,但 `fill()` 不会自动触发 blur。
```typescript
// 显式触发 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](locators.md)——定位表单元素的定位器策略
- [core/assertions-and-waiting.md](assertions-and-waiting.md)——验证表单状态的断言模式
- [core/file-operations.md](file-operations.md)——表单中的文件上传字段
- [core/error-and-edge-cases.md](error-and-edge-cases.md)——测试表单错误状态和边界情况
- [core/accessibility.md](accessibility.md)——确保表单可访问(标签关联、ARIA 属性)
- [core/network-mocking.md](network-mocking.md)——模拟表单提交 API 响应