39 KiB
Forms and Validation
When to use: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions. Prerequisites: core/locators.md, core/assertions-and-waiting.md
Quick Reference
// Text input
await page.getByLabel("Name").fill("Jane Doe")
// Select dropdown
await page.getByLabel("Country").selectOption("US")
await page.getByLabel("Country").selectOption({ label: "United States" })
// Checkbox and radio
await page.getByLabel("Remember me").check()
await page.getByLabel("Express shipping").click()
// Date input
await page.getByLabel("Start date").fill("2025-03-15")
// Clear a field
await page.getByLabel("Name").clear()
// Submit
await page.getByRole("button", { name: "Submit" }).click()
// Verify validation error
await expect(page.getByText("Email is required")).toBeVisible()
Patterns
Filling Basic Form Fields
Use when: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio. Avoid when: Never. This is the foundation pattern.
TypeScript
import { test, expect } from "@playwright/test"
test("fill and submit a registration form", async ({ page }) => {
await page.goto("/register")
// Text inputs — use fill() which clears first, not 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!")
// Textarea
await page.getByLabel("Bio").fill("Software engineer with 10 years of experience.")
// Number input
await page.getByLabel("Age").fill("32")
// Native <select>
await page.getByLabel("Country").selectOption("US")
// Select by visible label text (when value differs from display text)
await page.getByLabel("State").selectOption({ label: "California" })
// Multi-select
await page.getByLabel("Interests").selectOption(["coding", "testing", "devops"])
// Checkbox — use check() not click() (idempotent: won't uncheck if already checked)
await page.getByLabel("I agree to the terms").check()
await expect(page.getByLabel("I agree to the terms")).toBeChecked()
// Radio button
await page.getByLabel("Monthly billing").check()
await expect(page.getByLabel("Monthly billing")).toBeChecked()
// Submit
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()
})
Date and Time Inputs
Use when: Testing native <input type="date">, <input type="time">, <input type="datetime-local">, or third-party date pickers.
Avoid when: The date picker is a simple text field with no special input type. Just use fill().
TypeScript
import { test, expect } from "@playwright/test"
test("fill native date and time inputs", async ({ page }) => {
await page.goto("/booking")
// Native date input — use ISO format YYYY-MM-DD
await page.getByLabel("Check-in date").fill("2025-06-15")
await expect(page.getByLabel("Check-in date")).toHaveValue("2025-06-15")
// Native time input — use HH:MM format
await page.getByLabel("Arrival time").fill("14:30")
// datetime-local — use YYYY-MM-DDTHH:MM format
await page.getByLabel("Event start").fill("2025-06-15T09:00")
})
test("interact with a third-party date picker", async ({ page }) => {
await page.goto("/booking")
// Click to open the date picker
await page.getByLabel("Departure date").click()
// Navigate months if needed
await page.getByRole("button", { name: "Next month" }).click()
// Select a specific day
await page.getByRole("gridcell", { name: "20" }).click()
// Verify the selected date appears in the input
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/)
})
Required Field Validation
Use when: Testing that the form shows appropriate error messages when required fields are empty. Avoid when: You only care about the happy path. Validation tests should complement, not replace, success path tests.
TypeScript
import { test, expect } from "@playwright/test"
test("shows validation errors for empty required fields", async ({ page }) => {
await page.goto("/contact")
// Submit without filling anything
await page.getByRole("button", { name: "Send message" }).click()
// Verify all required field errors appear
await expect(page.getByText("Name is required")).toBeVisible()
await expect(page.getByText("Email is required")).toBeVisible()
await expect(page.getByText("Message is required")).toBeVisible()
// Verify the form was NOT submitted (still on the same page)
await expect(page).toHaveURL(/\/contact/)
})
test("clears validation errors when fields are filled", async ({ page }) => {
await page.goto("/contact")
// Trigger errors
await page.getByRole("button", { name: "Send message" }).click()
await expect(page.getByText("Name is required")).toBeVisible()
// Fill the field — error should disappear
await page.getByLabel("Name").fill("Jane Doe")
// Use tab or click away to trigger blur validation
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()
// Check for native validation message via the :invalid pseudo-class
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()
})
Format Validation and Custom Rules
Use when: Testing email format, phone number format, password strength, and business-specific validation rules. Avoid when: The validation is purely server-side with no client-side feedback. Test via API instead.
TypeScript
import { test, expect } from "@playwright/test"
test("validates email format", async ({ page }) => {
await page.goto("/register")
const emailField = page.getByLabel("Email")
// Invalid formats
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()
}
// Valid format clears the error
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 })
// Too short
await passwordField.fill("Ab1!")
await passwordField.blur()
await expect(page.getByText("At least 8 characters")).toBeVisible()
// Missing uppercase
await passwordField.fill("abcdefg1!")
await passwordField.blur()
await expect(page.getByText("At least one uppercase letter")).toBeVisible()
// Strong password — all checks pass
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()
})
Multi-Step Forms and Wizards
Use when: The form spans multiple pages or steps, with next/previous navigation and per-step validation. Avoid when: The form is a single page. Use the basic form filling pattern.
TypeScript
import { test, expect } from "@playwright/test"
test("complete a multi-step checkout wizard", async ({ page }) => {
await page.goto("/checkout")
// Step 1: Shipping
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()
})
// Step 2: Payment
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()
})
// Step 3: Review
await test.step("review and confirm order", async () => {
await expect(page.getByRole("heading", { name: "Review" })).toBeVisible()
// Verify data from previous steps is shown
await expect(page.getByText("123 Main St")).toBeVisible()
await expect(page.getByText("ending in 4242")).toBeVisible()
await page.getByRole("button", { name: "Place order" }).click()
})
// Confirmation
await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible()
})
test("wizard validates each step before proceeding", async ({ page }) => {
await page.goto("/checkout")
// Try to skip step 1 without filling required fields
await page.getByRole("button", { name: "Continue" }).click()
// Should stay on step 1 with validation errors
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")
// Fill step 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()
// Go back from step 2
await page.getByRole("button", { name: "Back" }).click()
// Verify step 1 data is preserved
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")
})
Auto-Complete and Typeahead Fields
Use when: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types. Avoid when: The field is a plain text input with no suggestions.
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" })
// Type slowly enough for suggestions to appear
// pressSequentially simulates real keystrokes (triggers keydown/keyup/input events)
await searchField.pressSequentially("playw", { delay: 100 })
// Wait for the suggestion list to appear
const suggestions = page.getByRole("listbox")
await expect(suggestions).toBeVisible()
// Select a specific suggestion
await suggestions.getByRole("option", { name: "Playwright Testing" }).click()
// Verify the selection populated the field
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 })
// Wait for the API-driven suggestion list
const responsePromise = page.waitForResponse("**/api/address-suggest*")
await responsePromise
await page.getByRole("option", { name: /123 Main St/ }).click()
// Verify dependent fields were auto-populated
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")
// Dismiss suggestions with Escape
await tagInput.press("Escape")
await expect(page.getByRole("listbox")).not.toBeVisible()
// Submit custom value with 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()
})
Dynamic Forms — Conditional Fields
Use when: Form fields appear, disappear, or change based on the value of other fields. Avoid when: All fields are always visible. Use the basic form filling pattern.
TypeScript
import { test, expect } from "@playwright/test"
test("conditional fields appear based on selection", async ({ page }) => {
await page.goto("/insurance/apply")
// Selecting "Business" shows additional fields
await page.getByLabel("Account type").selectOption("business")
// Wait for conditional fields to appear
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")
// Switching back to "Personal" hides them
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")
// "Different billing address" reveals billing fields
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")
// Unchecking hides the section
await page.getByLabel("Use different billing address").uncheck()
await expect(billingSection).not.toBeVisible()
})
test("dependent dropdown chains", async ({ page }) => {
await page.goto("/location-picker")
// Country selection populates the state dropdown
await page.getByLabel("Country").selectOption("US")
// Wait for the dependent dropdown to be populated
const stateDropdown = page.getByLabel("State")
await expect(stateDropdown.getByRole("option")).not.toHaveCount(0)
await stateDropdown.selectOption("CA")
// State selection populates the city dropdown
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" })
})
Form Submission and Response Handling
Use when: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission. Avoid when: You only care about client-side validation. Test submission separately from validation.
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")
// Wait for the API response during submission
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()
// Server responds with a 409 — email already taken
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()
// Button should be disabled during submission
await expect(page.getByRole("button", { name: /Sending/ })).toBeDisabled()
// After completion, button returns to normal
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()
// Verify redirect
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()
})
Form Reset Testing
Use when: Testing "clear form" or "reset" functionality, verifying that fields return to their default values. Avoid when: The form has no reset mechanism.
TypeScript
import { test, expect } from "@playwright/test"
test("reset button clears all fields to defaults", async ({ page }) => {
await page.goto("/settings")
// Change fields from defaults
await page.getByLabel("Display name").fill("New Name")
await page.getByLabel("Theme").selectOption("dark")
await page.getByLabel("Notifications").uncheck()
// Click reset
await page.getByRole("button", { name: "Reset" }).click()
// Verify fields returned to original values
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")
// Reset triggers a confirmation dialog
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("")
})
Decision Guide
| Scenario | Approach | Key API |
|---|---|---|
| Standard text input | fill() (clears, then types) |
page.getByLabel('Name').fill('Jane') |
| Need keystroke events (autocomplete) | pressSequentially() with delay |
locator.pressSequentially('text', { delay: 100 }) |
Native <select> dropdown |
selectOption() by value or label |
locator.selectOption('US') or { label: 'United States' } |
| Custom dropdown (ARIA listbox) | Click trigger, then select option role | getByRole('option', { name: '...' }).click() |
| Checkbox | check() / uncheck() (idempotent) |
locator.check() — safe to call even if already checked |
| Radio button | check() on the target radio |
page.getByLabel('Express').check() |
| Date input (native) | fill() with ISO format |
locator.fill('2025-03-15') |
| Date picker (third-party) | Click to open, navigate, select day | getByRole('gridcell', { name: '15' }).click() |
| Validation errors | Submit, then assert error text | expect(page.getByText('Required')).toBeVisible() |
| Multi-step wizard | test.step() per step, assert heading |
await test.step('Step 1', async () => { ... }) |
| Conditional/dynamic fields | Change trigger field, assert new field visibility | expect(locator).toBeVisible() / .not.toBeVisible() |
| Form submission | waitForResponse + click submit |
Register response listener before click |
| Auto-complete | pressSequentially(), wait for listbox, select option |
getByRole('option', { name }).click() |
| Form reset | Click reset, assert default values | expect(locator).toHaveValue('') |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
await page.getByLabel('Name').type('Jane') |
type() appends to existing content; does not clear first |
await page.getByLabel('Name').fill('Jane') |
await page.getByLabel('Agree').click() |
click() toggles — if already checked, it unchecks |
await page.getByLabel('Agree').check() |
await page.fill('#email', 'test@test.com') |
CSS selector is fragile | await page.getByLabel('Email').fill('test@test.com') |
await page.selectOption('select', 'US') without label |
Targets first <select> on page; ambiguous |
await page.getByLabel('Country').selectOption('US') |
| Testing every invalid input in one test | Test becomes huge, slow, and hard to debug | One test per validation rule or group related rules |
expect(await input.inputValue()).toBe('Jane') |
Resolves once — no retry. Race condition. | await expect(input).toHaveValue('Jane') |
Filling fields with page.evaluate() |
Bypasses event handlers (no input, change events fire) |
Use fill() or pressSequentially() |
| Not waiting for conditional fields before filling | fill() fails on hidden/detached elements |
await expect(field).toBeVisible() first |
| Hardcoding wait after selecting a dropdown | waitForTimeout(500) is flaky and slow |
Wait for the dependent element to appear |
| Skipping server-side validation tests | Client-side validation can be bypassed | Test both client-side UX and server response |
Troubleshooting
fill() does nothing or clears but doesn't type
Cause: The input field uses a contenteditable div (rich text editors), not a real <input> or <textarea>.
// Check if it is contenteditable
const isContentEditable = await page
.getByTestId("editor")
.evaluate((el) => el.getAttribute("contenteditable"))
// For contenteditable, use pressSequentially or type
if (isContentEditable) {
await page.getByTestId("editor").click()
await page.getByTestId("editor").pressSequentially("Hello world")
}
Date picker does not accept fill() value
Cause: Third-party date pickers often render custom UI over a hidden input. fill() sets the hidden input but the UI does not update.
// Interact with the date picker UI instead
await page.getByLabel("Date").click() // Opens the picker
await page.getByRole("button", { name: "Next month" }).click()
await page.getByRole("gridcell", { name: "15" }).click()
// Alternatively, if the library reads from the input on change:
await page.getByLabel("Date").fill("2025-06-15")
await page.getByLabel("Date").dispatchEvent("change")
selectOption() throws "not a element"
Cause: The dropdown is a custom component (ARIA listbox), not a native <select>.
// For custom dropdowns: click to open, then select from the listbox
await page.getByRole("combobox", { name: "Country" }).click()
await page.getByRole("option", { name: "United States" }).click()
Validation errors do not appear after fill() and submit
Cause: The validation triggers on blur (focus leaving the field), but fill() does not trigger blur automatically.
// Trigger blur explicitly
await page.getByLabel("Email").fill("invalid")
await page.getByLabel("Email").blur()
await expect(page.getByText("Please enter a valid email")).toBeVisible()
// Or move focus to the next field
await page.getByLabel("Password").focus()
Related
- core/locators.md -- locator strategies for finding form elements
- core/assertions-and-waiting.md -- assertion patterns for verifying form state
- core/file-operations.md -- file upload fields in forms
- core/error-and-edge-cases.md -- testing form error states and edge cases
- core/accessibility.md -- ensuring forms are accessible (label associations, ARIA attributes)
- core/network-mocking.md -- mocking form submission API responses