Playwright gives you two ways to get text into an input, and picking the wrong one causes most typing-related flakiness.
fill is the default. It focuses the element, replaces its entire value in one operation, and fires a single input event. It is fast, it works on inputs, textareas, and contenteditable elements, and it does not care how long the text is.
await page.getByLabel('Email address').fill('leo@example.com');pressSequentially presses one key at a time, firing keydown, keypress, and keyup for every character, exactly like a person typing.
await page.getByLabel('City').pressSequentially('Amsterdam');Use pressSequentially only when the page reacts to individual keystrokes. Autocomplete fields that fetch suggestions on each character, masked inputs that reformat as you type, and editors with per-key handlers all fall in that category. Everything else should use fill, because per-key typing is slower and gives the frontend more opportunities to interleave renders with your input.
You may still see locator.type() in older tutorials. It is deprecated; pressSequentially is its direct replacement, and fill covers the majority of what type() was used for.
An optional delay slows the keystrokes down when a debounced handler needs breathing room:
await page.getByLabel('Search').pressSequentially('play', { delay: 100 });To empty a field, fill it with an empty string:
await page.getByLabel('Promo code').fill('');There is also clear(), which does exactly the same thing with a more readable name:
await page.getByLabel('Promo code').clear();Both replace the current value, so you never need the old select-all-then-delete dance. If a field appears to refuse clearing, it is usually a controlled component that restores its value from state, and the fix belongs in how you interact with it, not in more aggressive clearing.
press sends a single key or a combination to an element. Key names follow the W3C key values: Enter, Tab, Escape, ArrowDown, Backspace, and printable characters like a.
await page.getByLabel('Quantity').press('ArrowUp');
await page.getByLabel('Search').press('Enter');Modifiers combine with +:
await page.getByLabel('Notes').press('Control+A');
await page.getByLabel('Notes').press('Control+V');On macOS the modifier is Meta, so cross-platform suites often use ControlOrMeta+A, which resolves to the right key for the current OS.
When you need to paste a chunk of text without simulating each keystroke and without touching the clipboard, insertText dispatches only an input event:
await page.getByLabel('Notes').focus();
await page.keyboard.insertText('Pasted content, no key events fired');focus() and blur() are available on any locator, and blur matters more than it looks: many forms validate a field on blur, so a test that fills a field and immediately asserts an error message may need to blur first.
const email = page.getByLabel('Email address');
await email.fill('not-an-email');
await email.blur();
await expect(page.getByText('Enter a valid email')).toBeVisible();Textareas behave like inputs; fill handles multi-line content including newlines in the string:
await page.getByLabel('Description').fill('Line one\nLine two');Rich text editors built on contenteditable also accept fill, which sets the text content directly. If the editor relies on key events to maintain internal state, as ProseMirror and Slate sometimes do, switch to pressSequentially and target the editable region:
const editor = page.locator('[contenteditable="true"]');
await editor.click();
await editor.pressSequentially('Report for Q3');You have two natural submit paths: click the button, or press Enter in a field. Both are fine. What matters is how you wait for the outcome.
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();The assertion is the wait. toBeVisible retries until the heading appears, whether the submission triggered a full navigation, a client-side route change, or an in-place render. Reaching for page.waitForNavigation() here is the old Puppeteer habit; it is deprecated in Playwright and races against fast navigations. Web-first assertions express the same intent without the race.
Pressing Enter works the same way:
await page.getByLabel('Search').press('Enter');
await expect(page.getByRole('list', { name: 'Results' })).toBeVisible();If your form submits to a slow endpoint, raise the assertion timeout rather than adding sleeps. The waits and timeouts guide covers why fixed delays are the wrong tool.
Inline validation messages are ordinary elements, so assert them like any other content:
await page.getByLabel('Password').fill('short');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Password must be at least 12 characters')).toBeVisible();When the message renders inside a dedicated element, toHaveText pins down the exact copy:
await expect(page.getByTestId('password-error'))
.toHaveText('Password must be at least 12 characters');Also assert the inverse after fixing the input. A validation message that fails to disappear is a real bug, and the test is cheap:
await page.getByLabel('Password').fill('a-much-longer-password');
await expect(page.getByTestId('password-error')).toBeHidden();Phone numbers, credit cards, and dates often use input masks that reformat the value on every keystroke. fill bypasses the per-key handlers, so the mask never runs and the field may end up with a raw or rejected value. This is the textbook case for pressSequentially:
const card = page.getByLabel('Card number');
await card.pressSequentially('4242424242424242');
await expect(card).toHaveValue('4242 4242 4242 4242');Assert the formatted value, not the digits you typed. The mask's output is what the form actually submits or re-parses, and the assertion catches masks that break after a library upgrade.
The same applies to autofill-resistant fields, the ones that ignore programmatic value changes to block browser autofill or bots. If fill leaves the field empty while manual typing works, the page is listening for trusted-looking key events, and pressSequentially supplies them. Should even that fail, check whether the field validates on paste or blur and trigger those explicitly before assuming Playwright is at fault.
Everything above combines into a compact, readable test. Locator choices follow the locators guide: labels for fields, roles for buttons.
import { test, expect } from '@playwright/test';
test('signs up a new user', async ({ page }) => {
await page.goto('https://app.example.com/signup');
await page.getByLabel('Full name').fill('Leo Baecker');
await page.getByLabel('Email address').fill('leo@example.com');
await page.getByLabel('Password', { exact: true }).fill('correct-horse-battery');
await page.getByLabel('Phone').pressSequentially('0612345678');
await expect(page.getByLabel('Phone')).toHaveValue('06 12 34 56 78');
await page.getByRole('checkbox', { name: 'I agree to the terms' }).check();
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Verify your email' })).toBeVisible();
});Note the exact: true on the password label, which avoids matching a "Confirm password" field, and the toHaveValue check on the masked phone number before submission. Checkbox and dropdown patterns get their own treatment in the dropdowns and checkboxes chapter.
fill runs actionability checks before touching the element: it must be attached, visible, enabled, and editable. A disabled input fails the checks, so Playwright retries silently until the timeout and then throws:
TimeoutError: locator.fill: Timeout 30000ms exceeded.
Call log:
- waiting for getByLabel('Company VAT number')
- locator resolved to <input disabled id="vat" type="text"/>
- fill("NL123456789B01")
- attempting fill action
- waiting for element to be visible, enabled and editable
- element is not enabledThe call log tells you exactly which check failed, which makes these errors quick to diagnose. Two honest responses exist. If the field is supposed to be disabled at that point, assert it instead of filling it:
await expect(page.getByLabel('Company VAT number')).toBeDisabled();If it should be enabled, perform the UI step that enables it first, such as checking the "I am registering as a company" box. What you should not do is reach into the DOM with evaluate to force a value into a disabled or hidden field. The test would then submit a form no user could ever produce, and it stops telling you anything about the product.
Hidden inputs behave the same way, failing the visibility check. The one common exception, <input type="file"> elements that are hidden behind styled upload buttons, has its own escape hatch covered in the file upload chapter.
Form flows are exactly what breaks silently in production: a signup that errors after a dependency bump, a login form that a CSP change quietly broke. Hyperping Browser Checks run these same Playwright scripts on a schedule from multiple regions and alert you the moment the flow stops completing.