.agents/skills/playwright-best-practices/testing-patterns/forms-validation.md
When to use: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.
// Text input
await page.getByLabel('Username').fill('john_doe')
// Select dropdown
await page.getByLabel('Region').selectOption('EU')
await page.getByLabel('Region').selectOption({label: 'Europe'})
// Checkbox and radio
await page.getByLabel('Subscribe').check()
await page.getByLabel('Priority shipping').click()
// Date input
await page.getByLabel('Departure').fill('2025-08-20')
// Clear a field
await page.getByLabel('Username').clear()
// Submit
await page.getByRole('button', {name: 'Register'}).click()
// Verify validation error
await expect(page.getByText('Username is required')).toBeVisible()
Use when: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.
test('select from typeahead suggestions', async ({page}) => {
await page.goto('/products')
const searchBox = page.getByRole('combobox', {name: 'Find product'})
await searchBox.pressSequentially('lapt', {delay: 100})
const suggestionList = page.getByRole('listbox')
await expect(suggestionList).toBeVisible()
await suggestionList.getByRole('option', {name: 'Laptop Pro'}).click()
await expect(searchBox).toHaveValue('Laptop Pro')
})
test('typeahead with API-driven suggestions', async ({page}) => {
await page.goto('/shipping')
const streetField = page.getByLabel('Street')
const responsePromise = page.waitForResponse('**/api/address-lookup*')
await streetField.pressSequentially('456 Elm', {delay: 50})
await responsePromise
await page.getByRole('option', {name: /456 Elm St/}).click()
await expect(page.getByLabel('Town')).toHaveValue('Austin')
await expect(page.getByLabel('State')).toHaveValue('TX')
await expect(page.getByLabel('Postal code')).toHaveValue('78701')
})
test('dismiss suggestions and enter custom value', async ({page}) => {
await page.goto('/labels')
const labelInput = page.getByLabel('New label')
await labelInput.pressSequentially('my-label')
await labelInput.press('Escape')
await expect(page.getByRole('listbox')).not.toBeVisible()
await labelInput.press('Enter')
await expect(page.getByText('my-label')).toBeVisible()
})
Use when: Form fields appear, disappear, or change based on the value of other fields.
test('conditional fields appear based on selection', async ({page}) => {
await page.goto('/loan/apply')
await page.getByLabel('Applicant type').selectOption('corporate')
await expect(page.getByLabel('Business name')).toBeVisible()
await expect(page.getByLabel('EIN')).toBeVisible()
await page.getByLabel('Business name').fill('TechCorp Inc')
await page.getByLabel('EIN').fill('98-7654321')
await page.getByLabel('Applicant type').selectOption('individual')
await expect(page.getByLabel('Business name')).not.toBeVisible()
await expect(page.getByLabel('EIN')).not.toBeVisible()
})
test('checkbox toggles additional section', async ({page}) => {
await page.goto('/delivery')
await page.getByLabel('Separate invoice address').check()
const invoiceSection = page.getByRole('group', {name: 'Invoice address'})
await expect(invoiceSection).toBeVisible()
await invoiceSection.getByLabel('Address').fill('789 Pine Rd')
await invoiceSection.getByLabel('City').fill('Denver')
await page.getByLabel('Separate invoice address').uncheck()
await expect(invoiceSection).not.toBeVisible()
})
test('dependent dropdown chains', async ({page}) => {
await page.goto('/region-selector')
await page.getByLabel('Country').selectOption('CA')
const provinceDropdown = page.getByLabel('Province')
await expect(provinceDropdown.getByRole('option')).not.toHaveCount(0)
await provinceDropdown.selectOption('ON')
const cityDropdown = page.getByLabel('City')
await expect(cityDropdown.getByRole('option')).not.toHaveCount(0)
await cityDropdown.selectOption({label: 'Toronto'})
})
Use when: The form spans multiple pages or steps, with next/previous navigation and per-step validation.
test('complete a multi-step booking wizard', async ({page}) => {
await page.goto('/booking')
await test.step('enter guest information', async () => {
await expect(page.getByRole('heading', {name: 'Guest Info'})).toBeVisible()
await page.getByLabel('Full name').fill('Alice Smith')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Phone').fill('555-1234')
await page.getByRole('button', {name: 'Next'}).click()
})
await test.step('select room options', async () => {
await expect(page.getByRole('heading', {name: 'Room Selection'})).toBeVisible()
await page.getByLabel('Room type').selectOption('suite')
await page.getByLabel('Check-in').fill('2025-09-01')
await page.getByLabel('Check-out').fill('2025-09-05')
await page.getByRole('button', {name: 'Next'}).click()
})
await test.step('confirm booking', async () => {
await expect(page.getByRole('heading', {name: 'Confirmation'})).toBeVisible()
await expect(page.getByText('Alice Smith')).toBeVisible()
await expect(page.getByText('suite')).toBeVisible()
await page.getByRole('button', {name: 'Confirm booking'}).click()
})
await expect(page.getByRole('heading', {name: 'Booking complete'})).toBeVisible()
})
test('wizard validates each step before proceeding', async ({page}) => {
await page.goto('/booking')
await page.getByRole('button', {name: 'Next'}).click()
await expect(page.getByRole('heading', {name: 'Guest Info'})).toBeVisible()
await expect(page.getByText('Full name is required')).toBeVisible()
})
test('wizard supports going back without losing data', async ({page}) => {
await page.goto('/booking')
await page.getByLabel('Full name').fill('Alice Smith')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Phone').fill('555-1234')
await page.getByRole('button', {name: 'Next'}).click()
await page.getByRole('button', {name: 'Previous'}).click()
await expect(page.getByLabel('Full name')).toHaveValue('Alice Smith')
await expect(page.getByLabel('Email')).toHaveValue('[email protected]')
})
Use when: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission.
test('successful form submission shows confirmation', async ({page}) => {
await page.goto('/feedback')
await page.getByLabel('Subject').fill('Feature request')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Details').fill('Please add dark mode')
const responsePromise = page.waitForResponse('**/api/feedback')
await page.getByRole('button', {name: 'Submit feedback'}).click()
const response = await responsePromise
expect(response.status()).toBe(200)
await expect(page.getByText('Feedback received')).toBeVisible()
})
test('form submission shows server-side validation errors', async ({page}) => {
await page.goto('/signup')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password', {exact: true}).fill('Secure1@pass')
await page.getByRole('button', {name: 'Sign up'}).click()
await expect(page.getByText('Email address already registered')).toBeVisible()
})
test('form shows loading state during submission', async ({page}) => {
await page.goto('/feedback')
await page.getByLabel('Subject').fill('Bug report')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Details').fill('Found an issue')
const submit = page.getByRole('button', {
name: /Submit feedback|Submitting/,
})
await submit.click()
await expect(submit).toHaveText(/Submitting/)
await expect(submit).toBeDisabled()
await expect(submit).toHaveText('Submit feedback')
await expect(submit).toBeEnabled()
})
test('form redirects after successful submission', async ({page}) => {
await page.goto('/auth/login')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('admin123')
await page.getByRole('button', {name: 'Log in'}).click()
await page.waitForURL('/home')
await expect(page.getByRole('heading', {name: 'Welcome'})).toBeVisible()
})
Use when: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.
test('fill and submit a signup form', async ({page}) => {
await page.goto('/signup')
await page.getByLabel('First name').fill('Bob')
await page.getByLabel('Last name').fill('Wilson')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password', {exact: true}).fill('P@ssw0rd!')
await page.getByLabel('Confirm password').fill('P@ssw0rd!')
await page.getByLabel('About you').fill('Developer with 5 years experience.')
await page.getByLabel('Years of experience').fill('5')
await page.getByLabel('Country').selectOption('UK')
await page.getByLabel('City').selectOption({label: 'London'})
await page.getByLabel('Skills').selectOption(['typescript', 'playwright', 'nodejs'])
await page.getByLabel('Accept terms').check()
await expect(page.getByLabel('Accept terms')).toBeChecked()
await page.getByLabel('Annual billing').check()
await expect(page.getByLabel('Annual billing')).toBeChecked()
await page.getByRole('button', {name: 'Create account'}).click()
await expect(page.getByRole('heading', {name: 'Welcome'})).toBeVisible()
})
Use when: Testing native <input type="date">, <input type="time">, <input type="datetime-local">, or third-party date pickers.
test('fill native date and time inputs', async ({page}) => {
await page.goto('/reservation')
await page.getByLabel('Reservation date').fill('2025-07-10')
await expect(page.getByLabel('Reservation date')).toHaveValue('2025-07-10')
await page.getByLabel('Time slot').fill('18:00')
await page.getByLabel('Reminder').fill('2025-07-10T17:30')
})
test('interact with a third-party date picker', async ({page}) => {
await page.goto('/reservation')
await page.getByLabel('Event date').click()
await page.getByRole('button', {name: 'Next month'}).click()
await page.getByRole('gridcell', {name: '25'}).click()
await expect(page.getByLabel('Event date')).toHaveValue(/2025/)
})
Use when: Testing that the form shows appropriate error messages when required fields are empty.
test('shows validation errors for empty required fields', async ({page}) => {
await page.goto('/inquiry')
await page.getByRole('button', {name: 'Send inquiry'}).click()
await expect(page.getByText('Name is required')).toBeVisible()
await expect(page.getByText('Email is required')).toBeVisible()
await expect(page.getByText('Question is required')).toBeVisible()
await expect(page).toHaveURL(/\/inquiry/)
})
test('clears validation errors when fields are filled', async ({page}) => {
await page.goto('/inquiry')
await page.getByRole('button', {name: 'Send inquiry'}).click()
await expect(page.getByText('Name is required')).toBeVisible()
await page.getByLabel('Name').fill('Carol Brown')
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('/basic-form')
await page.getByRole('button', {name: 'Submit'}).click()
const emailInput = page.getByLabel('Email')
const validationMessage = await emailInput.evaluate(
(el: HTMLInputElement) => el.validationMessage,
)
expect(validationMessage).toBeTruthy()
})
Use when: Testing email format, phone number format, password strength, and business-specific validation rules.
test('validates email format', async ({page}) => {
await page.goto('/signup')
const emailField = page.getByLabel('Email')
const invalidEmails = ['invalid', 'missing@', '@nodomain.com', 'has [email protected]']
for (const email of invalidEmails) {
await emailField.fill(email)
await emailField.blur()
await expect(page.getByText('Enter a valid email address')).toBeVisible()
}
await emailField.fill('[email protected]')
await emailField.blur()
await expect(page.getByText('Enter a valid email address')).not.toBeVisible()
})
test('validates password strength rules', async ({page}) => {
await page.goto('/signup')
const passwordField = page.getByLabel('Password', {exact: true})
await passwordField.fill('Xy1!')
await passwordField.blur()
await expect(page.getByText('Minimum 8 characters')).toBeVisible()
await passwordField.fill('lowercase1!')
await passwordField.blur()
await expect(page.getByText('Include an uppercase letter')).toBeVisible()
await passwordField.fill('SecureP@ss1')
await passwordField.blur()
await expect(page.getByText(/Minimum|Include/)).not.toBeVisible()
})
test('validates custom business rule — minimum amount', async ({page}) => {
await page.goto('/transfer')
await page.getByLabel('Amount').fill('5')
await page.getByLabel('Amount').blur()
await expect(page.getByText('Minimum transfer is $10')).toBeVisible()
await page.getByLabel('Amount').fill('1000000')
await page.getByLabel('Amount').blur()
await expect(page.getByText('Maximum transfer is $100,000')).toBeVisible()
await page.getByLabel('Amount').fill('500')
await page.getByLabel('Amount').blur()
await expect(page.getByText(/Minimum|Maximum/)).not.toBeVisible()
})
Use when: Testing "clear form" or "reset" functionality, verifying that fields return to their default values.
test('reset button clears all fields to defaults', async ({page}) => {
await page.goto('/preferences')
await page.getByLabel('Nickname').fill('CustomNick')
await page.getByLabel('Language').selectOption('es')
await page.getByLabel('Email alerts').uncheck()
await page.getByRole('button', {name: 'Reset'}).click()
await expect(page.getByLabel('Nickname')).toHaveValue('')
await expect(page.getByLabel('Language')).toHaveValue('en')
await expect(page.getByLabel('Email alerts')).toBeChecked()
})
test('confirmation dialog before resetting a dirty form', async ({page}) => {
await page.goto('/document')
await page.getByLabel('Document title').fill('Draft document')
page.on('dialog', (dialog) => dialog.accept())
await page.getByRole('button', {name: 'Clear changes'}).click()
await expect(page.getByLabel('Document title')).toHaveValue('')
})
| Scenario | Approach | Key API |
|---|---|---|
| Standard text input | fill() (clears, then types) | page.getByLabel('Field').fill('value') |
| 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('Option').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('') |
| Don't Do This | Problem | Do This Instead |
|---|---|---|
await page.getByLabel('Field').type('value') | type() appends to existing content; does not clear first | await page.getByLabel('Field').fill('value') |
await page.getByLabel('Option').click() | click() toggles — if already checked, it unchecks | await page.getByLabel('Option').check() |
await page.fill('#email', '[email protected]') | CSS selector is fragile | await page.getByLabel('Email').fill('[email protected]') |
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('value') | Resolves once — no retry. Race condition. | await expect(input).toHaveValue('value') |
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 |
fill() does nothing or clears but doesn't typeCause: The input field uses a contenteditable div (rich text editors), not a real <input> or <textarea>.
const isContentEditable = await page
.getByTestId('editor')
.evaluate((el) => el.getAttribute('contenteditable'))
if (isContentEditable) {
await page.getByTestId('editor').click()
await page.getByTestId('editor').pressSequentially('Hello world')
}
fill() valueCause: Third-party date pickers often render custom UI over a hidden input. fill() sets the hidden input but the UI does not update.
await page.getByLabel('Date').click()
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 select element"Cause: The dropdown is a custom component (ARIA listbox), not a native <select>.
await page.getByRole('combobox', {name: 'Country'}).click()
await page.getByRole('option', {name: 'United States'}).click()
fill() and submitCause: The validation triggers on blur (focus leaving the field), but fill() does not trigger blur automatically.
await page.getByLabel('Email').fill('invalid')
await page.getByLabel('Email').blur()
await expect(page.getByText('Enter a valid email')).toBeVisible()
// Or move focus to the next field
await page.getByLabel('Password').focus()