API Testing & Quality Engineering
Hands-onIntermediate 40 min· Beyond functional testing · 12 of 19

End-to-end testing with Playwright

Write browser tests that click through a page like a customer, using Playwright and pytest. Learn reliable locators, auto-waiting, debugging with traces and which journeys deserve an E2E test.

In this tutorial you will write end-to-end (E2E) tests: tests that open a real browser, click through your web page like a customer would and check what appears on screen. You will use Playwright, a free, open-source browser automation tool from Microsoft, with pytest in Python.

You need: Python 3.10 or newer, pytest basics (see *Unit testing in Python with pytest* earlier in this track) and a little HTML. To keep the lesson self-contained, you will test a tiny one-file Brew & Bean ordering page. Later you can point the same tests at your own React site from the Web Development track.

Playwright also has a JavaScript/TypeScript version (npm init playwright@latest) with the same ideas and almost the same method names. Everything you learn here carries over.

What E2E tests are for

Unit and API tests check pieces. An E2E test checks that the pieces work together, through the screen: the HTML, the JavaScript, the API and the database. It catches bugs nothing else can: a button hidden behind another element, a form that submits to the wrong URL, a total that is right in the API but shown wrongly on the page.

The price is speed and stability. E2E tests are the slowest and most fragile tests you will write, which is why the test pyramid (see *Why we test*) keeps only a few of them, for the most important journeys. For a coffee shop: browse the menu, add items, place an order. Not every validation rule; the API tests already cover those.

Step 1: Set up

Install Playwright for Python

  1. 1Create a folder coffee-e2e and open a terminal in it.
  2. 2Create and activate a virtual environment: python -m venv .venv, then source .venv/bin/activate (macOS, Linux, Git Bash) or .venv\Scripts\activate (Windows).
  3. 3Install the pytest plugin, which also installs Playwright: pip install pytest-playwright.
  4. 4Download the browsers Playwright controls: playwright install. This is a one-time download of several hundred MB (Chromium, Firefox and WebKit), so use a good connection.
  5. 5Create a sub-folder site and put the page from the next step in site/index.html.
Terminal
mkdir coffee-e2e && cd coffee-e2e
python -m venv .venv
source .venv/bin/activate
pip install pytest-playwright
playwright install
mkdir site

On Windows PowerShell, activate with .venv\Scripts\Activate.ps1. If you only want one browser, run playwright install chromium.

Step 2: The page under test

Save this as site/index.html. It is a small ordering page: add items, see the total, enter a name and place the order. It has no backend, so the test needs nothing else running. Notice the label for the name field, the real button elements and the role="status" message. Good accessible HTML is also easy to test.

site/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Brew & Bean</title>
</head>
<body>
  <h1>Brew & Bean</h1>
  <h2>Menu</h2>
  <ul id="menu"></ul>

  <h2>Your order</h2>
  <ul id="cart" data-testid="cart"></ul>
  <p>Total: <strong data-testid="total">₹0</strong></p>

  <label for="name">Your name</label>
  <input id="name" type="text">
  <button id="place" type="button">Place order</button>
  <p id="message" role="status"></p>

  <script>
    const MENU = [
      { id: "espresso", name: "Espresso", price: 120 },
      { id: "cappuccino", name: "Cappuccino", price: 180 },
      { id: "croissant", name: "Butter Croissant", price: 150 },
    ];
    let cart = [];

    const menuEl = document.getElementById("menu");
    MENU.forEach((item) => {
      const li = document.createElement("li");
      li.textContent = item.name + " - ₹" + item.price + " ";
      const btn = document.createElement("button");
      btn.type = "button";
      btn.textContent = "Add " + item.name;
      btn.addEventListener("click", () => { cart.push(item); render(); });
      li.appendChild(btn);
      menuEl.appendChild(li);
    });

    function render() {
      const cartEl = document.getElementById("cart");
      cartEl.innerHTML = "";
      cart.forEach((item) => {
        const li = document.createElement("li");
        li.textContent = item.name;
        cartEl.appendChild(li);
      });
      const total = cart.reduce((sum, item) => sum + item.price, 0);
      document.querySelector("[data-testid=total]").textContent = "₹" + total;
    }

    document.getElementById("place").addEventListener("click", () => {
      const name = document.getElementById("name").value.trim();
      const msg = document.getElementById("message");
      if (!name) { msg.textContent = "Please enter your name."; return; }
      if (cart.length === 0) { msg.textContent = "Your cart is empty."; return; }
      const total = cart.reduce((sum, item) => sum + item.price, 0);
      msg.textContent = "Thanks " + name + "! Order placed. Total ₹" + total + ".";
      cart = [];
      render();
    });
  </script>
</body>
</html>

Start a web server for it in a second terminal and leave it running. Python can serve a folder with one command:

Terminal 2
python -m http.server 8000 --directory site

Open http://localhost:8000 in your browser and click around first. You should always understand a page by hand before you automate it.

Step 3: Your first tests

Create test_order_flow.py. The page argument is a fixture from pytest-playwright: a fresh browser page for every test, closed automatically afterwards. Tests do not share cookies or storage, so they cannot affect each other.

We do not look for elements by CSS class or position. We look the way a user does: by role and visible name (get_by_role), by label (get_by_label). When these are not possible, you can add a data-testid attribute and use get_by_test_id. This is why tests written this way keep passing when a designer changes the styling.

test_order_flow.py
import re

from playwright.sync_api import Page, expect


def add(page: Page, item_name: str):
    page.get_by_role("button", name=f"Add {item_name}").click()


def test_page_shows_menu(page: Page):
    page.goto("/")

    expect(page).to_have_title("Brew & Bean")
    expect(page.get_by_role("button", name="Add Espresso")).to_be_visible()
    expect(page.get_by_role("button", name="Add Cappuccino")).to_be_visible()


def test_total_updates_as_items_are_added(page: Page):
    page.goto("/")

    add(page, "Espresso")
    add(page, "Espresso")
    add(page, "Butter Croissant")

    expect(page.get_by_test_id("cart").get_by_role("listitem")).to_have_count(3)
    expect(page.get_by_test_id("total")).to_have_text("₹390")


def test_customer_can_place_an_order(page: Page):
    page.goto("/")

    add(page, "Cappuccino")
    page.get_by_label("Your name").fill("Asha")
    page.get_by_role("button", name="Place order").click()

    expect(page.get_by_role("status")).to_have_text(re.compile(r"Order placed\. Total ₹180"))
    expect(page.get_by_test_id("total")).to_have_text("₹0")


def test_name_is_required(page: Page):
    page.goto("/")

    add(page, "Espresso")
    page.get_by_role("button", name="Place order").click()

    expect(page.get_by_role("status")).to_have_text("Please enter your name.")
    expect(page.get_by_test_id("total")).to_have_text("₹120")


def test_empty_cart_cannot_be_ordered(page: Page):
    page.goto("/")

    page.get_by_label("Your name").fill("Asha")
    page.get_by_role("button", name="Place order").click()

    expect(page.get_by_role("status")).to_have_text("Your cart is empty.")
Terminal
pytest --base-url http://localhost:8000

By default the tests run headless (no window) in Chromium. --base-url lets page.goto("/") work, and lets you run the same tests against staging later.

Expected output
collected 5 items

test_order_flow.py .....                                                 [100%]

5 passed in 4.12s

Auto-waiting and why you never need sleep

The biggest cause of flaky E2E tests is timing: the test clicks before the page is ready. Playwright solves most of this for you. Before a click it waits until the element exists, is visible, is stable and is enabled. expect(...) assertions retry for a few seconds (five by default) until they pass, then fail with a clear message.

That is why the tests above contain no time.sleep(). If you reach for a sleep, you almost always want an expect on the thing you are waiting for instead (for example, expect(page.get_by_role("status")).to_be_visible()).

Use expect, not plain assert, for page content

Write expect(locator).to_have_text("₹390"), not assert locator.inner_text() == "₹390". The plain assert reads the text once, immediately, and fails if the page is a few milliseconds late. The expect version waits and retries.

Step 4: Watch, debug and record

When a test fails, you must see what the browser saw. These options are the ones you will use daily:

Useful pytest-playwright options

OptionWhat it does
--headedShows the real browser window while the test runs
--slowmo 500Slows each action by 500 ms so you can follow it (use with --headed)
--browser firefoxRuns in Firefox instead of Chromium. Others: webkit, chromium. Repeat the flag to run in several
--screenshot only-on-failureSaves a screenshot of the failing page into test-results/
--video retain-on-failureRecords a video and keeps it only for failed tests
--tracing retain-on-failureSaves a trace file (a time-travel recording) for failed tests
-k placedPlain pytest: run only tests whose name contains "placed"
Terminal
pytest --base-url http://localhost:8000 --headed --slowmo 400 -k test_customer_can_place_an_order

# After a failure with --tracing retain-on-failure, open the trace:
playwright show-trace test-results/<test-folder>/trace.zip

A trace shows every action, a snapshot of the page before and after it, the console and the network calls. It is the best tool for debugging a test that fails only in CI.

Terminal
playwright codegen http://localhost:8000

Opens a browser and an inspector. As you click and type, Playwright writes test code for you. Treat it as a first draft: rename things, remove noise and add real expect assertions.

Break a test on purpose

Change the espresso price in index.html from 120 to 125 and run the tests. Read the failure message: it shows the expected and actual text. Then undo the change. Reading failures calmly is half of the job.

Making E2E tests reliable

Habits that prevent flaky tests

  1. Locate by role, label, text or test id; avoid long CSS or XPath chains tied to layout.
  2. Use expect assertions and never sleep.
  3. Keep every test independent: it creates the data it needs and does not rely on another test running first.
  4. Test whole user journeys, not tiny details. Leave details to unit and API tests.
  5. Set up state through the API (for example create an order with a request) instead of clicking through ten screens.
  6. Use a dedicated test environment and test data, never real customers or production.
  7. When a test is flaky, find the cause (timing, shared data, an unstable selector). Do not just add retries.

Where this goes next

With a real site, the web server and API must be running before the tests. In CI you start them in the workflow, wait until they respond, then run pytest; this is shown in *Running tests in CI with GitHub Actions*. For the Brew & Bean React site you would run npm run dev or serve the built files, and pass its address as --base-url.

To cut the amount of E2E work, test the API behind the page with the tools from *Automating API tests in code*, and use E2E only to prove the screen is wired to it.

Prompt · Turn a user journey into E2E test cases
You are a QA engineer who writes Playwright tests in Python with pytest-playwright.

Here is the HTML of my page:
<html>
[paste the HTML or the component code]
</html>

Task: list the 5 most important user journeys, then write one Playwright test for each. Use get_by_role, get_by_label or get_by_test_id locators, expect() assertions and no sleeps. Each test must be independent. Say which of my elements need better labels or data-testid attributes to be testable.

What to expect: Check that locators match elements that really exist in your HTML, that there are no time.sleep calls and that each test starts with page.goto. Run every generated test yourself and break it on purpose once to confirm it can fail.

How this comes up in interviews

Typical questions: "What is the difference between an E2E test and an integration test?", "How do you deal with flaky tests?", "How do you decide what to automate at UI level?", "Selenium or Playwright?". Good answers: E2E drives the real UI through the whole system, so keep few of them for critical journeys; flakiness is fixed at the cause (waiting, data isolation, selectors), not hidden with retries. Playwright auto-waits and has built-in tracing, which cuts flakiness; Selenium is older and very widely used in Indian companies, so knowing both ideas helps. Say that you would put most checks lower in the pyramid.

Try it: Add a new item and a second order

1. Add a new menu item, Masala Chai at ₹90, to site/index.html. 2. Write a test that adds one Masala Chai and one Cappuccino and checks the total is ₹270. 3. Write a test that places an order, then adds another item and checks the total starts again from that item's price, not from the previous total. 4. Run all tests in Firefox too: pytest --base-url http://localhost:8000 --browser firefox.

Try it: Test your own site

Take a page you built (a React app from the Web track, or any page of your own project). Write three E2E tests for its most important journey, using only role, label or test-id locators. Run them headed with --slowmo, then with --tracing on and open one trace.

Key takeaways

  1. E2E tests drive a real browser through the whole system. They are valuable, slow and fragile, so keep few and focus on critical journeys.
  2. Playwright auto-waits before actions and expect retries, so you do not need sleep.
  3. Locate elements the way a user does: by role, label, text or test id, not by layout-dependent CSS.
  4. Each test gets a fresh page and must not depend on other tests.
  5. Use --headed, --slowmo, screenshots and traces to debug; use codegen for a first draft only.
  6. Fix flaky tests at the cause instead of adding retries.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why does a Playwright test not need time.sleep() before clicking a button?

2. Which locator is most likely to keep working when a designer restyles the page?

3. Which assertion is correct for text that may appear a moment after a click?

4. A test fails only on the CI server and you cannot reproduce it locally. What is the most useful tool?

5. Where do E2E tests belong in the test pyramid?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free