Selecting an element only matters because of what you do to it next. This lesson is the action layer of Playwright — every command that simulates a user touching the page. Each one fires real DOM events the application can't tell apart from a human, and each one runs Playwright's actionability checks first: the element must be attached to the DOM, visible, stable (not animating), enabled, and able to receive events. By the end you'll have a complete typed registration-form spec that exercises click, fill, check, select, file upload, and the keyboard shortcuts you'll use in real tests.
.click() — the most-used action
.click() clicks an element. Playwright waits for the element to pass actionability checks, then dispatches the same mousedown / mouseup / click event sequence a real user would:
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("link", { name: "Products" }).click();Options for non-default clicks:
await page.locator("canvas").click({ position: { x: 100, y: 200 } }); // exact x/y
await page.locator("button").click({ button: "right" }); // right-click
await page.locator(".item").click({ clickCount: 2 }); // double-click
await page.locator(".item").dblclick(); // shorthand
await page.locator(".item").click({ modifiers: ["Shift"] }); // Shift+clickWhen Playwright refuses to click ("element is outside of the viewport," "element is not stable"), { force: true } skips actionability checks — but it's almost always the wrong fix:
// ⚠️ Use sparingly — usually a sign the test scenario is wrong
await page.locator(".hidden-btn").click({ force: true });force: true is what you reach for when you're trying to test a genuinely hidden element on purpose. The errors you skipped were Playwright telling you the test setup is wrong — perhaps the element is behind a modal, perhaps a sticky header is overlapping. Solve the underlying issue first.
.fill() — the form workhorse
.fill() clears the input and types the value in one operation, dispatching the events React, Vue, and other framework inputs need:
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("Sup3rS3cret!");Use .fill() for almost every form input you'll ever write. It's faster than typing character-by-character, it handles framework state correctly, and it doesn't need a separate clear step — fill('') empties the field.
.pressSequentially() — character-by-character typing
When the page reacts to each keystroke — autocomplete, character counters, input masks, search-as-you-type — .fill() skips the per-key events. Use .pressSequentially() instead:
await page
.getByLabel("Search")
.pressSequentially("laptop", { delay: 100 }); // 100ms between keys{ delay: 100 } adds a 100ms pause between characters, mimicking human typing speed. This is what triggers debounced search dropdowns to actually open. (Older Playwright versions called this .type(); both still work, but .pressSequentially() is the modern name.)
.press() — single keys and combinations
For one-shot key presses or modifier combinations:
await page.getByLabel("Search").press("Enter");
await page.keyboard.press("Escape");
await page.keyboard.press("Control+A"); // select all
await page.keyboard.press("Meta+C"); // mac copy
await page.keyboard.press("Shift+Tab"); // back-tabCommon keys: Enter, Tab, Escape, Backspace, Delete, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Home, End, PageDown, PageUp. Modifier syntax uses +: Control+A, Shift+ArrowRight, Alt+F4. For consistency across macOS and other platforms, use ControlOrMeta to mean "the platform's primary modifier."
.clear() — wiping inputs
.fill('') is the idiomatic way, but .clear() exists too and reads a little nicer:
await page.getByLabel("Search").clear();
await page.getByLabel("Search").fill("new value");Either works. Use whichever your team's style prefers.
.check() / .uncheck() — checkboxes
await page.getByLabel("Remember me").check();
await page.getByLabel("Newsletter").uncheck();
// Assertions
await expect(page.getByLabel("Remember me")).toBeChecked();
await expect(page.getByLabel("Newsletter")).not.toBeChecked();.check() is idempotent — if the box is already checked, it does nothing (no error, no toggle). Same for .uncheck(). This is what makes "ensure terms is checked before submit" tests robust regardless of starting state.
.selectOption() — native dropdowns
For native <select> elements:
// By visible text
await page.getByLabel("Country").selectOption("United Kingdom");
// By value attribute
await page.getByLabel("Country").selectOption({ value: "uk" });
// By zero-based index
await page.getByLabel("Country").selectOption({ index: 2 });
// Multi-select
await page.getByLabel("Interests").selectOption(["Sports", "Music", "Tech"]);For custom dropdowns built with <div> and JavaScript (which <select> semantics don't apply to), it's a click-then-click pattern:
await page.getByRole("combobox", { name: "Category" }).click();
await page.getByRole("option", { name: "Electronics" }).click();Inspect the underlying markup before reaching for .selectOption — if it's a real <select>, use it; if it's a styled component, click through.
.setInputFiles() — file uploads
// Single file
await page.getByLabel("Profile photo").setInputFiles("./test-files/avatar.jpg");
// Multiple
await page.getByLabel("Documents").setInputFiles([
"./test-files/contract.pdf",
"./test-files/addendum.pdf"
]);
// Clear the selection
await page.getByLabel("Profile photo").setInputFiles([]);The path is resolved relative to the current working directory. For uploads that don't have a visible <input type="file"> (some custom uploaders intercept clicks to open a native picker), you wait for the picker event:
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "Upload photo" }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles("./test-files/avatar.jpg");.hover() and .focus()
await page.getByText("Account menu").hover(); // reveal a hover dropdown
await page.getByLabel("Email").focus(); // give an input focus
await page.getByLabel("Email").blur(); // remove focus.hover() is what you reach for when an interaction depends on the mouse being over an element — submenu reveals, tooltips, hover cards.
Actionability — why you don't need explicit waits
Before every action, Playwright runs a checklist:
- Attached to the DOM
- Visible (not
display: none, not zero-sized) - Stable (not animating)
- Enabled (not disabled)
- Editable (for inputs)
- Receives events (not covered by another element)
If any check fails, Playwright keeps re-checking for up to the action timeout (default 30 seconds for actions, 5 seconds for assertions). You almost never need an explicit wait around an action — the framework handles the timing. Where Cypress also auto-retries queries and assertions, Playwright extends the same idea to the action itself: clicking a button that hasn't rendered yet just waits until it does.
A complete registration-form test
Putting every command together:
import { test, expect } from "@playwright/test";
test.describe("User registration", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/register");
});
test("registers a new user with all fields", async ({ page }) => {
await page.getByLabel("First name").fill("Alice");
await page.getByLabel("Last name").fill("Reed");
await page.getByLabel("Email").fill("alice.reed@test.com");
await page.getByLabel("Password").fill("Sup3rS3cret!");
await page.getByLabel("Country").selectOption("United Kingdom");
await page.getByLabel("Profile photo").setInputFiles("./fixtures/avatar.jpg");
await page.getByLabel("Subscribe to newsletter").check();
await expect(page.getByLabel("Subscribe to newsletter")).toBeChecked();
await page.getByLabel("Pro plan").check(); // radio group
await page.getByLabel("Accept terms").check();
await page.getByRole("button", { name: "Register" }).click();
await expect(page).toHaveURL(/welcome/);
await expect(page.getByRole("heading")).toContainText("Welcome, Alice");
});
test("clears and refills a field", async ({ page }) => {
const firstName = page.getByLabel("First name");
await firstName.fill("Bob");
await firstName.clear();
await firstName.fill("Robert");
await expect(firstName).toHaveValue("Robert");
});
test("uses keyboard to submit", async ({ page }) => {
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Password").fill("Sup3rS3cret!");
await page.getByLabel("Password").press("Enter"); // submit on Enter
await expect(page).toHaveURL(/welcome/);
});
});Every field interaction is a single readable line. The await expect(...) after .check() is belt-and-braces — it catches "the click landed but the framework's state didn't update" bugs, which surface when controlled inputs have buggy onChange handlers.
The full registration flow visualised
Step 1 of 5
fill names
page.getByLabel('First name').fill('Alice') — Playwright clears any existing value and dispatches input/change events. Same for last name.
Coming from Cypress?
The mappings:
cy.get(...).click()→await page.locator(...).click()cy.get(...).type('hello')→await page.locator(...).fill('hello')(or.pressSequentiallyfor per-key)cy.get(...).clear()→await page.locator(...).clear()(same name)cy.get(...).check()→await page.locator(...).check()cy.get(...).select('UK')→await page.locator(...).selectOption('UK')cy.get(...).type('{enter}')→await page.locator(...).press('Enter')cy.get(...).attachFile('avatar.jpg')→await page.locator(...).setInputFiles('avatar.jpg')
The two real differences: every action needs await, and Playwright distinguishes fill (one-shot) from pressSequentially (per-key). Cypress's .type() does both jobs and figures out which the input needs; Playwright forces you to be explicit, which is faster in 95% of cases.
⚠️ Common mistakes
- Reaching for
{ force: true }to silence actionability errors. "Element is outside of the viewport" or "element is being intercepted" usually means a modal is open, a sticky header is overlapping, or the test set up the wrong page state. Forcing the click ships a green test that doesn't reflect what a user can actually do. Fix the test scenario first. - Using
.pressSequentially()for normal form fills. It's 10x slower than.fill()because of the per-key delay. Reserve it for inputs that genuinely react to each keystroke (autocomplete, character-by-character validation). For a registration form with name, email, and password,.fill()is correct every time. - Forgetting
awaiton a single action.page.getByRole('button').click()(noawait) returns a Promise that floats — the next line runs against the wrong page state. ESLint's@typescript-eslint/no-floating-promisesrule catches this; turn it on, leave it on.
🎯 Practice task
Build a complete typed checkout-form spec. 25-30 minutes.
-
With
baseURL: "https://www.saucedemo.com"set, write atests/checkout.spec.tsthat handles login inbeforeEach. -
Add a single
test("places an order through the full checkout")that:test("places an order through the full checkout", async ({ page }) => { // Add three items by name for (const item of ["Sauce Labs Backpack", "Sauce Labs Bike Light", "Sauce Labs Bolt T-Shirt"]) { await page .locator(".inventory_item") .filter({ hasText: item }) .getByRole("button", { name: "Add to cart" }) .click(); } // Open the cart await page.locator(".shopping_cart_link").click(); await expect(page.locator(".cart_item")).toHaveCount(3); // Start checkout await page.getByRole("button", { name: "Checkout" }).click(); // Fill the form await page.getByPlaceholder("First Name").fill("Alice"); await page.getByPlaceholder("Last Name").fill("Reed"); await page.getByPlaceholder("Zip/Postal Code").fill("E1 6AN"); await page.getByRole("button", { name: "Continue" }).click(); // Assert summary then finish await expect(page.locator(".cart_item")).toHaveCount(3); await expect(page.locator(".summary_total_label")).toContainText("Total"); await page.getByRole("button", { name: "Finish" }).click(); await expect(page).toHaveURL(/checkout-complete/); await expect(page.getByRole("heading")).toContainText("Thank you"); }); -
Run it. Should pass across all three browsers.
-
Add a second test that fills the form partially (only First Name), clicks Continue, and asserts the error:
await expect(page.locator("[data-test='error']")).toContainText("Last Name is required"). -
Add a third test that demonstrates
.clear()and refill — fill First Name with "Bob",.clear()it, refill as "Robert", assert the value is "Robert" withawait expect(field).toHaveValue("Robert"). -
Force an actionability error. In one test,
await page.locator(".hidden_input").click()against an element that doesn't exist on the page. Read the failure message — Playwright shows you what it tried, which checks failed, and how long it waited. This is the framework being maximally helpful. -
Stretch: find a public form that has a
<select>, a checkbox group, and a radio group (tryhttps://demoqa.com/automation-practice-formor your own app). Write a single test that exercises.selectOption(),.check()on multiple checkboxes,.check()on one radio, and.setInputFiles()for an upload. Confirm every selected value is reflected on the post-submit page.
You now have the four core verbs of Playwright (locator, click, fill, check) under your fingers. The next lesson — assertions — turns the post-action half of the test into a precise tool that turns "the page changed somehow" into "the page changed in this exact way."