Actions and input

Click, fill, press, hover, drag, and upload through locators that wait for actionability.

Actions make the browser behave like a user: click a button, fill a field, press a key, hover a menu, drag an item, or upload a file. A good action is not just syntactically correct. It targets the same control a user would target, waits for that control to be ready, and is followed by an assertion that proves the product reacted.

The safe default is:

  1. locate semantically;
  2. perform one action;
  3. assert the visible result.

Mental model: locators wait for actionability

When you call an action on a locator, Playwright PHP waits for the target to be actionable before sending the input.

For normal UI, that means the element should be attached, visible, stable, enabled, and able to receive the action. You usually do not need sleep() before click(), fill(), hover(), or dragTo().

php
use function Playwright\Testing\expect;

$page->getByRole('button', ['name' => 'Save'])->click();

expect($page->getByText('Saved'))->toBeVisible();

The click is not the success condition. The visible confirmation is.

Actionability is a guardrail, not a guarantee that the application did the right thing. It tells you the browser could perform the action. The assertion tells you the product responded correctly. Keep both ideas separate when debugging: if the action times out, inspect the target; if the assertion times out, inspect the result.

Click visible UI, not implementation details

Prefer locators that describe user-facing controls:

php
$page->getByRole('link', ['name' => 'Pricing'])->click();
$page->getByRole('button', ['name' => 'Create account'])->click();
$page->getByText('View details')->click();

Reach for CSS when structure is the contract, or when no semantic locator exists:

php
$page->locator('[data-testid="plan-card-pro"]')->click();

Test IDs are legitimate for components whose visible text is unstable or repeated. They should be a deliberate testing contract, not a way to avoid fixing inaccessible markup.

Avoid page-level selector actions for new tests when a locator can express the same target:

php
$page->locator('button.save')->click();

That form is still valid, but it usually says less than a semantic locator. The stronger version names the product control:

php
$page->getByRole('button', ['name' => 'Save'])->click();

Use the weaker selector only when the product has no better contract.

Fill text fields

Use fill() for normal text inputs. It clears the current value and sets the new value:

php
$email = $page->getByLabel('Email');

$email->fill('ada@example.com');

if ('ada@example.com' !== $email->inputValue()) {
    throw new RuntimeException('Email field was not filled.');
}

Use type() when the application behavior depends on individual keystrokes: incremental search, input masks, hotkeys, or debounce behavior.

php
$search = $page->getByPlaceholder('Search products');

$search->type('keyboard', ['delay' => 50]);

expect($page->getByRole('list'))->toBeVisible();

Typing every field because it feels more human usually makes the test slower and more timing-sensitive.

Press keys and shortcuts

Use locator-level press() when the key belongs to one focused control:

php
$search = $page->getByPlaceholder('Search');
$search->fill('invoice');
$search->press('Enter');

Use page keyboard input when the behavior is global:

php
$page->keyboard()->press('Control+K');

expect($page->getByRole('dialog', ['name' => 'Command menu']))->toBeVisible();

Keep keyboard tests focused. If the goal is normal form input, fill() is clearer.

Check, uncheck, and select

Use control-specific actions when the control has state:

php
$remember = $page->getByLabel('Remember me');
$remember->check();

if (!$remember->isChecked()) {
    throw new RuntimeException('Remember me should be checked.');
}

$page->getByLabel('Country')->selectOption('fr');

These methods express desired state. A raw click only expresses an input event. If a checkbox starts checked, check() is still correct; another click could make it wrong.

Hover, menus, and transient UI

Hover is useful for UI that intentionally appears on pointer movement:

php
$page->getByRole('button', ['name' => 'More actions'])->hover();
$page->getByRole('menuitem', ['name' => 'Archive'])->click();

expect($page->getByText('Archived'))->toBeVisible();

If a hover menu is flaky, first check the product behavior. Menus that disappear before a user can reach them are often product bugs, not test bugs.

Drag and pointer input

Use dragTo() when source and target are real elements:

php
$source = $page->getByRole('listitem', ['name' => 'Draft proposal']);
$target = $page->getByRole('region', ['name' => 'Done']);

$source->dragTo($target);

expect($target)->toBeVisible();

Use raw mouse coordinates only when the product itself is coordinate-based: canvas, drawing tools, maps, games, or custom gesture surfaces.

php
$page->mouse()->move(120, 140);
$page->mouse()->down();
$page->mouse()->move(260, 220);
$page->mouse()->up();

Coordinate tests depend on viewport size. Set the viewport on the context before the page loads.

Pointer APIs are powerful, but they remove a lot of Playwright's safety. There is no label, role, or element-level assertion built into a coordinate. That is acceptable for a drawing canvas; it is usually the wrong tool for a normal button, menu, or card.

Upload files without the OS dialog

The native file picker is outside the web page. Set files on the input instead:

php
$page->getByLabel('Avatar')->setInputFiles(__DIR__.'/fixtures/avatar.png');

expect($page->getByText('avatar.png'))->toBeVisible();

For several files, pass an array of paths. Keep fixtures small, deterministic, and stored with the test suite.

If the file input is hidden behind a styled button, still set the file on the input. The styled button is only the trigger for a native picker; the input is the browser control that receives files.

Actions that trigger events

Some actions produce an event as their main result: a dialog opens, a popup appears, a file downloads, or a network response arrives. In those cases, the action and the wait belong together. Register the wait or handler before the action that causes the event.

For example, popup flows should wrap the click:

php
$popup = $page->waitForPopup(function () use ($page): void {
    $page->getByRole('link', ['name' => 'Open invoice'])->click();
});

expect($popup->getByRole('heading', ['name' => 'Invoice']))->toBeVisible();

This prevents the test from missing a fast event. Event handling is covered in the events guide; the action rule is simple: listen first, trigger second, assert afterwards.

Common mistakes

Injecting JavaScript clicks. JavaScript clicks skip user input behavior. Use them only when testing code that intentionally cannot be reached by a user is acceptable.

Clicking coordinates on normal UI. Coordinates are brittle. Prefer locators unless the product is actually spatial.

Using type() everywhere. fill() is faster and less timing-sensitive for ordinary fields.

Asserting the input instead of the outcome. Filling a field proves little by itself. The user-visible result after submit is usually the real assertion.

Hiding accessibility issues with test IDs. If users need a label or role, the test should probably need it too.

Repeating an action until it works. A retry loop around click() usually hides a missing readiness condition or a product race. Prefer one action and one assertion that describes the state that should follow.

Verification checklist

  • Is the target locator user-facing?
  • Does the action method match the control?
  • Is the result asserted after the action?
  • Would a user recognize the failure message?
  • Are raw keyboard or mouse APIs limited to cases that need them?

Go next