Back to Sanity

Form Testing Patterns

.agents/skills/playwright-best-practices/testing-patterns/forms-validation.md

5.24.021.2 KB
Original Source

Form Testing Patterns

Table of Contents

  1. Quick Reference
  2. Patterns
  3. Decision Guide
  4. Anti-Patterns
  5. Troubleshooting

When to use: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.

Quick Reference

typescript
// 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()

Patterns

Auto-Complete and Typeahead Fields

Use when: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.

typescript
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()
})

Dynamic Forms — Conditional Fields

Use when: Form fields appear, disappear, or change based on the value of other fields.

typescript
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'})
})

Multi-Step Forms and Wizards

Use when: The form spans multiple pages or steps, with next/previous navigation and per-step validation.

typescript
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]')
})

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.

typescript
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()
})

Filling Basic Form Fields

Use when: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.

typescript
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()
})

Date and Time Inputs

Use when: Testing native <input type="date">, <input type="time">, <input type="datetime-local">, or third-party date pickers.

typescript
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/)
})

Required Field Validation

Use when: Testing that the form shows appropriate error messages when required fields are empty.

typescript
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()
})

Format Validation and Custom Rules

Use when: Testing email format, phone number format, password strength, and business-specific validation rules.

typescript
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()
})

Form Reset Testing

Use when: Testing "clear form" or "reset" functionality, verifying that fields return to their default values.

typescript
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('')
})

Decision Guide

ScenarioApproachKey API
Standard text inputfill() (clears, then types)page.getByLabel('Field').fill('value')
Need keystroke events (autocomplete)pressSequentially() with delaylocator.pressSequentially('text', { delay: 100 })
Native <select> dropdownselectOption() by value or labellocator.selectOption('US') or { label: 'United States' }
Custom dropdown (ARIA listbox)Click trigger, then select option rolegetByRole('option', { name: '...' }).click()
Checkboxcheck() / uncheck() (idempotent)locator.check() — safe to call even if already checked
Radio buttoncheck() on the target radiopage.getByLabel('Option').check()
Date input (native)fill() with ISO formatlocator.fill('2025-03-15')
Date picker (third-party)Click to open, navigate, select daygetByRole('gridcell', { name: '15' }).click()
Validation errorsSubmit, then assert error textexpect(page.getByText('Required')).toBeVisible()
Multi-step wizardtest.step() per step, assert headingawait test.step('Step 1', async () => { ... })
Conditional/dynamic fieldsChange trigger field, assert new field visibilityexpect(locator).toBeVisible() / .not.toBeVisible()
Form submissionwaitForResponse + click submitRegister response listener before click
Auto-completepressSequentially(), wait for listbox, select optiongetByRole('option', { name }).click()
Form resetClick reset, assert default valuesexpect(locator).toHaveValue('')

Anti-Patterns

Don't Do ThisProblemDo This Instead
await page.getByLabel('Field').type('value')type() appends to existing content; does not clear firstawait page.getByLabel('Field').fill('value')
await page.getByLabel('Option').click()click() toggles — if already checked, it unchecksawait page.getByLabel('Option').check()
await page.fill('#email', '[email protected]')CSS selector is fragileawait page.getByLabel('Email').fill('[email protected]')
await page.selectOption('select', 'US') without labelTargets first <select> on page; ambiguousawait page.getByLabel('Country').selectOption('US')
Testing every invalid input in one testTest becomes huge, slow, and hard to debugOne 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 fillingfill() fails on hidden/detached elementsawait expect(field).toBeVisible() first
Hardcoding wait after selecting a dropdownwaitForTimeout(500) is flaky and slowWait for the dependent element to appear
Skipping server-side validation testsClient-side validation can be bypassedTest 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>.

typescript
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')
}

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.

typescript
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>.

typescript
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.

typescript
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()