Actions and Forms
Fill inputs, tick boxes, choose options, upload files and handle dialogs — plus what fill actually dispatches and when you need pressSequentially instead.
Every action in this lesson runs the actionability checks from the previous one first. What
changes is what gets dispatched afterwards — and the difference between fill and typing is
where most form tests go wrong.
A form to work with
page.setContent renders HTML directly, so these examples need no server:
import { test, expect } from '@playwright/test';
const FORM = `
<form id="signup">
<label for="email">Email</label><input id="email" type="email">
<label for="plan">Plan</label>
<select id="plan">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
<label><input id="terms" type="checkbox"> I accept the terms</label>
<label><input name="contact" type="radio" value="email" checked> Email</label>
<label><input name="contact" type="radio" value="sms"> SMS</label>
<button type="submit">Create account</button>
<p id="result"></p>
</form>
<script>
signup.onsubmit = e => {
e.preventDefault();
result.textContent =
email.value + ' / ' + plan.value + ' / terms=' + terms.checked +
' / ' + signup.contact.value;
};
</script>`;
test('fill the form and submit', async ({ page }) => {
await page.setContent(FORM);
await page.getByLabel('Email').fill('ada@example.com');
await page.getByLabel('Plan').selectOption('pro');
await page.getByLabel('I accept the terms').check();
await page.getByLabel('SMS').check();
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.locator('#result')).toHaveText(
'ada@example.com / pro / terms=true / sms'
);
});
Running 1 test using 1 worker
✓ 1 [chromium] › tests/forms.spec.ts:29:1 › fill the form and submit (118ms)
1 passed (742ms)
118ms, because nothing had to wait for the network. setContent is the fastest way to
reproduce a form bug in isolation.
fill versus typing
fill focuses the element, sets .value, and fires one input event. It does not press
keys. Most of the time that is exactly right, and it is an order of magnitude faster.
test('fill fires one input event, typing fires many', async ({ page }) => {
await page.setContent(`
<input id="search">
<p id="count">0</p>
<script>
let n = 0;
search.addEventListener('input', () => count.textContent = ++n);
</script>`);
await page.getByRole('textbox').fill('kafka');
await expect(page.locator('#count')).toHaveText('1');
await page.getByRole('textbox').fill('');
await page.getByRole('textbox').pressSequentially('kafka', { delay: 20 });
await expect(page.locator('#count')).toHaveText('7');
});
✓ 1 [chromium] › tests/forms.spec.ts:48:1 › fill fires one input event, typing fires many (289ms)
1 passed (912ms)
Seven, not five: the clearing fill('') counted as one, then five keystrokes, and the
counter started from the earlier value. The point stands — fill is one event, typing is
one per character.
That matters for an autocomplete that opens on the third keystroke, or a field that
formats as you type. If a dropdown never appears in your test but does by hand,
pressSequentially is the fix.
Clicking
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save' }).dblclick();
await page.getByRole('button', { name: 'Save' }).click({ button: 'right' });
await page.getByRole('link', { name: 'Docs' }).click({ modifiers: ['ControlOrMeta'] });
await page.getByTestId('canvas').click({ position: { x: 12, y: 40 } });
ControlOrMeta resolves to Cmd on macOS and Ctrl elsewhere, which saves a platform branch
in every “open in new tab” test.
The option to be suspicious of is force:
test('force skips the checks that protect you', async ({ page }) => {
await page.setContent(`
<button id="save" onclick="result.textContent='saved'">Save</button>
<div style="position:fixed;inset:0;background:rgba(0,0,0,.5)">Cookie banner</div>
<p id="result"></p>`);
await page.getByRole('button', { name: 'Save' }).click({ timeout: 2000 });
});
✘ 1 [chromium] › tests/forms.spec.ts:66:1 › force skips the checks that protect you (2.1s)
Error: locator.click: Timeout 2000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Save' })
- locator resolved to <button id="save">Save</button>
- attempting click action
- waiting for element to receive pointer events
- <div style="position:fixed;inset:0;background:rgba(0,0,0,.5)">Cookie banner</div>
intercepts pointer events
- retrying click action, attempt #9
The failure names the overlay that stole the click. Adding { force: true } makes this test
pass — and hides the fact that a real user cannot click that button. Dismiss the banner
instead.
Checkboxes and radios
await page.getByLabel('I accept the terms').check();
await page.getByLabel('I accept the terms').uncheck();
await expect(page.getByLabel('I accept the terms')).toBeChecked();
await expect(page.getByLabel('SMS')).not.toBeChecked();
check() is not click(): it verifies the resulting state and retries if the box did not
end up checked. On a custom component that swallows the first click, click passes and
check catches the bug.
Select elements
test('selectOption by value, label and index', async ({ page }) => {
await page.setContent(FORM);
const plan = page.getByLabel('Plan');
await plan.selectOption('team');
await expect(plan).toHaveValue('team');
await plan.selectOption({ label: 'Pro' });
await expect(plan).toHaveValue('pro');
await plan.selectOption({ index: 0 });
await expect(plan).toHaveValue('free');
});
✓ 1 [chromium] › tests/forms.spec.ts:82:1 › selectOption by value, label and index (96ms)
1 passed (688ms)
For a multi-select, pass an array. Note that this only works on a native <select>; a
React or Vue “select” built from divs is a click on a button followed by a click on an
option.
Uploading files
test('upload from disk and from memory', async ({ page }) => {
await page.setContent(`
<input id="doc" type="file" multiple>
<p id="names"></p>
<script>
doc.onchange = () =>
names.textContent = [...doc.files].map(f => f.name + ':' + f.size).join(', ');
</script>`);
await page.getByRole('button', { name: /choose|browse/i }).count(); // no button needed
await page.locator('#doc').setInputFiles({
name: 'report.csv',
mimeType: 'text/csv',
buffer: Buffer.from('id,total\n1,25.50\n'),
});
await expect(page.locator('#names')).toHaveText('report.csv:17');
});
✓ 1 [chromium] › tests/forms.spec.ts:98:1 › upload from disk and from memory (104ms)
1 passed (711ms)
Building the file in memory means no fixture files to check in and no path juggling in CI.
Pass a string path for a real file, an array for a multi-upload, and [] to clear the
selection.
When the app hides the input behind a styled button, catch the chooser event:
const [chooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Attach' }).click(),
]);
await chooser.setFiles('fixtures/logo.png');
The Promise.all matters: start listening before the click, or the event fires while you
are still setting up the listener.
Dialogs
test('accept a confirm dialog', async ({ page }) => {
await page.setContent(`
<button onclick="result.textContent = confirm('Delete?') ? 'deleted' : 'kept'">
Delete
</button>
<p id="result"></p>`);
page.on('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('#result')).toHaveText('deleted');
});
✓ 1 [chromium] › tests/forms.spec.ts:118:1 › accept a confirm dialog (88ms)
1 passed (695ms)
Remove the handler and the test still runs — but Playwright auto-dismisses, confirm
returns false, and the result reads kept. This is a common surprise: the test does not
hang, it quietly takes the other branch.
Downloads
test('capture a download', async ({ page }) => {
await page.setContent(
`<a download="report.csv" href="data:text/csv,id,total%0A1,25.50">Download</a>`
);
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('link', { name: 'Download' }).click(),
]);
expect(download.suggestedFilename()).toBe('report.csv');
await download.saveAs('test-results/report.csv');
});
✓ 1 [chromium] › tests/forms.spec.ts:132:1 › capture a download (142ms)
1 passed (760ms)
The file is deleted when the browser context closes unless you call saveAs, so save it if
you intend to assert on its contents.
Iframes
Locators do not cross frame boundaries. Use frameLocator:
await page
.frameLocator('#payment-iframe')
.getByLabel('Card number')
.fill('4242424242424242');
Everything after frameLocator behaves normally, including strict mode and auto-waiting.
If a locator “definitely exists” but never resolves, check whether it lives in an iframe —
that is the usual answer.
Practice
1. Assert that the submit button is disabled until the terms box is ticked.
test('submit gated on terms', async ({ page }) => {
await page.setContent(`
<input id="terms" type="checkbox">
<button id="go" disabled>Create account</button>
<script>terms.onchange = () => go.disabled = !terms.checked;</script>`);
const button = page.getByRole('button', { name: 'Create account' });
await expect(button).toBeDisabled();
await page.locator('#terms').check();
await expect(button).toBeEnabled();
});
✓ 1 [chromium] › tests/forms.spec.ts:146:1 › submit gated on terms (94ms)
1 passed (702ms)
toBeDisabled and toBeEnabled both retry, so no wait is needed between the check and the
assertion.
2. Type into a field that only reacts after three characters.
await page.getByRole('textbox').pressSequentially('kaf');
await expect(page.getByRole('listbox')).toBeVisible();
✓ 1 [chromium] › tests/forms.spec.ts:158:1 › autocomplete opens (203ms)
With fill('kaf') the component sees one input event with a complete value. Many
autocompletes listen for keyup and never fire at all, which is why the dropdown assertion
times out on a field that works by hand.
3. Upload two files at once and assert both names appear.
await page.locator('#doc').setInputFiles([
{ name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('a') },
{ name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') },
]);
await expect(page.locator('#names')).toHaveText('a.txt:1, b.txt:1');
✓ 1 [chromium] › tests/forms.spec.ts:166:1 › multi upload (99ms)
The input needs the multiple attribute; without it the browser keeps only the last file
and the assertion fails with a one-name string.
4. Dismiss the confirm dialog instead of accepting it, and assert the cancel path.
page.on('dialog', dialog => dialog.dismiss());
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('#result')).toHaveText('kept');
✓ 1 [chromium] › tests/forms.spec.ts:174:1 › dismiss confirm (91ms)
Worth writing as its own test. “Cancel actually cancels” is a behaviour users rely on and almost nobody covers.
Next: hooks and fixtures — sharing setup without sharing state.