Playwright
Playwright is a browser automation and testing framework from Microsoft. It drives Chromium, Firefox and WebKit (the engine behind Safari) with one API, and it was designed to remove the biggest pain of UI testing: flaky timing. Playwright waits for elements to be ready before acting on them and retries assertions until they pass, so you almost never write a wait yourself. This lesson uses Playwright 1.64, the current release.
In this lesson you will install Playwright for Python, write the login tests from the Selenium lesson the Playwright way, understand auto-waiting and auto-retrying assertions, and see the JavaScript test runner and the debugging tools.
Install
Playwright for Python works with pytest through the pytest-playwright plugin. The second command downloads the browsers Playwright controls:
python -m pip install pytest-playwright
playwright installBy default tests run in Chromium, headless (no window). Useful options are pytest --headed to watch the browser, --browser firefox or --browser webkit to switch engines, and --browser-channel chrome to use the Chrome or Edge already installed on your computer.
Locators that read like the page
Playwright recommends user-facing locators: find elements the way a user or a screen reader would, by role, label and visible text. In priority order:
| Locator | Finds | Example |
|---|---|---|
get_by_role() | Elements by accessible role and name | page.get_by_role("button", name="Log in") |
get_by_label() | Form fields by their <label> text | page.get_by_label("Email") |
get_by_placeholder() | Inputs by placeholder | page.get_by_placeholder("Search books") |
get_by_text() | Elements by visible text | page.get_by_text("Out of stock") |
get_by_alt_text() | Images by alt text | page.get_by_alt_text("Kirana Kart logo") |
get_by_title() | Elements by title attribute | page.get_by_title("Close") |
get_by_test_id() | Elements by data-testid | page.get_by_test_id("cart-count") |
Locators are strict: if an action such as click() matches more than one element, Playwright raises an error instead of guessing. Role and label locators have a bonus: if they cannot find your button, a screen reader user probably cannot either, which links UI testing with accessibility testing.
Your first Playwright tests
The page argument is a fixture from pytest-playwright: a fresh browser page for every test, closed automatically afterwards.
# test_login_playwright.py
import re
from playwright.sync_api import Page, expect
LOGIN_URL = "http://localhost:8000/login.html"
def test_valid_login_opens_dashboard(page: Page):
page.goto(LOGIN_URL)
page.get_by_label("Email").fill("priya@example.com")
page.get_by_label("Password").fill("Kart@2026")
page.get_by_role("button", name="Log in").click()
expect(page.get_by_role("heading", name="Hello, Priya")).to_be_visible()
expect(page).to_have_title(re.compile("Dashboard"))
expect(page.get_by_test_id("cart-count")).to_have_text("Cart: 0 items")
def test_wrong_password_shows_error(page: Page):
page.goto(LOGIN_URL)
page.get_by_label("Email").fill("priya@example.com")
page.get_by_label("Password").fill("wrong-pass")
page.get_by_role("button", name="Log in").click()
expect(page.get_by_role("alert")).to_have_text("Invalid email or password")The results appear 0.8 seconds after the click, yet there is no wait anywhere in this code. Run pytest -v:
test_login_playwright.py::test_valid_login_opens_dashboard[chromium] PASSED [ 33%] test_login_playwright.py::test_wrong_password_shows_error[chromium] PASSED [ 66%]
The [chromium] suffix shows which browser ran the test; with --browser firefox --browser webkit each test runs once per browser.
Auto-waiting and auto-retrying assertions
Two features make this work:
- Auto-waiting actions. Before
click(), Playwright checks that the element is visible, stable (not moving), able to receive events (not covered by another element) and enabled.fill()also checks that the field is editable. If the checks do not pass within the timeout, the action fails with aTimeoutError. - Auto-retrying assertions.
expect(locator).to_have_text(...),to_be_visible(),to_have_count(),expect(page).to_have_url(...)and similar assertions keep re-checking until they pass or the timeout ends. The default assertion timeout is 5 seconds; change it per call withtimeout=10_000(milliseconds) or for all tests withexpect.set_options(timeout=10_000)inconftest.py.
The idea behind a retrying assertion fits in a few lines. Run this simulation, where a fake page shows its message 800 ms after a click:
// A fake page: the alert text appears 800 ms after the click
const startedAt = Date.now();
const page = {
alertText() {
return Date.now() - startedAt >= 800 ? "Invalid email or password" : "";
},
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// What an auto-retrying assertion does inside: retry until it passes or time runs out
async function expectToHaveText(getText, expected, { timeout = 5000, interval = 100 } = {}) {
const deadline = Date.now() + timeout;
let attempts = 0;
let actual;
while (true) {
attempts += 1;
actual = getText();
if (actual === expected) return attempts;
if (Date.now() > deadline) {
throw new Error(`expected "${expected}", last value "${actual}"`);
}
await sleep(interval);
}
}
const attempts = await expectToHaveText(() => page.alertText(), "Invalid email or password");
console.log(attempts > 1 ? "Passed after retrying, no fixed sleep needed" : "Passed at once");
try {
await expectToHaveText(() => page.alertText(), "Invalid email or pasword", { timeout: 1000 });
} catch (error) {
console.log("Failed as expected:", error.message);
}Passed after retrying, no fixed sleep needed Failed as expected: expected "Invalid email or pasword", last value "Invalid email or password"
Real Playwright reports failures in the same spirit, with a call log of what it saw on each check. Here are the important lines from a real run where the expected text had a typo and timeout=2000 was set:
E AssertionError: Locator expected to have text 'Invalid email or pasword'
E Actual value: Invalid email or password
E Call log:
E - Expect "to_have_text" get_by_role("alert") with timeout 2000ms
E - waiting for get_by_role("alert")
E 8 × locator resolved to <p id="message" role="alert"></p>
E - unexpected value ""
E 10 × locator resolved to <p id="message" role="alert">Invalid email or password</p>
E - unexpected value "Invalid email or password"The log shows the empty message first, then the real one; the counts will differ on your computer. Use web-first assertions like these instead of reading text once and comparing it with a plain assert, which would not retry.
The JavaScript test runner
Playwright's own test runner, Playwright Test, is a Node.js package with parallel runs, an HTML report, retries and projects for several browsers built in. Set up a project with npm init playwright@latest, then write tests like this:
// tests/login.spec.js
import { test, expect } from "@playwright/test";
test("wrong password shows an error", async ({ page }) => {
await page.goto("http://localhost:8000/login.html");
await page.getByLabel("Email").fill("priya@example.com");
await page.getByLabel("Password").fill("wrong-pass");
await page.getByRole("button", { name: "Log in" }).click();
await expect(page.getByRole("alert")).toHaveText("Invalid email or password");
});Run it with npx playwright test and open the report with npx playwright show-report. The method names are camelCase in JavaScript (getByRole) and snake_case in Python (get_by_role); otherwise the APIs match.
Tools that save hours
- Codegen:
playwright codegen http://localhost:8000opens a browser and writes test code as you click. Use it to discover good locators, then tidy the code. - Trace viewer: run with tracing on (for example
pytest --tracing retain-on-failure) and open the saved trace withplaywright show-trace trace.zip. It shows every action with screenshots, the page structure, network calls and console messages, so you can debug a CI failure without rerunning it. - Screenshots and videos of failed tests, configurable in both runners.
- Network control:
page.route()can stub API responses so you can test error screens on demand.
Common mistakes
- Adding
time.sleep()orwait_for_timeout(). Rely on auto-waiting and web-first assertions instead. - Checking text with a plain
assert.assert locator.inner_text() == "..."reads once;expect(locator).to_have_text("...")retries. - CSS class locators copied from the DOM. Prefer roles, labels and test IDs.
- Ignoring strict-mode errors. "Resolved to 2 elements" means your locator is ambiguous; make it more specific.
Key points
- Playwright drives Chromium, Firefox and WebKit with one API, from Python, JavaScript, Java and .NET.
- Prefer user-facing locators:
get_by_role,get_by_label,get_by_textandget_by_test_id. - Actions auto-wait for elements to be visible, stable, able to receive events and enabled.
expect()assertions retry until they pass or time out (5 seconds by default).- Codegen, the trace viewer and HTML reports make writing and debugging tests much faster.
Exercise
Write a Playwright test (Python) for a search page: open http://localhost:8000/books.html, type "Python" into the field with placeholder "Search books", press the button named "Search", and check that exactly 3 elements with the test ID book-card are shown and that the first of them contains the text "Python".
Show solution
from playwright.sync_api import Page, expect
def test_search_shows_three_python_books(page: Page):
page.goto("http://localhost:8000/books.html")
page.get_by_placeholder("Search books").fill("Python")
page.get_by_role("button", name="Search").click()
cards = page.get_by_test_id("book-card")
expect(cards).to_have_count(3)
expect(cards.first).to_contain_text("Python")to_have_count(3) retries until the results have loaded, so the test is reliable even if the search takes a moment. cards.first picks the first match, which avoids a strict-mode error when you need just one of several elements.
Quick Quiz
1. Which locator does Playwright recommend first?
Role locators match how users and assistive technology see the page.
2. What does expect(locator).to_have_text("Saved") do if the text is not there yet?
Web-first assertions retry, with a 5-second default timeout.
3. An action such as click() matches two buttons. What happens?
Locators are strict for single-element actions to prevent clicking the wrong element.
4. Which tool records your clicks and writes test code?
playwright codegen generates code from your actions in a browser.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
