Playwright Testing With Python
Use when asked to write, explain, debug, or extend Playwright browser automation code in Python — locating elements, performing actions, waiting, and writing real assertions with pytest-playwright.
Playwright is Microsoft's browser automation library: one API drives Chromium, Firefox, and WebKit, with a Python binding available as the PyPI package playwright. See https://playwright.dev/.
Install
For writing browser-automation scripts:
pip install playwright
playwright install
For writing real tests with pytest:
pip install pytest-playwright
playwright install
playwright install downloads the actual browser binaries. Playwright does not bundle them with the Python package, so this step is required after either install — skipping it is one of the most common first-run failures (see Common pitfalls below).
Four core concepts
Any browser automation tool needs to do four things: locate an element, act on it, wait for it to be ready, and assert something about it. Playwright's Python API shapes each of these distinctly:
- Locating. Everything starts with
page.locator(...), which returns aLocator— a lazy, re-queryable reference to an element (or elements), not a snapshot. Locators can be built from CSS ('#id-example-1','.class-example-1'), attribute selectors ('[name="name-example-1"]'), XPath ('xpath=//input[@type="submit"]'), or text content (page.locator('a', has_text='Link Example 1')). Because a locator is lazy, you can hold onto it and it will re-find the live element every time you use it.
- Acting. Locators expose action methods that mirror what a user does:
.click(),.fill(text),.check(),.select_option(...),.hover(),.press(key). These act on real elements, not raw DOM nodes, so they fail loudly if the target turns out not to be interactable.
- Waiting. This is Playwright's headline advantage over tools like Selenium: locators auto-wait and auto-retry. Before
.click()or.fill()runs, Playwright waits for the element to be attached, visible, stable (not animating), and able to receive events — up to a timeout — with no explicitsleep()or manual polling loop required. The same auto-retry applies to assertions (see below).
- Asserting. For actual test suites, use
playwright.sync_api.expect()web-first assertions instead of a bare Pythonassert. They retry until the condition holds or a timeout expires, which absorbs normal timing flakiness (an element rendering a moment late, text arriving after a fetch) without you writing any wait code.
sync_api vs. async_api
Playwright ships two Python APIs with matching method names:
playwright.sync_api— blocking calls, ordinary functions, noasync/await. Recommended for beginners and for most scripts and test suites: it reads top-to-bottom and is whatpytest-playwrightuses under the hood.playwright.async_api—async/awaitthroughout. Reach for it when you need real concurrency, e.g. driving several pages/contexts in parallel inside oneasyncioevent loop.
Never mix the two in one file (see Common pitfalls).
Worked example
This is the real, working walkthrough style, trimmed from demo-playwright-python's src/demo.py. It opens a browser, finds elements several different ways, and performs a few actions, printing what it finds as it goes:
from playwright.sync_api import sync_playwright, Locator
def demo() -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
try:
page.goto("https://testingexamples.github.io")
# Find an element by id.
element_by_id: Locator = page.locator('#id-example-1')
print(element_by_id.evaluate('(el) => el.outerHTML'))
# Find an element by name attribute.
element_by_name: Locator = page.locator('[name="name-example-1"]')
print(element_by_name.evaluate('(el) => el.outerHTML'))
# Find an element by class name.
element_by_class_name: Locator = page.locator('.class-example-1')
print(element_by_class_name.evaluate('(el) => el.outerHTML'))
# Find a link element by its visible text.
element_by_link_text: Locator = page.locator('a', has_text='Link Example 1')
print(element_by_link_text.evaluate('(el) => el.outerHTML'))
# Find an element by XPath.
element_by_xpath: Locator = page.locator('xpath=//input[@type="submit"]')
print(element_by_xpath.evaluate('(el) => el.outerHTML'))
# Fill a text input.
text: Locator = page.locator('#text-example-1-id')
text.fill("hello")
# Check a checkbox.
checkbox: Locator = page.locator('#checkbox-example-1-id')
checkbox.check()
# Check a radio button.
radio: Locator = page.locator('#radio-example-1-option-1-id')
radio.check()
# Select an option by index.
select_element: Locator = page.locator('#select-example-1-id')
select_element.select_option(index=0)
print(select_element.input_value())
except Exception as err:
print(f"Error: {err}")
finally:
browser.close()
if __name__ == "__main__":
demo()
Note the try/finally: browser.close() must run even if something above raises, or the browser process leaks.
From walkthrough to real test
The script above is a walkthrough: it prints HTML and values for a human to read, and never actually confirms anything. That's fine for exploring a page, but it is not a test — nothing fails if the page changes.
A real test replaces print(...) with an assertion, and replaces bare Python asserts with Playwright's own auto-retrying expect(). Using pytest with the pytest-playwright plugin also gets you a ready-made page fixture, so there's no sync_playwright()/launch()/close() boilerplate to write or clean up:
from playwright.sync_api import Page, expect
def test_id_example_has_expected_text(page: Page) -> None:
page.goto("https://testingexamples.github.io")
expect(page.locator("#id-example-1")).to_have_text("Id Example 1")
Run it with pytest (no test runner code of your own needed). Why prefer expect(...).to_have_text(...) over assert locator.text_content() == ...? The latter reads the text once, immediately, and fails if the page hasn't finished rendering yet. expect() assertions retry against the live page until the condition becomes true or a timeout elapses — the same auto-waiting behavior locators use for actions, applied to assertions. This removes an entire category of flaky test caused by manual polling, sleeps, or races between page updates and assertion checks.
For a full real pytest suite with real assertions against a production site, see demo-playwright-python-for-nhs-wales.
Common pitfalls
- Forgetting to close the browser.
browser.close()must run even on error — wrap it intry/finallyin a plain script, or let apytest-playwrightfixture handle it for you in tests. - Mixing
sync_apiandasync_apiin one file. Pick one per file/ module; importing both and interleaving sync and async calls raises runtime errors. - Skipping
playwright install.pip install playwright(orpytest-playwright) installs only the Python package — the browser binaries (Chromium, Firefox, WebKit) are a separate download fetched byplaywright install. Forgetting this step is the most common "browser executable doesn't exist" error. - Strict-mode violations. By default, a locator that resolves to more than one element raises an error on action or assertion, rather than silently picking the first match. Narrow the locator (add an id, text filter, or
.first/.nth(i)) instead of assuming "first match wins".
Learn more / real examples
- demo-playwright-python — the locator-strategy walkthrough this skill's worked example is trimmed from, run against https://testingexamples.github.io.
- demo-playwright-python-for-google-search — same patterns applied to Google Search. Illustrative only — Google's Terms of Service restrict automated querying of Google Search, so this is meant to show syntax, not to be run repeatedly against the live site.
- demo-playwright-python-for-google-maps — same patterns applied to Google Maps. Illustrative only, for the same Google Terms of Service reason as above.
- demo-playwright-python-for-nhs-wales — a real pytest test suite with real assertions against https://www.nhs.wales/.
- https://playwright.dev/python/docs/intro — official Python docs.
- https://testingexamples.github.io/ — the free fixture page these demos target; safe to run against repeatedly.
---
AGENTS.md and spec/index.md in this repo are the source of truth for this skill's own scope — if this file ever disagrees with those, they win.