Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
testing-patterns/forms-validation.md
1# Form Testing Patterns23## Table of Contents451. [Quick Reference](#quick-reference)62. [Patterns](#patterns)73. [Decision Guide](#decision-guide)84. [Anti-Patterns](#anti-patterns)95. [Troubleshooting](#troubleshooting)1011> **When to use**: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.1213## Quick Reference1415```typescript16// Text input17await page.getByLabel("Username").fill("john_doe");1819// Select dropdown20await page.getByLabel("Region").selectOption("EU");21await page.getByLabel("Region").selectOption({ label: "Europe" });2223// Checkbox and radio24await page.getByLabel("Subscribe").check();25await page.getByLabel("Priority shipping").click();2627// Date input28await page.getByLabel("Departure").fill("2025-08-20");2930// Clear a field31await page.getByLabel("Username").clear();3233// Submit34await page.getByRole("button", { name: "Register" }).click();3536// Verify validation error37await expect(page.getByText("Username is required")).toBeVisible();38```3940## Patterns4142### Auto-Complete and Typeahead Fields4344**Use when**: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.4546```typescript47test("select from typeahead suggestions", async ({ page }) => {48await page.goto("/products");4950const searchBox = page.getByRole("combobox", { name: "Find product" });51await searchBox.pressSequentially("lapt", { delay: 100 });5253const suggestionList = page.getByRole("listbox");54await expect(suggestionList).toBeVisible();5556await suggestionList.getByRole("option", { name: "Laptop Pro" }).click();57await expect(searchBox).toHaveValue("Laptop Pro");58});5960test("typeahead with API-driven suggestions", async ({ page }) => {61await page.goto("/shipping");6263const streetField = page.getByLabel("Street");64const responsePromise = page.waitForResponse("**/api/address-lookup*");65await streetField.pressSequentially("456 Elm", { delay: 50 });6667await responsePromise;6869await page.getByRole("option", { name: /456 Elm St/ }).click();7071await expect(page.getByLabel("Town")).toHaveValue("Austin");72await expect(page.getByLabel("State")).toHaveValue("TX");73await expect(page.getByLabel("Postal code")).toHaveValue("78701");74});7576test("dismiss suggestions and enter custom value", async ({ page }) => {77await page.goto("/labels");7879const labelInput = page.getByLabel("New label");80await labelInput.pressSequentially("my-label");8182await labelInput.press("Escape");83await expect(page.getByRole("listbox")).not.toBeVisible();8485await labelInput.press("Enter");86await expect(page.getByText("my-label")).toBeVisible();87});88```8990### Dynamic Forms — Conditional Fields9192**Use when**: Form fields appear, disappear, or change based on the value of other fields.9394```typescript95test("conditional fields appear based on selection", async ({ page }) => {96await page.goto("/loan/apply");9798await page.getByLabel("Applicant type").selectOption("corporate");99100await expect(page.getByLabel("Business name")).toBeVisible();101await expect(page.getByLabel("EIN")).toBeVisible();102103await page.getByLabel("Business name").fill("TechCorp Inc");104await page.getByLabel("EIN").fill("98-7654321");105106await page.getByLabel("Applicant type").selectOption("individual");107await expect(page.getByLabel("Business name")).not.toBeVisible();108await expect(page.getByLabel("EIN")).not.toBeVisible();109});110111test("checkbox toggles additional section", async ({ page }) => {112await page.goto("/delivery");113114await page.getByLabel("Separate invoice address").check();115116const invoiceSection = page.getByRole("group", { name: "Invoice address" });117await expect(invoiceSection).toBeVisible();118119await invoiceSection.getByLabel("Address").fill("789 Pine Rd");120await invoiceSection.getByLabel("City").fill("Denver");121122await page.getByLabel("Separate invoice address").uncheck();123await expect(invoiceSection).not.toBeVisible();124});125126test("dependent dropdown chains", async ({ page }) => {127await page.goto("/region-selector");128129await page.getByLabel("Country").selectOption("CA");130131const provinceDropdown = page.getByLabel("Province");132await expect(provinceDropdown.getByRole("option")).not.toHaveCount(0);133134await provinceDropdown.selectOption("ON");135136const cityDropdown = page.getByLabel("City");137await expect(cityDropdown.getByRole("option")).not.toHaveCount(0);138139await cityDropdown.selectOption({ label: "Toronto" });140});141```142143### Multi-Step Forms and Wizards144145**Use when**: The form spans multiple pages or steps, with next/previous navigation and per-step validation.146147```typescript148test("complete a multi-step booking wizard", async ({ page }) => {149await page.goto("/booking");150151await test.step("enter guest information", async () => {152await expect(153page.getByRole("heading", { name: "Guest Info" }),154).toBeVisible();155156await page.getByLabel("Full name").fill("Alice Smith");157await page.getByLabel("Email").fill("[email protected]");158await page.getByLabel("Phone").fill("555-1234");159160await page.getByRole("button", { name: "Next" }).click();161});162163await test.step("select room options", async () => {164await expect(165page.getByRole("heading", { name: "Room Selection" }),166).toBeVisible();167168await page.getByLabel("Room type").selectOption("suite");169await page.getByLabel("Check-in").fill("2025-09-01");170await page.getByLabel("Check-out").fill("2025-09-05");171172await page.getByRole("button", { name: "Next" }).click();173});174175await test.step("confirm booking", async () => {176await expect(177page.getByRole("heading", { name: "Confirmation" }),178).toBeVisible();179180await expect(page.getByText("Alice Smith")).toBeVisible();181await expect(page.getByText("suite")).toBeVisible();182183await page.getByRole("button", { name: "Confirm booking" }).click();184});185186await expect(187page.getByRole("heading", { name: "Booking complete" }),188).toBeVisible();189});190191test("wizard validates each step before proceeding", async ({ page }) => {192await page.goto("/booking");193194await page.getByRole("button", { name: "Next" }).click();195196await expect(page.getByRole("heading", { name: "Guest Info" })).toBeVisible();197await expect(page.getByText("Full name is required")).toBeVisible();198});199200test("wizard supports going back without losing data", async ({ page }) => {201await page.goto("/booking");202203await page.getByLabel("Full name").fill("Alice Smith");204await page.getByLabel("Email").fill("[email protected]");205await page.getByLabel("Phone").fill("555-1234");206await page.getByRole("button", { name: "Next" }).click();207208await page.getByRole("button", { name: "Previous" }).click();209210await expect(page.getByLabel("Full name")).toHaveValue("Alice Smith");211await expect(page.getByLabel("Email")).toHaveValue("[email protected]");212});213```214215### Form Submission and Response Handling216217**Use when**: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission.218219```typescript220test("successful form submission shows confirmation", async ({ page }) => {221await page.goto("/feedback");222223await page.getByLabel("Subject").fill("Feature request");224await page.getByLabel("Email").fill("[email protected]");225await page.getByLabel("Details").fill("Please add dark mode");226227const responsePromise = page.waitForResponse("**/api/feedback");228await page.getByRole("button", { name: "Submit feedback" }).click();229const response = await responsePromise;230231expect(response.status()).toBe(200);232await expect(page.getByText("Feedback received")).toBeVisible();233});234235test("form submission shows server-side validation errors", async ({236page,237}) => {238await page.goto("/signup");239240await page.getByLabel("Email").fill("[email protected]");241await page.getByLabel("Password", { exact: true }).fill("Secure1@pass");242await page.getByRole("button", { name: "Sign up" }).click();243244await expect(245page.getByText("Email address already registered"),246).toBeVisible();247});248249test("form shows loading state during submission", async ({ page }) => {250await page.goto("/feedback");251252await page.getByLabel("Subject").fill("Bug report");253await page.getByLabel("Email").fill("[email protected]");254await page.getByLabel("Details").fill("Found an issue");255256const submit = page.getByRole("button", {257name: /Submit feedback|Submitting/,258});259await submit.click();260261await expect(submit).toHaveText(/Submitting/);262await expect(submit).toBeDisabled();263264await expect(submit).toHaveText("Submit feedback");265await expect(submit).toBeEnabled();266});267268test("form redirects after successful submission", async ({ page }) => {269await page.goto("/auth/login");270271await page.getByLabel("Email").fill("[email protected]");272await page.getByLabel("Password").fill("admin123");273await page.getByRole("button", { name: "Log in" }).click();274275await page.waitForURL("/home");276await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();277});278```279280### Filling Basic Form Fields281282**Use when**: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.283284```typescript285test("fill and submit a signup form", async ({ page }) => {286await page.goto("/signup");287288await page.getByLabel("First name").fill("Bob");289await page.getByLabel("Last name").fill("Wilson");290await page.getByLabel("Email").fill("[email protected]");291await page.getByLabel("Password", { exact: true }).fill("P@ssw0rd!");292await page.getByLabel("Confirm password").fill("P@ssw0rd!");293294await page.getByLabel("About you").fill("Developer with 5 years experience.");295await page.getByLabel("Years of experience").fill("5");296297await page.getByLabel("Country").selectOption("UK");298await page.getByLabel("City").selectOption({ label: "London" });299await page300.getByLabel("Skills")301.selectOption(["typescript", "playwright", "nodejs"]);302303await page.getByLabel("Accept terms").check();304await expect(page.getByLabel("Accept terms")).toBeChecked();305306await page.getByLabel("Annual billing").check();307await expect(page.getByLabel("Annual billing")).toBeChecked();308309await page.getByRole("button", { name: "Create account" }).click();310await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();311});312```313314### Date and Time Inputs315316**Use when**: Testing native `<input type="date">`, `<input type="time">`, `<input type="datetime-local">`, or third-party date pickers.317318```typescript319test("fill native date and time inputs", async ({ page }) => {320await page.goto("/reservation");321322await page.getByLabel("Reservation date").fill("2025-07-10");323await expect(page.getByLabel("Reservation date")).toHaveValue("2025-07-10");324325await page.getByLabel("Time slot").fill("18:00");326await page.getByLabel("Reminder").fill("2025-07-10T17:30");327});328329test("interact with a third-party date picker", async ({ page }) => {330await page.goto("/reservation");331332await page.getByLabel("Event date").click();333await page.getByRole("button", { name: "Next month" }).click();334await page.getByRole("gridcell", { name: "25" }).click();335336await expect(page.getByLabel("Event date")).toHaveValue(/2025/);337});338```339340### Required Field Validation341342**Use when**: Testing that the form shows appropriate error messages when required fields are empty.343344```typescript345test("shows validation errors for empty required fields", async ({ page }) => {346await page.goto("/inquiry");347348await page.getByRole("button", { name: "Send inquiry" }).click();349350await expect(page.getByText("Name is required")).toBeVisible();351await expect(page.getByText("Email is required")).toBeVisible();352await expect(page.getByText("Question is required")).toBeVisible();353354await expect(page).toHaveURL(/\/inquiry/);355});356357test("clears validation errors when fields are filled", async ({ page }) => {358await page.goto("/inquiry");359360await page.getByRole("button", { name: "Send inquiry" }).click();361await expect(page.getByText("Name is required")).toBeVisible();362363await page.getByLabel("Name").fill("Carol Brown");364await page.getByLabel("Email").focus();365366await expect(page.getByText("Name is required")).not.toBeVisible();367});368369test("native HTML5 validation with required attribute", async ({ page }) => {370await page.goto("/basic-form");371372await page.getByRole("button", { name: "Submit" }).click();373374const emailInput = page.getByLabel("Email");375const validationMessage = await emailInput.evaluate(376(el: HTMLInputElement) => el.validationMessage,377);378expect(validationMessage).toBeTruthy();379});380```381382### Format Validation and Custom Rules383384**Use when**: Testing email format, phone number format, password strength, and business-specific validation rules.385386```typescript387test("validates email format", async ({ page }) => {388await page.goto("/signup");389390const emailField = page.getByLabel("Email");391392const invalidEmails = [393"invalid",394"missing@",395"@nodomain.com",396"has [email protected]",397];398399for (const email of invalidEmails) {400await emailField.fill(email);401await emailField.blur();402await expect(page.getByText("Enter a valid email address")).toBeVisible();403}404405await emailField.fill("[email protected]");406await emailField.blur();407await expect(page.getByText("Enter a valid email address")).not.toBeVisible();408});409410test("validates password strength rules", async ({ page }) => {411await page.goto("/signup");412413const passwordField = page.getByLabel("Password", { exact: true });414415await passwordField.fill("Xy1!");416await passwordField.blur();417await expect(page.getByText("Minimum 8 characters")).toBeVisible();418419await passwordField.fill("lowercase1!");420await passwordField.blur();421await expect(page.getByText("Include an uppercase letter")).toBeVisible();422423await passwordField.fill("SecureP@ss1");424await passwordField.blur();425await expect(page.getByText(/Minimum|Include/)).not.toBeVisible();426});427428test("validates custom business rule — minimum amount", async ({ page }) => {429await page.goto("/transfer");430431await page.getByLabel("Amount").fill("5");432await page.getByLabel("Amount").blur();433await expect(page.getByText("Minimum transfer is $10")).toBeVisible();434435await page.getByLabel("Amount").fill("1000000");436await page.getByLabel("Amount").blur();437await expect(page.getByText("Maximum transfer is $100,000")).toBeVisible();438439await page.getByLabel("Amount").fill("500");440await page.getByLabel("Amount").blur();441await expect(page.getByText(/Minimum|Maximum/)).not.toBeVisible();442});443```444445### Form Reset Testing446447**Use when**: Testing "clear form" or "reset" functionality, verifying that fields return to their default values.448449```typescript450test("reset button clears all fields to defaults", async ({ page }) => {451await page.goto("/preferences");452453await page.getByLabel("Nickname").fill("CustomNick");454await page.getByLabel("Language").selectOption("es");455await page.getByLabel("Email alerts").uncheck();456457await page.getByRole("button", { name: "Reset" }).click();458459await expect(page.getByLabel("Nickname")).toHaveValue("");460await expect(page.getByLabel("Language")).toHaveValue("en");461await expect(page.getByLabel("Email alerts")).toBeChecked();462});463464test("confirmation dialog before resetting a dirty form", async ({ page }) => {465await page.goto("/document");466467await page.getByLabel("Document title").fill("Draft document");468469page.on("dialog", (dialog) => dialog.accept());470await page.getByRole("button", { name: "Clear changes" }).click();471472await expect(page.getByLabel("Document title")).toHaveValue("");473});474```475476## Decision Guide477478| Scenario | Approach | Key API |479| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |480| Standard text input | `fill()` (clears, then types) | `page.getByLabel('Field').fill('value')` |481| Need keystroke events (autocomplete) | `pressSequentially()` with delay | `locator.pressSequentially('text', { delay: 100 })` |482| Native `<select>` dropdown | `selectOption()` by value or label | `locator.selectOption('US')` or `{ label: 'United States' }` |483| Custom dropdown (ARIA listbox) | Click trigger, then select option role | `getByRole('option', { name: '...' }).click()` |484| Checkbox | `check()` / `uncheck()` (idempotent) | `locator.check()` — safe to call even if already checked |485| Radio button | `check()` on the target radio | `page.getByLabel('Option').check()` |486| Date input (native) | `fill()` with ISO format | `locator.fill('2025-03-15')` |487| Date picker (third-party) | Click to open, navigate, select day | `getByRole('gridcell', { name: '15' }).click()` |488| Validation errors | Submit, then assert error text | `expect(page.getByText('Required')).toBeVisible()` |489| Multi-step wizard | `test.step()` per step, assert heading | `await test.step('Step 1', async () => { ... })` |490| Conditional/dynamic fields | Change trigger field, assert new field visibility | `expect(locator).toBeVisible()` / `.not.toBeVisible()` |491| Form submission | `waitForResponse` + click submit | Register response listener before click |492| Auto-complete | `pressSequentially()`, wait for listbox, select option | `getByRole('option', { name }).click()` |493| Form reset | Click reset, assert default values | `expect(locator).toHaveValue('')` |494495## Anti-Patterns496497| Don't Do This | Problem | Do This Instead |498| ------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |499| `await page.getByLabel('Field').type('value')` | `type()` appends to existing content; does not clear first | `await page.getByLabel('Field').fill('value')` |500| `await page.getByLabel('Option').click()` | `click()` toggles — if already checked, it unchecks | `await page.getByLabel('Option').check()` |501| `await page.fill('#email', '[email protected]')` | CSS selector is fragile | `await page.getByLabel('Email').fill('[email protected]')` |502| `await page.selectOption('select', 'US')` without label | Targets first `<select>` on page; ambiguous | `await page.getByLabel('Country').selectOption('US')` |503| Testing every invalid input in one test | Test becomes huge, slow, and hard to debug | One test per validation rule or group related rules |504| `expect(await input.inputValue()).toBe('value')` | Resolves once — no retry. Race condition. | `await expect(input).toHaveValue('value')` |505| Filling fields with `page.evaluate()` | Bypasses event handlers (no `input`, `change` events fire) | Use `fill()` or `pressSequentially()` |506| Not waiting for conditional fields before filling | `fill()` fails on hidden/detached elements | `await expect(field).toBeVisible()` first |507| Hardcoding wait after selecting a dropdown | `waitForTimeout(500)` is flaky and slow | Wait for the dependent element to appear |508| Skipping server-side validation tests | Client-side validation can be bypassed | Test both client-side UX and server response |509510## Troubleshooting511512### `fill()` does nothing or clears but doesn't type513514**Cause**: The input field uses a contenteditable div (rich text editors), not a real `<input>` or `<textarea>`.515516```typescript517const isContentEditable = await page518.getByTestId("editor")519.evaluate((el) => el.getAttribute("contenteditable"));520521if (isContentEditable) {522await page.getByTestId("editor").click();523await page.getByTestId("editor").pressSequentially("Hello world");524}525```526527### Date picker does not accept `fill()` value528529**Cause**: Third-party date pickers often render custom UI over a hidden input. `fill()` sets the hidden input but the UI does not update.530531```typescript532await page.getByLabel("Date").click();533await page.getByRole("button", { name: "Next month" }).click();534await page.getByRole("gridcell", { name: "15" }).click();535536// Alternatively, if the library reads from the input on change:537await page.getByLabel("Date").fill("2025-06-15");538await page.getByLabel("Date").dispatchEvent("change");539```540541### `selectOption()` throws "not a select element"542543**Cause**: The dropdown is a custom component (ARIA listbox), not a native `<select>`.544545```typescript546await page.getByRole("combobox", { name: "Country" }).click();547await page.getByRole("option", { name: "United States" }).click();548```549550### Validation errors do not appear after `fill()` and submit551552**Cause**: The validation triggers on `blur` (focus leaving the field), but `fill()` does not trigger blur automatically.553554```typescript555await page.getByLabel("Email").fill("invalid");556await page.getByLabel("Email").blur();557await expect(page.getByText("Enter a valid email")).toBeVisible();558559// Or move focus to the next field560await page.getByLabel("Password").focus();561```562