Skip to content

Latest commit

 

History

History
1056 lines (765 loc) · 38 KB

File metadata and controls

1056 lines (765 loc) · 38 KB

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