You've found the element. Now you need to do something with it. Playwright's action API in Python is the same toolbox as the TypeScript course — click, fill, type, press, check, select_option, set_input_files, hover, clear — in snake_case form, called from sync def tests with no await. Crucially, every action auto-waits for the element to be attached, visible, stable, enabled, and able to receive events. You don't sprinkle time.sleep calls; the framework handles the timing.
click — the workhorse
page.get_by_role("button", name="Submit").click()
page.get_by_role("link", name="Products").click()
page.get_by_text("Read more").click(force=True) # skip actionability checksThree things to know:
- Auto-wait built in.
click()waits up to the action timeout (30 seconds default) for the element to be visible, stable, and able to receive events. No manual wait needed. force=Trueskips the actionability checks — clicks the element even if it's covered, off-screen, or disabled. Reach for it only when you've debugged why the normal click doesn't work and decided to bypass the safety net deliberately.- Click variants:
dblclick()for double-click,click(button="right")for right-click,click(modifiers=["Shift"])to hold a modifier key.
If you're translating from the TypeScript course, the change is await locator.click() → locator.click(). That's it.
fill — clear and type (preferred for forms)
page.get_by_label("Email").fill("alice@test.com")
page.get_by_label("Password").fill("password123")
page.get_by_label("Search").fill("") # clears the inputfill is the right choice for ~95% of form interactions. It clears the existing value, sets the new one in a single operation, and dispatches the input and change events React/Vue/Angular need. For most QA needs, this is the only text-entry method you'll reach for.
type — character by character
When you specifically need keypress events for autocomplete, character counters, or input masks:
page.get_by_label("Search").type("laptop", delay=100) # 100ms between keystype (or its newer alias press_sequentially) dispatches keydown, keypress, and keyup for each character. It's slower than fill and only necessary when the page has key-by-key behaviour. The delay parameter throttles between keys — useful when an autocomplete dropdown only appears after a user-realistic typing speed.
If you don't have a specific reason to use type, use fill. It's faster and more reliable.
press — keyboard keys
page.get_by_label("Search").press("Enter")
page.keyboard.press("Escape")
page.keyboard.press("Control+a") # select all
page.keyboard.press("Shift+Tab") # focus previousTwo flavours:
locator.press(key)— focuses the locator first, then presses the key. Right tool for "submit form by pressing Enter on the email field."page.keyboard.press(key)— presses without focusing anything specific. Right tool for global shortcuts like Escape, Ctrl+A, Cmd+K.
Modifier syntax matches the platform's actual key names: "Control", "Shift", "Alt", "Meta" (Cmd on macOS). Combine with +. The full list of recognised key names is in the Playwright docs.
check / uncheck — checkboxes and radios
page.get_by_label("Remember me").check()
page.get_by_label("Newsletter").uncheck()
page.get_by_label("Monthly plan").check() # radios use check toocheck() is idempotent — it ensures the box ends up checked, regardless of starting state, and verifies the result. uncheck() is the inverse. Compare to click(), which blindly toggles the box — calling click on an already-checked box would uncheck it. Always reach for check/uncheck over click for boolean inputs.
select_option — native dropdowns
page.get_by_label("Country").select_option("UK") # by value attribute
page.get_by_label("Country").select_option(value="uk") # explicit by value
page.get_by_label("Country").select_option(label="United Kingdom") # by visible label
page.get_by_label("Country").select_option(index=2) # by zero-indexed position
page.get_by_label("Size").select_option(["S", "M"]) # multi-selectselect_option works on native <select> elements only. For custom (div-based) dropdowns, you click to open and click the option as a normal element:
page.get_by_role("combobox", name="Role").click()
page.get_by_role("option", name="Tester").click()The combobox role + option role pattern is what most modern design systems (Material UI, Headless UI, Radix) render. When in doubt, inspect the DOM — if it's a <select>, use select_option; if it's <div role="combobox">, click and click.
set_input_files — file uploads
page.get_by_label("Upload photo").set_input_files("photo.jpg")
page.get_by_label("Documents").set_input_files(["doc1.pdf", "doc2.pdf"])
page.get_by_label("Upload").set_input_files([]) # clear the selectionset_input_files skips the OS file picker entirely — it attaches files to the input directly and fires the right change events. Pass a single path for one file, a list for multi-upload, or an empty list to clear. For in-memory uploads, pass a dict with the buffer:
page.get_by_label("Upload").set_input_files({
"name": "report.csv",
"mimeType": "text/csv",
"buffer": b"id,name\n1,Alice\n2,Bob"
})This is invaluable for tests that don't want to rely on a real file existing on disk — generate the bytes in the test, upload, assert.
hover — mouseover interactions
page.get_by_text("Menu").hover()
page.get_by_role("img", name="Avatar").hover()hover triggers mouseenter and mouseover events. Use it to reveal hover-only menus, tooltips, or "edit" buttons that appear on row hover.
clear — empty an input
page.get_by_label("Email").clear()clear is the explicit form of fill(""). Use it when you want to communicate intent: "empty this field, then assert the validation error appears." Either call works; clear reads better.
All actions auto-wait — no manual waits needed
Every action listed above waits for the element to be:
- Attached to the DOM
- Visible (non-zero size, not
display: none) - Stable (no animation in progress)
- Enabled (not disabled, not
aria-disabled) - Receiving events (no other element on top)
If the element doesn't meet these conditions within the action timeout, Playwright throws an Error describing exactly which condition failed. This is what makes time.sleep(2) an anti-pattern — Playwright already waits, and it does so smarter than a fixed sleep.
A complete form interaction — the flow
Step 1 of 5
Navigate
page.goto('/register') resolves against base_url and waits for the load event before returning. Auto-waits do the rest.
A complete QA example — registration form
from playwright.sync_api import Page, expect
def test_registration_form_happy_path(page: Page):
page.goto("/register")
# Text inputs — fill is the default
page.get_by_label("Full name").fill("Alice Smith")
page.get_by_label("Email").fill("alice@test.com")
page.get_by_label("Password").fill("SecurePass123")
# Native dropdown
page.get_by_label("Country").select_option(label="United Kingdom")
# Custom combobox (div-based)
page.get_by_role("combobox", name="Role").click()
page.get_by_role("option", name="Tester").click()
# Checkboxes — idempotent toggles
page.get_by_label("I agree to the terms").check()
page.get_by_label("Subscribe to newsletter").uncheck()
# Radio button
page.get_by_label("Monthly plan").check()
# File upload
page.get_by_label("Profile photo").set_input_files("photo.jpg")
# Submit
page.get_by_role("button", name="Register").click()
# Verify
expect(page).to_have_url("/welcome")
expect(page.get_by_text("Welcome, Alice")).to_be_visible()Read it as a flow: navigate, fill the form, pick from dropdowns, toggle the booleans, upload a file, submit, verify. Twelve lines of action, three lines of assertion — the shape of every form-driven QA test you'll ever write.
Coming from Playwright TypeScript?
The mapping is the same as locators — drop await, switch to snake_case keyword args:
await locator.click()→locator.click()await locator.fill('...')→locator.fill("...")await locator.selectOption({ label: 'UK' })→locator.select_option(label="UK")await locator.setInputFiles('...')→locator.set_input_files("...")await locator.press('Enter')→locator.press("Enter")
The behaviour, the auto-wait, the actionability checks — all identical. Same engine.
⚠️ Common mistakes
- Reaching for
click()on a checkbox.click()blindly toggles, so calling it on an already-checked box unchecks it.check()is idempotent and asserts the resulting state. Always prefercheck/uncheckfor boolean inputs — same applies to radios. - Using
type()whenfill()would do.type()dispatches per-character events and runs noticeably slower thanfill(). Reach for it only when the page genuinely has key-by-key behaviour (autocomplete that fires per keystroke, character counters that update live). For a normal<input>filled and submitted,fillis faster and more reliable. - Forgetting that custom dropdowns aren't native
<select>elements.select_optiononly works on real<select>tags. If the dropdown is a styled<div role="combobox">— common in Material UI, Headless UI, Radix —select_optionraisesElement is not a <select>. Click to open, then click the option as a normal element.
🎯 Practice task
Build a registration-form interaction test on your own demo or a public form. 25-30 minutes.
-
Create
tests/test_form_actions.pyagainst Sauce Demo's checkout flow (login + add to cart + checkout):from playwright.sync_api import Page, expect class TestCheckoutForm: def test_complete_checkout(self, page: Page): # Log in and add a product page.goto("/") page.get_by_placeholder("Username").fill("standard_user") page.get_by_placeholder("Password").fill("secret_sauce") page.get_by_role("button", name="Login").click() page.locator(".inventory_item").filter(has_text="Backpack") \ .get_by_role("button", name="Add to cart").click() expect(page.locator(".shopping_cart_badge")).to_have_text("1") # Open the cart and start checkout page.locator(".shopping_cart_link").click() page.get_by_role("button", name="Checkout").click() # Fill the checkout form page.get_by_placeholder("First Name").fill("Alice") page.get_by_placeholder("Last Name").fill("Smith") page.get_by_placeholder("Zip/Postal Code").fill("SW1A 1AA") page.get_by_role("button", name="Continue").click() # Confirm the order summary and finish expect(page.get_by_text("Backpack")).to_be_visible() page.get_by_role("button", name="Finish").click() expect(page.get_by_text("Thank you for your order")).to_be_visible() -
Run it with
pytest tests/test_form_actions.py -v --headed. Watch the form fill itself in Chromium. -
Demonstrate auto-wait. Add a deliberate slowdown — open Chrome devtools when the test pauses (use
page.pause()), throttle the network to "Slow 3G", resume. The test still passes —fillandclickwaited for each element to be ready. Notime.sleepneeded. -
Force the wrong action. Replace the
.check()on the agree-to-terms checkbox in your registration form (if you have one) with.click(). Run the test twice in a row without resetting state. The second run unchecks it — that's the click-toggle bug. Switch back to.check()and the test is idempotent again. -
Stretch: add an in-memory file upload test. Find a form on your demo app that takes a file and use the buffer-dict form:
page.get_by_label("Upload").set_input_files({"name": "test.csv", "mimeType": "text/csv", "buffer": b"..."} ). Confirm the upload succeeds without ever touching the disk. This pattern is gold for CI runners that don't have your dev-machine fixture files.
You've got the action vocabulary. Next lesson is the assertion side of the equation — expect, web-first vs non-retrying assertions, soft assertions, and the snake_case differences that matter.