1073 lines
39 KiB
Markdown
1073 lines
39 KiB
Markdown
# 表单与校验
|
||
|
||
> **使用场景**:测试表单填写、提交、校验信息、多步骤向导、动态字段和自动补全交互。
|
||
> **前置知识**:[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 响应
|