EthicsComputerCourses
Join training +91 90532 10052

Playwright

Software Testing · Lesson 21 of 30 Advanced 6 min read Updated

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:

Terminal
python -m pip install pytest-playwright
playwright install

By 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:

LocatorFindsExample
get_by_role()Elements by accessible role and namepage.get_by_role("button", name="Log in")
get_by_label()Form fields by their <label> textpage.get_by_label("Email")
get_by_placeholder()Inputs by placeholderpage.get_by_placeholder("Search books")
get_by_text()Elements by visible textpage.get_by_text("Out of stock")
get_by_alt_text()Images by alt textpage.get_by_alt_text("Kirana Kart logo")
get_by_title()Elements by title attributepage.get_by_title("Close")
get_by_test_id()Elements by data-testidpage.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.

Python
# 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:

Output
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 a TimeoutError.
  • 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 with timeout=10_000 (milliseconds) or for all tests with expect.set_options(timeout=10_000) in conftest.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:

JavaScriptRun it yourself
// 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);
}
Output
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:

Output
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:

JavaScript
// 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:8000 opens 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 with playwright 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() or wait_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_text and get_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
Python
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?

2. What does expect(locator).to_have_text("Saved") do if the text is not there yet?

3. An action such as click() matches two buttons. What happens?

4. Which tool records your clicks and writes test code?

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