Skip to main content
Playwright beginner Lesson 4 of 10

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.

Frequently Asked Questions

What is the difference between fill and pressSequentially?
`fill` sets the value in one step and fires a single `input` event, which is fast and right for almost every form. `pressSequentially` sends real keydown/keypress/keyup per character, which you need only when the field reacts to individual keystrokes — an autocomplete, a masked input, or a keystroke counter.
When should I use force: true on a click?
Almost never. It skips the actionability checks, so it will happily click an element covered by a modal — turning a real bug into a green test. Use it only when you have confirmed the element is genuinely clickable and Playwright's hit test is wrong, such as with a custom pointer-events overlay.
How do I upload a file in Playwright?
Call `setInputFiles` on the `<input type="file">` locator with a path, or an object holding a name, mimeType and buffer to build the file in memory. For a button that opens the picker without exposing an input, wait for the `filechooser` event and call `setFiles` on it.
How do I handle a JavaScript alert or confirm?
Register a `page.on('dialog')` handler before the action that triggers it and call `accept()` or `dismiss()`. With no handler Playwright dismisses dialogs automatically, so a `confirm` returns false and the code under test silently takes the cancel path.