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, validation and disabled states, rich text surfaces, 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. A React form may not attach its change handler until hydration completes; a component library may swap a native <select> for a listbox of <div>s after the first paint; a masked phone field may rewrite the value on every keystroke and reject a pasted string outright. 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.
The event model behind text entry
Every decision in this guide follows from one fact: a form field's behaviour is a function of the DOM events it receives, not of the string sitting in its value property. A browser handling a real keystroke dispatches keydown, then beforeinput, then mutates the value, then dispatches input, and finally keyup. React and Vue bind their state to input. Autocomplete widgets and keyboard shortcut handlers bind to keydown, because they need the key identity before the value changes. Input masks bind to beforeinput so they can rewrite or veto the incoming character. Validation libraries frequently bind to blur or change rather than input, so a field can hold an invalid value indefinitely until focus moves elsewhere.
fill() deliberately short-circuits most of that pipeline. It focuses the element, sets value directly through the DOM, and dispatches a single input event so framework state stays in sync. No keydown is emitted, which means no debounce timer starts, no autocomplete opens, and no per-character mask runs. That is not a defect—it is precisely what you want when the field is a plain text box and you care about the submitted payload, because it turns a thirty-character entry from thirty event round-trips into one.
pressSequentially() walks the string one character at a time through Playwright's keyboard layer, producing the same event sequence a physical keyboard would. With { delay: 50 } each character is separated in wall-clock time, which is what lets a 300 ms debounce timer expire naturally after the final keystroke instead of being reset by an instantaneous burst. The cost is real: a twenty-character query at 50 ms costs a full second, so use it only where the event stream matters.
The third path is the clipboard. Some fields—particularly masked and paste-normalising ones—behave differently again under a paste, which fires beforeinput with inputType: 'insertFromPaste' and no keydown at all. Testing a paste means writing to the clipboard and pressing ControlOrMeta+V, and it is worth doing explicitly whenever the product claims to accept pasted card numbers or IBANs.
Prerequisites
Everything below assumes a Playwright project with @playwright/test installed, a baseURL set in playwright.config.ts so page.goto('/signup') resolves, and testIdAttribute configured if your application uses something other than data-testid. Configuration mechanics live in Playwright Config & Fixtures. Form tests also benefit from per-test isolation so a half-filled draft never leaks into the next case, which is what Browser Contexts & Isolation provides by default.
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. The accessible name comes from a <label for>, an aria-label, or an aria-labelledby reference, so a role query doubles as a low-grade accessibility check: if getByRole() cannot find the field, a screen reader user probably cannot either. Where a control genuinely has no clear role—a custom colour swatch, a signature pad—getByTestId() gives a framework-agnostic anchor. Both outlast DOM churn far better than brittle CSS chains, and the trade-offs are laid out in getByRole & Accessibility Selectors and CSS & XPath Best Practices.
Two details bite in practice. First, name matching is case-insensitive and normalises whitespace but is substring-based only when you pass exact: false; a form with both "Password" and "Confirm Password" will trigger a strict mode violation unless you pass { name: 'Password', exact: true }. Second, a placeholder is not an accessible name in every browser, so getByPlaceholder() is a fallback rather than a primary strategy.
import { test, expect } from '@playwright/test';
test('fields located by role and test id', async ({ page }) => {
await page.goto('/signup');
// exact: true stops "Password" from also matching "Confirm Password".
const email = page.getByRole('textbox', { name: 'Email Address' });
const password = page.getByRole('textbox', { name: 'Password', exact: true });
const promo = page.getByTestId('promo-code-field');
await email.waitFor({ state: 'visible' }); // explicit readiness guard
await email.fill('user@example.com');
await password.fill('correct-horse-battery');
await promo.fill('LAUNCH25');
await expect(email).toHaveValue('user@example.com');
await expect(promo).toHaveValue('LAUNCH25');
});
Scoping matters as much as naming. When a page renders the same form twice—a billing address and a shipping address, say—anchor the field lookup inside a labelled region first: page.getByRole('group', { name: 'Shipping address' }).getByRole('textbox', { name: 'Postcode' }). That single extra hop removes an entire category of ambiguity failures and reads closer to how a person describes the task.
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 sanitisation to probe backend validation—for example, forcing a malformed phone number past an input mask to confirm the server rejects it. It also waits for the element to be editable first, so a field that is still readonly during hydration produces a clear actionability timeout rather than a silently dropped value.
pressSequentially() types character by character, emitting keydown, beforeinput, input, and keyup 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. Note that it appends to whatever is already in the field—it does not clear first—so pair it with await field.clear() when you are replacing an existing value.
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,
);
await box.clear(); // pressSequentially appends
// 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();
await expect(page.getByRole('listbox')).toContainText('Enterprise');
});
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.
A hybrid pattern keeps long entries cheap without losing the trigger: fill() all but the last character, then press() the final one. The bulk of the string arrives instantly, and the single trailing keystroke starts the debounce timer that the widget is listening for. It is a deliberate optimisation rather than a default—reach for it when a suite spends seconds typing into search boxes.
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> and returns the array of values actually selected, which is useful for multi-selects. 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();
// Multi-select: pass an array; the call returns what was actually selected.
const chosen = await page
.getByRole('listbox', { name: 'Regions' })
.selectOption(['emea', 'apac']);
expect(chosen).toEqual(['emea', 'apac']);
await expect(page.getByRole('checkbox', { name: 'Email me updates' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Annual plan' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Monthly plan' })).not.toBeChecked();
});
Custom dropdowns built from <div>s rather than native <select> need a click-then-pick interaction instead of selectOption(), and their state lives in aria-selected or aria-expanded rather than in a value property. Two further traps recur: a tri-state checkbox reports indeterminate, for which toBeChecked({ checked: false }) passes even though the box is visually half-filled—use toHaveJSProperty('indeterminate', true) instead; and a custom switch rendered as a <button role="switch"> responds to click() but not to check(). The matrix below is the quick reference.
The walkthrough on handling dropdowns, checkboxes, and radio buttons covers both native and custom controls in depth, including ARIA listboxes, multi-selects, and keyboard-only navigation of an option list.
Validation states, disabled controls, and error messages
A form test that only checks the happy path misses most of the product's behaviour. Client-side validation is a state machine of its own: pristine, dirty, touched, invalid, submitted. Libraries such as React Hook Form and Formik expose that state through aria-invalid, an error node wired up with aria-describedby, and a submit button that flips between enabled and disabled. Those are the observable contracts to assert against, and because Playwright's expect retries, you can assert on them immediately after the action without a sleep.
The critical detail is when validation runs. Many implementations validate on blur, not on input, so a test that fills a field and immediately asserts on the error will find nothing—the field is dirty but untouched. Calling blur() explicitly, or moving focus with press('Tab'), forces the transition deterministically instead of relying on the next click() happening to move focus.
import { test, expect } from '@playwright/test';
test('client validation gates the submit button', async ({ page }) => {
await page.goto('/signup');
const email = page.getByRole('textbox', { name: 'Email Address' });
const submit = page.getByRole('button', { name: 'Create Account' });
await email.fill('not-an-email');
await email.blur(); // many validators only run on blur
// The error node is a live region associated via aria-describedby.
await expect(page.getByRole('alert')).toHaveText('Enter a valid email address');
await expect(email).toHaveAttribute('aria-invalid', 'true');
await expect(submit).toBeDisabled(); // retries until the button settles
await email.fill('user@example.com');
await email.blur();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(email).toHaveAttribute('aria-invalid', 'false');
await expect(submit).toBeEnabled();
});
Disabled controls deserve their own note. Playwright's actionability checks refuse to click a disabled button and will time out rather than silently no-op, which is usually what you want—but it means await submit.click() on a form that is still validating produces a confusing thirty-second failure. Assert toBeEnabled() first and the failure message tells you the real story. Server-side validation returned in the response body needs the same treatment from the other direction: intercept the call, then assert the rendered error, so the test proves the UI surfaced what the API sent. The mocking mechanics live in Network Interception Basics, and the wider vocabulary of retrying matchers is catalogued in Web-First Assertions.
Rich text and contenteditable surfaces
Editors built on ProseMirror, TipTap, Quill, or Lexical are not inputs at all. They render a contenteditable element, intercept beforeinput to maintain their own document model, and re-render the DOM from that model. fill() on such a surface either throws—because the element is not an input, textarea, or contenteditable that Playwright recognises—or sets text that the editor's model immediately overwrites. There is no value to assert on either; the source of truth is the rendered node tree.
The working approach is to click into the editing surface to focus it, drive content with pressSequentially() and page.keyboard.press() for formatting shortcuts, and assert on structure with toContainText() and locators for the mark elements the editor emits.
import { test, expect } from '@playwright/test';
test('writes formatted content into a contenteditable editor', async ({ page }) => {
await page.goto('/compose');
// The editor exposes role="textbox" on its contenteditable host element.
const editor = page.getByRole('textbox', { name: 'Message body' });
await editor.click(); // focus the editing surface
await editor.pressSequentially('Release notes');
await page.keyboard.press('Enter');
// ControlOrMeta resolves to Meta on macOS and Control elsewhere.
await page.keyboard.press('ControlOrMeta+b'); // toggle the bold mark on
await editor.pressSequentially('Breaking changes');
await page.keyboard.press('ControlOrMeta+b'); // toggle it back off
// Assert the rendered document — a contenteditable has no value property.
await expect(editor.locator('strong')).toHaveText('Breaking changes');
await expect(editor).toContainText('Release notes');
});
Toolbar buttons are the other half of the story, and they interact badly with selection: clicking "Bold" after typing usually applies nothing because the click moved focus out of the editor and collapsed the selection. Select the target range with ControlOrMeta+a or shift-arrow presses first, and assert the toolbar button's aria-pressed state to confirm the mark applied. The deep dive on automating rich text editors works through selection ranges, paste sanitisation, embedded media, and the iframe-hosted editors that older WYSIWYG libraries still ship.
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();
// Inspect the request too, not just the reply the server sent back.
const sent = JSON.parse(response.request().postData() ?? '{}');
expect(sent.email).toBe('user@example.com');
expect(payload.status).toBe('pending_review'); // verify what came back
// 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 reading response.request().postData() proves the client serialised the form correctly—a check that catches silently dropped fields long before a QA pass would. Double submission is the other failure worth covering: assert the button becomes disabled during the in-flight request, and count the matching requests with a page.on('request') listener if the product has ever shipped a duplicate-order bug. When you want to fake the backend's reply to exercise 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. Model the wizard as a state machine in the test itself: each step has an entry assertion that proves you arrived, an action block, and a transition that you wait on explicitly.
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');
// Entry assertion: prove the step rendered before touching its fields.
await expect(page.getByRole('heading', { name: 'Company details' })).toBeVisible();
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 page.getByRole('button', { name: 'Next' }).click();
// The review step echoes earlier answers — assert the carry-over survived.
await expect(page.getByText('Acme Corp')).toBeVisible();
await context.close();
});
Two extra behaviours are worth explicit coverage. Backwards navigation must restore the draft, so click "Back" and assert the earlier field still holds its value—an easy regression when a team migrates from local component state to a store. And deep-linking into step three without completing step one should redirect rather than render a broken form; assert the redirect with toHaveURL(). For full state-machine handling, retry strategies between steps, and resuming a partially completed wizard, see automating multi-step forms with Playwright.
Failure modes and debugging
The value is set but the framework never saw it
You filled the field, toHaveValue() passes, and the submit button stays disabled. The application is reading from component state that was never updated because the framework listens for an event your entry method did not emit—commonly a library that binds to keyup or that wraps a third-party masked input. Switch to pressSequentially() for that field and the state updates. If it still does not, the widget is probably reading beforeinput data, in which case a clipboard paste reproduces the real user path.
Strict mode violation on a repeated label
Playwright refuses to act when a locator resolves to more than one element. On forms this almost always means duplicated labels—two "Address line 1" fields, or a mobile and desktop rendering of the same form both present in the DOM. Scope the query to the enclosing getByRole('group') or getByRole('form') region rather than reaching for .first(), which papers over the ambiguity and picks the hidden copy about half the time.
The field is covered by an overlay
An actionability timeout that reports the element as visible but not receiving pointer events usually means a sticky header, a cookie banner, or a floating label sits over the input. Dismiss the overlay as part of setup rather than forcing the click, because { force: true } bypasses the check that would have caught a genuine regression. Scroll behaviour and late-mounting overlays are covered in Handling Dynamic Content.
Typing is dropped or reordered on a masked field
Masks that rewrite the value on every keystroke can race a fast typist. Raise the delay to 100 ms for that field only, and assert the final formatted value rather than the raw digits you typed. If characters still vanish, the mask is likely re-setting the caret position asynchronously; typing into a cleared field rather than appending removes the caret ambiguity.
When a form failure resists reasoning, the recorded trace shows every event Playwright dispatched, the DOM before and after each action, and the network calls in between. Reading one is covered in analyzing test failures with the Playwright Trace Viewer.
CI/CD considerations
Form suites are the slowest part of most projects because they are dominated by typing delays and network waits, and they are the flakiest because they depend on backend state. Three habits keep them stable.
Budget the timeouts deliberately. A pressSequentially() with delay: 50 over a long query plus a debounce plus a server round-trip can legitimately exceed a 5-second expect timeout on a loaded CI runner. Raise expect.timeout in playwright.config.ts rather than sprinkling per-assertion overrides, and see Configuring Retries and Timeouts for Stable CI for the values that hold up under contention.
Generate unique data per worker. Signup forms fail on the second run because the email already exists. Derive the address from the worker index and a run identifier so parallel workers never collide, and clean up through an API teardown rather than a UI flow. Parallelism and worker scoping are covered in CI/CD Integration.
Capture artifacts only on retry. Enable trace: 'on-first-retry' and video: 'retain-on-failure' so a green run costs nothing while a failed form submission arrives with the full event log attached. That combination gives you the debugging fidelity of a local run without paying for it on every push.
Deep dives beneath this guide
Three focused walkthroughs sit under this guide, each taking one control family further than the summaries above.
- Handling Dropdowns, Checkboxes, and Radio Buttons — native
<select>versus ARIA listboxes, multi-select, indeterminate checkboxes, and keyboard-driven option pickers. - Automating Multi-Step Forms with Playwright — modelling a wizard as an explicit state machine, resuming partial progress, and retrying a step without restarting the flow.
- Automating Rich Text Editors — selection ranges, toolbar shortcuts, paste sanitisation, and the iframe-hosted editors that older WYSIWYG libraries still ship.
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.
Why does my form still show an error after I fill a valid value?
The validator most likely runs on blur rather than on input, so the field is dirty but untouched and the previous error is still rendered. Call blur() or press Tab to move focus after filling, then assert the error node has disappeared with toHaveCount(0).
Why does fill() throw on my rich text editor?
Because the editor is a contenteditable element managed by a document model, not an <input> or <textarea>. Click it to take focus, drive content with pressSequentially() and keyboard shortcuts, and assert the rendered nodes with toContainText() rather than looking for a value property that does not exist.
How do I stop a form test from failing on the second CI run?
Duplicate data is the usual cause: the email or company name from the first run already exists in the database. Derive unique values from the worker index and a per-run identifier, and remove the created records through an API teardown so the suite is repeatable without a manual reset.
Can I paste into a field instead of typing it?
Yes, and it is worth testing separately because a paste fires beforeinput with insertFromPaste and no key events at all. Write the string to the clipboard, focus the field, and press ControlOrMeta+V, then assert the formatted result — masked and normalising fields often behave differently under paste than under typing.