Forms and Validation

February 13, 2026 · View on GitHub

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

ScenarioApproachKey API
Standard text inputfill() (clears, then types)page.getByLabel('Name').fill('Jane')
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('Express').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('Name').type('Jane')type() appends to existing content; does not clear firstawait page.getByLabel('Name').fill('Jane')
await page.getByLabel('Agree').click()click() toggles — if already checked, it unchecksawait page.getByLabel('Agree').check()
await page.fill('#email', 'test@test.com')CSS selector is fragileawait page.getByLabel('Email').fill('test@test.com')
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('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 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>.

// 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