Form Automation & Input Handling
Forms are where the difference between Playwright's two text-entry methods stops being academic. fill() sets a value in a single shot; pressSequentially() emits real per-character keyboard events. Pick the wrong one and you get the classic bug report: it works when a human does it, but the automated test never fires the debounce, never triggers the autocomplete, never validates the way production does. This guide, part of Advanced Interactions & Test Assertions, covers text entry, the full range of form controls, and multi-route wizards—each paired with assertions that anchor on application state instead of arbitrary delays.
Single-page applications make this harder by deferring rendering, debouncing keystrokes, and mutating validation rules asynchronously. The answer is always the same: locate elements by role or test id so selectors survive refactors, and guard every interaction with a retrying assertion or a network wait so a green test means the form actually accepted the input.
Targeting fields that survive refactors
Resilient selectors decouple the test from volatile CSS classes and auto-generated ids. Prefer getByRole('textbox', { name: 'Email Address' }), which matches the way assistive technology and users perceive the field and respects the accessible name. Where a control has no clear role, getByTestId() gives a framework-agnostic anchor. Both outlast DOM churn far better than brittle CSS chains, and they connect to the broader selector discipline in Advanced Interactions & Test Assertions.
import { test, expect } from '@playwright/test';
test('fields located by role and test id', async ({ page }) => {
await page.goto('/signup');
const email = page.getByRole('textbox', { name: 'Email Address' });
const promo = page.getByTestId('promo-code-field');
await email.waitFor({ state: 'visible' }); // explicit readiness guard
await email.fill('user@example.com');
await promo.fill('LAUNCH25');
await expect(email).toHaveValue('user@example.com');
});
fill() versus pressSequentially()
fill() focuses the element, sets its value, and dispatches a single input event. It is fast and the correct choice when you want to bypass client-side keystroke sanitization to probe backend validation—for example, forcing a malformed phone number past an input mask to confirm the server rejects it.
pressSequentially() types character by character, emitting keydown, keypress, and input for each one with an optional delay. That is what debounced search boxes, autocompletes, and masked inputs need to behave as a user would see them, because those features only react to real key events.
import { test, expect } from '@playwright/test';
test('debounced search waits on the suggestion call', async ({ page }) => {
await page.goto('/search');
const box = page.getByRole('combobox', { name: 'Search' });
// Register the response wait before typing so the round-trip is captured.
const suggested = page.waitForResponse(
(res) => res.url().includes('/api/suggestions') && res.status() === 200,
);
// delay lets the debounce timer fire as it would for a real user.
await box.pressSequentially('enterprise', { delay: 50 });
await suggested;
await expect(page.getByRole('option').first()).toBeVisible();
});
Reach for fill() to inject a payload when testing server-side error handling; reserve pressSequentially() for verifying client-side formatting, autocomplete, and masking. That distinction mirrors the gesture-precision required in Drag & Drop Workflows, where the event sequence must match what the UI expects.
Dropdowns, checkboxes, and radio buttons
Beyond text, real forms mean selects, checkboxes, and radios—each with a dedicated Playwright method that carries actionability waiting. selectOption() chooses by value, label, or index on a native <select>. check() and uncheck() toggle checkboxes and radios and are idempotent, so they will not double-toggle an already-checked control. Assert the resulting state with toBeChecked() rather than trusting the action fired.
import { test, expect } from '@playwright/test';
test('select, check, and confirm control state', async ({ page }) => {
await page.goto('/preferences');
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
await page.getByRole('checkbox', { name: 'Email me updates' }).check();
await page.getByRole('radio', { name: 'Annual plan' }).check();
await expect(page.getByRole('checkbox', { name: 'Email me updates' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Annual plan' })).toBeChecked();
});
Custom dropdowns built from <div>s rather than native <select> need a click-then-pick interaction instead of selectOption(). The walkthrough on handling dropdowns, checkboxes, and radio buttons covers both native and custom controls, including ARIA listboxes and multi-selects.
Submission and network validation
Clicking submit fires an asynchronous request that decides the application's next state. Capture it to confirm the form sent exactly the data you expect, then assert the post-submit UI as the completion marker. Register the response wait before the click—the same register-before-trigger rule that governs uploads and downloads in File Uploads & Downloads.
import { test, expect } from '@playwright/test';
test('submit sends the right payload and confirms', async ({ page }) => {
await page.goto('/application');
const submitted = page.waitForResponse(
(res) => res.request().method() === 'POST' && res.url().includes('/api/forms/submit'),
);
await page.getByRole('button', { name: 'Submit Application' }).click();
const response = await submitted;
const payload = await response.json();
expect(payload.status).toBe('pending_review'); // verify what was sent
// Completion markers: a confirmation message and a route change.
await expect(page.getByText('Submission successful')).toBeVisible();
await expect(page).toHaveURL(/\/confirmation\/[a-f0-9]{8}/);
});
The predicate filters out unrelated traffic so you isolate the submit endpoint, and parsing response.json() lets you validate the structure of what the UI actually transmitted. When you want to fake the backend's reply to test error paths without a live server, the routing approach in Network Interception Basics fulfills the submit with a chosen status.
Multi-step wizards
Wizard forms span several routes while carrying transient data between them. A persistent context keeps cookies, local storage, and session tokens alive across navigation, and conditional branching driven by isChecked() or waitForURL()—not fixed sleeps—handles divergent paths.
import { test, expect } from '@playwright/test';
test('wizard branches on plan selection', async ({ browser }) => {
const context = await browser.newContext({ storageState: 'auth-state.json' });
const page = await context.newPage();
await page.goto('/wizard/step-1');
await page.getByRole('textbox', { name: 'Company Name' }).fill('Acme Corp');
await page.getByRole('button', { name: 'Next' }).click();
await page.waitForURL(/\/wizard\/step-\d+/); // anchor on real navigation
const enterprise = await page.getByRole('radio', { name: 'Enterprise Plan' }).isChecked();
if (enterprise) {
const contract = page.getByRole('textbox', { name: 'Contract ID' });
await contract.waitFor({ state: 'attached' }); // branch-only field
await contract.fill('ENT-8842');
}
await context.close();
});
For full state-machine handling, retry strategies between steps, and resuming a partially completed wizard, see automating multi-step forms with Playwright.
Frequently Asked Questions
When should I use fill() instead of pressSequentially()?
Use fill() to set a value instantly or to bypass client-side keystroke handling when probing backend validation. Use pressSequentially() with a delay when the field debounces input, drives an autocomplete, or applies an input mask, because those behaviors only react to real per-character key events.
How do I select an option from a custom dropdown that is not a native select?
selectOption() only works on native <select> elements. For custom dropdowns built from divs or ARIA listboxes, click the trigger to open the menu, then click the option by its role and accessible name, and assert the chosen value rendered back into the control.
How do I keep data across multi-step form pages?
Create a persistent BrowserContext so cookies, local storage, and session tokens survive each navigation. Drive branching with isChecked() and waitForURL() rather than fixed delays, and assert each step rendered before filling the next.