What Is Playwright? A Practical Guide for Developers
Playwright is Microsoft's open-source browser automation tool. It drives Chromium, Firefox, and WebKit through one API, runs headless in CI, and is the default choice for Playwright testing in 2026. If you need to click buttons, fill forms, and assert on what users actually see, this is the library most teams reach for first.
This guide answers what Playwright is, what it does, how Playwright testing differs from Playwright automation, and how to use Playwright with Python from a minimal script through a real login and GitHub Actions. For the official API reference and language guides, see playwright.dev. Working Python demos for every step below live in the companion repo: github.com/Inekss/test_playwright.
What is Playwright
Playwright is a browser automation tool you install and run yourself. It is not a cloud product and not a full product suite. Bindings exist for JavaScript/TypeScript, Python, Java, and .NET.
What does Playwright do
You use it to:
- Launch a browser (headless or headed).
- Open pages and navigate URLs.
- Interact: click a button, type, upload files, intercept network.
- Assert on DOM state, accessibility trees, responses, and console output.
- Capture a Playwright screenshot or a trace when something fails.
The object model has four layers:
| Layer | Role | Typical call |
|---|---|---|
| Browser | Engine process | chromium.launch() |
| BrowserContext | Isolated session (cookies, storage) | browser.new_context() |
| Page | One tab | context.new_page() |
| Locator | Element handle with auto-waiting | page.get_by_role("button", name="Sign in") |
The design bet is web-first assertions. Instead of sleep(3) and hoping the DOM updated, you wait on a condition: the element is visible, the text matches, the URL changed. Playwright retries until the condition holds or a timeout expires. That auto-waiting is why the same scripts stay stable in CI and when you later schedule them against production as continuous checks. Running those journeys without a visible window is covered under headless browser checks; headless browser monitoring.
Is Playwright open source
Yes. Playwright is Apache 2.0, maintained by Microsoft. You install the language package, then download browser binaries with playwright install. There is no license fee for the tool itself.
Minimal script (Python)
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://playwright.dev/python/") print(page.title()) # "Playwright Python" browser.close()
Same idea in TypeScript
import { chromium } from "playwright"; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); await page.goto("https://playwright.dev/"); console.log(await page.title()); await browser.close();
Same model in both languages: launch → new page → goto → assert or read. This page is the practical overview; use the official docs linked above for the full API reference by language.
What is a Page in Playwright
A Page is one browser tab. Almost every action in Playwright testing goes through it: navigate, find elements, click, type, read the title, take a screenshot.
Navigate and inspect
page.goto("https://playwright.dev/python/") print(page.title()) print(page.url) page.reload() page.go_back()
goto waits for the load state you ask for (default load). Prefer asserting on content over wait_for_load_state("networkidle") when the page keeps open connections.
Find elements (prefer these over raw CSS)
page.get_by_role("button", name="Get started") page.get_by_label("Email address") page.get_by_placeholder("Search") page.get_by_text("Docs") page.get_by_test_id("submit") page.locator("css=.legacy-only-when-needed")
Role and label locators match how assistive tech sees the page. They survive CSS refactors better than .btn-primary-2.
Playwright click (and fill)
To click a button or link, prefer role-based locators:
page.get_by_role("link", name="Docs").click() page.get_by_role("button", name="Continue", exact=True).click() page.get_by_label("Email address").fill("user@example.com") # Prefer role=textbox for password fields (see login example below) page.get_by_role("textbox", name="Password").fill("secret") page.locator("input[type=file]").set_input_files("receipt.pdf")
fill clears the field and types the value. click auto-waits until the control is visible, enabled, and stable. That built-in wait is why you rarely need a fixed sleep before a Playwright click.
Playwright wait, expect, and assertions
Playwright wait behavior is built into actions and into expect. Assertions in Playwright are web-first: they retry until the condition holds or the timeout expires.
from playwright.sync_api import expect page.wait_for_url("**/dashboard") expect(page.get_by_role("heading", name="Dashboard")).to_be_visible() expect(page).to_have_title("Dashboard") expect(page.get_by_text("Welcome back")).to_contain_text("Welcome")
Common Playwright expect checks: to_be_visible, to_have_title, to_have_url, to_contain_text, to_have_count. Prefer these Playwright assertions over assert element.is_visible() after a manual sleep.
Playwright screenshot and traces
page.screenshot(path="failure.png", full_page=True) # Trace for CI / monitoring forensics context = browser.new_context() context.tracing.start(screenshots=True, snapshots=True, sources=True) page = context.new_page() # ... run the journey ... context.tracing.stop(path="trace.zip") # Open with: playwright show-trace trace.zip
A Locator is not a snapshot of the DOM at query time. It re-resolves when you act on it. Playwright auto-waits for the element to be actionable before each action. When a scheduled check fails, screenshot plus trace is how you debug without reproducing by hand. For a deeper look at failure forensics in browser checks, browser monitoring with Playwright.
Playwright codegen
To record a flow instead of writing locators by hand:
playwright codegen https://app.devhelm.io/login
Copy the generated locators into a small class, then tighten them (exact button names, role=textbox for password). Codegen is the fastest way to draft a first script; production checks still need stable roles and assertions.
Get posts like this in your inbox
Engineering insights on monitoring, incidents, and uptime. Bi-weekly, 2-minute read.
What is Playwright testing vs Playwright automation
The same Playwright automation tool shows up under two search intents:
- Playwright testing usually means end-to-end checks in a test runner (pytest, Jest, or
@playwright/test). Goal: catch regressions before merge. - Playwright automation is the broader capability: drive a browser for scraping, codegen, PDF generation, screenshots, or scheduled health checks. Goal: make the browser do work without a human.
Same API. Different cadence and environment. A checkout flow you assert in CI is Playwright testing. That same flow run every 15 minutes against production is still automation, now aimed at reliability. For how to schedule an existing journey as a continuous check, Playwright monitoring.
Why Playwright matters for monitoring
CI answers "did this branch break the app?" It does not answer "is production working for users right now?" A third-party script, a CDN blip, or a half-failed deploy can break a journey after merge, when nothing re-runs the suite.
Running browser scripts on a schedule against live URLs closes that gap. That practice is synthetic monitoring: outside-in checks that assert what users see, not only what the server returns. what synthetic monitoring is. Teams already writing Playwright tests get a short path: reuse the journey, schedule it, alert on failure. The Node scheduling and failure-forensics paths are covered in the linked guides above. Below is how to use Playwright with Python.
How to use Playwright with Python
Most tutorials assume TypeScript. Python teams (Django, FastAPI, data platforms) often want the same browser automation in the language they already ship.
Playwright Python has two APIs:
- Sync (
playwright.sync_api): blocking calls, reads like a script. Best for cron jobs, one-off checks, and learning. - Async (
playwright.async_api):async/await, for asyncio services.
For learning and scheduled checks, sync is enough. Prefer role- and label-based locators over brittle CSS.
Clone the example repo and follow along: github.com/Inekss/test_playwright. It has runnable code for every step below. Four practical topics on this page:
- Simple setup with Playwright: install Playwright and prove the browser works.
- Example of usage with Playwright: open a real site, locate elements, count them.
- Simulate login with Playwright: email + password on
app.devhelm.iowith your own account. - Automation with GitHub Actions: run the blog-count check in GitHub Actions.
Project layout
| Path | Role |
|---|---|
main.py | Config constants + if __name__ == "__main__" |
run_ci.py | CI entrypoint: blog count only (no login) |
.github/workflows/blog-count.yml | Runs run_ci.py on push/PR to main |
utils/browser.py | BrowserSession (open/close Chromium) |
checks/smoke.py | Setup check: goto URL, assert title |
checks/blog_count.py | Usage example: count post cards on a listing page |
examples/login/login_check.py | Login example: Auth0 email/password on app.devhelm.io |
Shared browser session
Reuse one Chromium process across checks:
# utils/browser.py from typing import Optional from playwright.sync_api import Browser, Page, Playwright, sync_playwright class BrowserSession: """Open one Chromium tab, close it when done.""" def __init__(self, headless: bool = True) -> None: self.headless = headless self._playwright: Optional[Playwright] = None self._browser: Optional[Browser] = None self.page: Optional[Page] = None def __enter__(self) -> "BrowserSession": self._playwright = sync_playwright().start() self._browser = self._playwright.chromium.launch(headless=self.headless) self.page = self._browser.new_page() return self def __exit__(self, *_exc: object) -> None: if self._browser is not None: self._browser.close() if self._playwright is not None: self._playwright.stop()
Set headless=False while debugging so you can watch the browser.
1. Simple setup with Playwright
Goal: confirm pip, the playwright package, and Chromium are installed. No product logic yet, only "can I launch a browser and read a title?"
Install once:
python -m venv .venv # Windows: .venv\Scripts\activate # macOS/Linux: source .venv/bin/activate pip install -r requirements.txt # playwright>=1.44 playwright install chromium
The setup check:
# checks/smoke.py from playwright.sync_api import Page class SmokeCheck: def __init__(self, url: str, expected_title: str) -> None: self.url = url self.expected_title = expected_title def run(self, page: Page) -> str: page.goto(self.url) title = page.title() assert title == self.expected_title, f"Unexpected title: {title!r}" return title
Wire it in main.py:
SMOKE_URL = "https://playwright.dev/python/" SMOKE_EXPECTED_TITLE = "Playwright Python" with BrowserSession(headless=True) as session: assert session.page is not None title = SmokeCheck(SMOKE_URL, SMOKE_EXPECTED_TITLE).run(session.page) print(f"OK: {title}")
Run:
python main.py
You are set up when the output includes:
OK: Playwright Python
If that line is missing or the assert fails, fix install before anything else: re-run pip install -r requirements.txt and playwright install chromium. Page titles on third-party sites do change; if the assert fails with a different string, print page.title() and update the constant.
2. Example of usage with Playwright
Goal: use the same Page API on a live site. Open https://devhelm.io/blog, find article cards, and print how many are on the page. Today that listing shows 20 posts, so the script prints:
Blog posts on page: 20
Each card is a link to /blog/<slug> with an h2 title:
# checks/blog_count.py from playwright.sync_api import Page class BlogCountCheck: def __init__(self, url: str) -> None: self.url = url def run(self, page: Page) -> int: page.goto(self.url) cards = page.locator('a[href^="/blog/"] h2') cards.first.wait_for(state="visible") return cards.count()
BLOG_URL = "https://devhelm.io/blog" count = BlogCountCheck(BLOG_URL).run(session.page) print(f"Blog posts on page: {count}")
What this example shows:
page.goto: navigatepage.locator: find elementswait_for: wait for the page to be ready (nosleep)count: read a number from the DOM
You can also assert with expect instead of returning a count:
from playwright.sync_api import expect page.goto("https://devhelm.io/blog") cards = page.locator('a[href^="/blog/"] h2') expect(cards.first).to_be_visible() expect(cards).not_to_have_count(0)
Counting cards on a public listing is a light example of scraping-style automation: open the page, wait for the list, read the DOM. The same pattern scales to checkout pages, search results, and pricing tables once you swap the locator.
3. Simulate login with Playwright
Goal: drive a real multi-step login. https://app.devhelm.io/login redirects to Auth0: email → Continue → password → Continue → back to the app.
Use your own DevHelm account. Put the values in main.py (leave them empty so the public repo skips this step):
LOGIN_URL = "https://app.devhelm.io/login" LOGIN_EMAIL = "" # set your own LOGIN_PASSWORD = "" # set your own
The check:
# examples/login/login_check.py import re from playwright.sync_api import Page, expect class LoginCheck: """Two-step Auth0 login: email → Continue → password → Continue.""" def __init__(self, url: str, email: str, password: str) -> None: self.url = url self.email = email self.password = password def run(self, page: Page) -> str: page.goto(self.url) page.get_by_label("Email address").fill(self.email) page.get_by_role("button", name="Continue", exact=True).click() # Auth0 also renders a "Show password" switch whose aria-label # matches get_by_label("Password") and trips strict mode. password = page.get_by_role("textbox", name="Password") password.wait_for(state="visible") password.fill(self.password) page.get_by_role("button", name="Continue", exact=True).click() expect(page).to_have_url( re.compile(r"https://app\.devhelm\.io/"), timeout=30_000, ) assert "/u/login" not in page.url, f"Still on Auth0: {page.url}" return page.url
Wire it:
if LOGIN_EMAIL and LOGIN_PASSWORD: url = LoginCheck(LOGIN_URL, LOGIN_EMAIL, LOGIN_PASSWORD).run(session.page) print(f"Logged in: {url}") else: print("Skip login: set LOGIN_EMAIL and LOGIN_PASSWORD in main.py")
What this example shows:
- Multi-step forms (email screen, then password screen)
- Role/label locators on a real Auth0 UI
- Strict-mode pitfalls when two controls share a label substring
- Waiting for a post-login URL instead of a fixed sleep
Do not commit real passwords. Keep credentials only in your local main.py. Login is the journey most teams promote from a local script into a scheduled production check first, because auth breaks silently and status codes alone miss it.
4. Automation with GitHub Actions
A script that only runs on your laptop is not automation yet. Step 4 lives in github.com/Inekss/test_playwright, not in a monorepo. On every push or merge to main, GitHub Actions runs the blog-count check against https://devhelm.io/blog. No login, no secrets. Keeping checks in git so environments stay reproducible is the same idea as monitoring as code; monitoring as code.
CI entrypoint (run_ci.py):
# run_ci.py from checks.blog_count import BlogCountCheck from utils.browser import BrowserSession BLOG_URL = "https://devhelm.io/blog" EXPECTED_MIN_POSTS = 20 def main() -> None: with BrowserSession(headless=True) as session: assert session.page is not None count = BlogCountCheck(BLOG_URL).run(session.page) print(f"Blog posts on page: {count}") assert count >= EXPECTED_MIN_POSTS, f"expected >= {EXPECTED_MIN_POSTS}, got {count}" print(f"OK: {count} >= {EXPECTED_MIN_POSTS}") if __name__ == "__main__": main()
Workflow (.github/workflows/blog-count.yml in the test_playwright repo):
name: Blog count on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: jobs: blog-count: name: Count posts on devhelm.io/blog runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | pip install -r requirements.txt playwright install --with-deps chromium - name: Count blog posts run: python run_ci.py env: BLOG_URL: https://devhelm.io/blog EXPECTED_MIN_POSTS: "20"
What this step shows:
- Same
BlogCountCheckas step 2, now headless in CI - Runs on every push to
mainand on every PR intomain(merge path) - Repo root is the project: no
working-directoryprefix - A floor of 20 posts (
EXPECTED_MIN_POSTS) so the check stays green as the listing grows - No credentials in the workflow
Locally mirror CI with python run_ci.py. On failure the job exits non-zero and the PR (or push to main) stays red until the listing or the locator is fixed.
Full main.py entrypoint
from checks.blog_count import BlogCountCheck from checks.smoke import SmokeCheck from examples.login.login_check import LoginCheck from utils.browser import BrowserSession HEADLESS = True SMOKE_URL = "https://playwright.dev/python/" SMOKE_EXPECTED_TITLE = "Playwright Python" BLOG_URL = "https://devhelm.io/blog" LOGIN_URL = "https://app.devhelm.io/login" LOGIN_EMAIL = "" LOGIN_PASSWORD = "" def main() -> None: with BrowserSession(headless=HEADLESS) as session: assert session.page is not None title = SmokeCheck(SMOKE_URL, SMOKE_EXPECTED_TITLE).run(session.page) print(f"OK: {title}") count = BlogCountCheck(BLOG_URL).run(session.page) print(f"Blog posts on page: {count}") if LOGIN_EMAIL and LOGIN_PASSWORD: url = LoginCheck(LOGIN_URL, LOGIN_EMAIL, LOGIN_PASSWORD).run( session.page ) print(f"Logged in: {url}") else: print("Skip login: set LOGIN_EMAIL and LOGIN_PASSWORD in main.py") if __name__ == "__main__": main()
Example output (with login configured):
OK: Playwright Python
Blog posts on page: 20
Logged in: https://app.devhelm.io/...
Common pitfalls (with fixes)
Strict mode violation. get_by_label("Password") can match both the password input and a "Show password" toggle. Prefer get_by_role("textbox", name="Password") or page.locator("#password").
# Bad: may match 2 elements page.get_by_label("Password").fill(secret) # Good: one input page.get_by_role("textbox", name="Password").fill(secret)
Fixed sleeps. Prefer expect / wait_for over time.sleep(3). Sleeps flake under load and slow CI.
# Bad time.sleep(3) assert page.get_by_text("Done").is_visible() # Good expect(page.get_by_text("Done")).to_be_visible()
Brittle CSS. Prefer roles, labels, and data-testid over hashed class names.
# Bad: breaks on redesign page.locator(".css-1a2b3c > button").click() # Good page.get_by_role("button", name="Save").click() # or page.get_by_test_id("save-button").click()
Headed debug. When a headless run fails, re-run with headless=False and optionally slow the actions:
browser = p.chromium.launch(headless=False, slow_mo=200)
Codegen. To record a flow instead of writing locators by hand, use playwright codegen (see the Playwright codegen section above). Copy the generated locators into a class like LoginCheck, then tighten them (exact button names, role=textbox for password).
When a CI or scheduled run fails, fix the locator or the product, then make sure alerts fire only on real breakage. Noisy pages create alert fatigue; for how to keep monitoring signals actionable, alert fatigue.
From local script to production monitor
A script that passes on your laptop is a CI check or a monitor waiting for a schedule. The path is the same for most teams: write the journey locally, run it in CI on every PR, capture screenshot and trace on failure, then schedule it against production and alert when it fails.
The most common Playwright usage patterns teams automate this way:
- Login — email/password or SSO, then assert the post-auth landing page
- Checkout — add to cart, fill payment fields, confirm the order success screen
- Form submit — signup, contact, or settings save with validation and redirect checks
- Search and filter — type a query, wait for results, assert count or first hit
- Scraping / data extraction — open listing pages, collect titles, prices, or links
- File upload — attach a document, wait for processing, assert the confirmation state
- Multi-step wizards — onboarding or checkout flows across several screens
- Auth-gated dashboards — after login, assert charts, tables, or empty states load
Once the journey is stable locally and in CI, schedule the same script against production. That is the move from a local Playwright script to a production monitor. For how outside-in browser checks fit next to other synthetic practices, synthetic monitoring best practices. When you are choosing a platform rather than writing the runner yourself, best synthetic monitoring tools.
Get posts like this in your inbox
Engineering insights on monitoring, incidents, and uptime. Bi-weekly, 2-minute read.