What end-to-end tests are for

An end-to-end test (E2E test) checks a complete user journey through the running system: a browser opens the portal, logs in, clicks to the cost page and reads what the customer would read. On the way it passes through everything: the HTML (HyperText Markup Language) and JavaScript, the PHP portal, meter-api, the database and the configuration that connects them. That is its strength: a missing environment variable, a broken login redirect or a JavaScript error on one browser shows up here and nowhere else.

It is also its weakness. Because the test touches everything, it can fail for reasons that have nothing to do with the change under test: a slow test environment, another team's deployment, a cookie banner. Google measured this on its own tests: over a week, 0.5% of small tests showed flakiness, against 14% of large ones, and tests that drive a browser through WebDriver were among the flakiest (Jeff Listfield, Google Testing Blog, 2017). His conclusion: "Size is more predictive than tool."

So the Portal team keeps a short list. For the "your costs this month" feature (PBI-450) there is one end-to-end test: log in as a test customer with a day/night contract, open the cost page, and check that the 23:15 reading is billed at the night rate. The 06:59, 07:00, 22:59 and 23:00 boundaries, the rounding and the daylight-saving days are all tested lower down, in unit tests that run in milliseconds; the end-to-end test only proves that the pieces are connected.

Playwright, Selenium and Cypress

Three tools dominate browser automation. All three can run the browser as a headless browser: a real browser engine that loads pages, runs JavaScript and computes the layout, but draws no window on a screen. Headless mode is what pipelines use, since CI (continuous integration) runners have no display; you switch it off (--headed) to watch a test while you debug it.

Playwright is Microsoft's open-source browser automation and testing library. One API drives Chromium, Firefox and WebKit (the engine of Safari), from JavaScript/TypeScript, Python, Java and .NET. Its own test runner, Playwright Test, is for JavaScript and TypeScript; Python users run it through the pytest-playwright plugin.

Selenium WebDriver is the older and most widely supported option: a library for many languages that controls real browsers through the W3C (World Wide Web Consortium) WebDriver standard, with Selenium Grid to run tests on many browsers and machines in parallel. Since version 4.6 the included Selenium Manager downloads the right browser driver for you, so tutorials that start with "download chromedriver" are out of date. Selenium is moving from the classic WebDriver protocol to the newer two-way WebDriver BiDi protocol.

Tool Version (October 2026) Languages Notes
Playwright 1.63 (pytest-playwright 0.9.0) JavaScript/TypeScript, Python, Java, .NET auto-waiting, trace viewer, code generator; bundles its own browser builds
Selenium 4.50 Java, Python, C#, JavaScript, Ruby and more W3C standard, every major browser, Grid; Selenium 5 planned without a date
Cypress 16.1 JavaScript/TypeScript runs inside the browser, interactive runner; 16.0 needs Node.js 22, 24 or 26+

The examples below use Playwright with Python. The ideas (locators, waiting, page objects) apply to all three.

A first Playwright test

With pytest-playwright installed, a test function asks for the page fixture and gets a fresh browser tab, closed again after the test. Relative URLs are resolved against --base-url, so the same test runs against any environment:

import os
import re

from playwright.sync_api import Page, expect


def test_night_reading_is_billed_at_night_rate(page: Page):
    page.goto("/login")
    page.get_by_label("Email address").fill("test.klant06@example.com")
    page.get_by_label("Password").fill(os.environ["PORTAL_TEST_PASSWORD"])
    page.get_by_role("button", name="Log in").click()

    page.get_by_role("link", name="Costs this month").click()
    expect(page.get_by_role("heading", level=1)).to_have_text("Your costs in October 2026")

    row = page.get_by_role("row", name=re.compile("5 October 2026, 23:15"))
    expect(row).to_contain_text("Night")
    expect(row).to_contain_text("€0.34")
pytest tests/e2e -v --browser chromium --base-url https://portal-test.kestrel.example.com
tests/e2e/test_costs.py::test_night_reading_is_billed_at_night_rate[chromium] PASSED [100%]

============================== 1 passed in 4.87s ===============================

Read the test aloud and it is the bug report for BUG-1041 turned into a check: a test customer with a day/night contract, a 1.5 kWh reading at 23:15, and 1.5 × €0.2240 = €0.336, shown as €0.34. The password comes from an environment variable that the pipeline fills from its secret store; it never appears in the test code. The [chromium] after the test name is the browser it ran in: add --browser firefox --browser webkit and the same test runs three times.

Locators: finding elements the way users do

A locator is a test's description of how to find an element on the page: "the button named Log in", "the input labeled Email address". In Playwright a locator is looked up again every time it is used, so it still works after the page has re-rendered. The choice of locator decides how often a test breaks for no good reason:

Locator Breaks when Verdict
get_by_role("button", name="Log in") the button's role or visible name changes first choice: what users and screen readers see
get_by_label("Email address"), get_by_text("Night") the label or text changes good for form fields and visible text
get_by_test_id("night-total") someone removes the data-testid attribute good where there is no stable visible name; an explicit contract between developers and tests
locator(".btn.btn-primary.mt-2") any styling change avoid
locator("xpath=/html/body/div[2]/form/button") any change in page structure avoid

Playwright's documentation recommends "prioritizing user-facing attributes and explicit contracts such as getByRole". There is a bonus: role and label locators only work if the page exposes roles and accessible names properly. A cost table built from divs instead of a real <table> has no row roles, and the test above cannot find the row; neither can a screen reader. It is not an accessibility test (see Accessibility Testing), but it pushes in the same direction. The CSS selectors and DOM methods behind the fragile options are explained in CSS selectors and finding elements.

Waiting instead of sleeping

A web page is never "ready" at one fixed moment. After a click, the browser sends a request, the portal asks meter-api for readings, the table is filled in by JavaScript. The classic mistake is a fixed pause: time.sleep(2) and then look. On a developer's laptop the table appears in one second and the test passes. On a busy pipeline runner it sometimes takes three, and the test fails although nothing is wrong: a flaky test. Making the sleep longer only makes every run slower.

The fix is to wait for a condition with a time limit. This simulation compares both approaches on 200 runs whose load times vary the way a shared test environment's do (seeded, so the result is the same every time):

import random

random.seed(7)

# How long the cost table took to appear in 200 runs on the shared test
# environment, in milliseconds: usually about one second, sometimes much longer.
load_times = [round(random.lognormvariate(7.0, 0.45)) for _ in range(200)]


def fixed_sleep(load_ms, sleep_ms=2000):
    """time.sleep(2), then look: passes only if the table was already there."""
    return load_ms <= sleep_ms, sleep_ms


def wait_until_visible(load_ms, timeout_ms=5000, poll_ms=100):
    """Check every 100 ms, give up after 5 s (what auto-waiting does)."""
    waited = 0
    while waited < timeout_ms:
        if waited >= load_ms:
            return True, waited
        waited += poll_ms
    return False, waited


for name, strategy in (("sleep(2)", fixed_sleep), ("wait up to 5 s", wait_until_visible)):
    results = [strategy(load) for load in load_times]
    failures = sum(1 for passed, _ in results if not passed)
    waited = sum(ms for _, ms in results) / 1000
    print(f"{name:15} failed {failures:3} of 200 runs, waited {waited:6.1f} s in total")

print(f"slowest load: {max(load_times)} ms, median: {sorted(load_times)[100]} ms")
sleep(2)        failed  15 of 200 runs, waited  400.0 s in total
wait up to 5 s  failed   0 of 200 runs, waited  243.1 s in total
slowest load: 3811 ms, median: 1041 ms

The fixed sleep failed 15 runs out of 200 (7.5%) and was slower: it always waits two seconds, even when the table was there after one. Waiting for the condition never failed and wasted almost no time, because it stops as soon as the table appears.

Two runs on a time line from 0 to 5 seconds. In a fast run the table appears after 1 second: sleep(2) looks at 2 seconds and passes but wasted a second, while waiting continues at 1.1 seconds. In a slow run the table appears after 3.8 seconds: sleep(2) looks at 2 seconds, finds nothing and fails, while waiting keeps checking and continues at 3.9 seconds, well within the 5 second limit. 0 s 1 s 2 s 3 s 4 s 5 s sleep(2) looks here time limit fast run table visible sleep: pass, 1 s wasted slow run sleep: fail visible wait: pass at 3.9 s
A fixed sleep is too long for fast runs and too short for slow ones; waiting for the condition adapts to both.

Auto-waiting in Playwright

Playwright builds this in. Auto-waiting means that before every action the tool waits, up to a time limit, until the element is ready for it. Before click(), Playwright checks that the locator matches exactly one element and that the element is visible, stable (not moving in an animation), able to receive events (not covered by an overlay) and enabled; before fill(), that it is visible, enabled and editable. If the checks do not pass in time, the test fails with a TimeoutError that says which check failed.

Assertions work the same way. expect(row).to_contain_text("€0.34") is a web-first assertion: it keeps checking until the text is there, for up to five seconds by default (expect.set_options(timeout=10_000) changes it). A plain Python assertion such as assert "€0.34" in row.inner_text() reads the text once, immediately, and is flaky for the same reason as the sleep.

In Selenium, waiting is explicit: you wrap the lookup in a WebDriverWait with a condition.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

driver = webdriver.Firefox()  # Selenium Manager finds or downloads the driver
driver.get("https://portal-test.kestrel.example.com/costs")
night_total = WebDriverWait(driver, timeout=5).until(
    EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-testid='night-total']"))
)
print(night_total.text)
driver.quit()

Page objects

Once there are a few end-to-end tests, the same steps appear in all of them: find the email field, fill it, click Log in. When the login page changes, every test breaks. The page object model fixes that: each page (or part of a page) gets a class that knows how to find its elements and offers methods in the user's language. Tests call the methods and never touch a locator; when the page changes, only its class changes.

import re

from playwright.sync_api import Page


class LoginPage:
    def __init__(self, page: Page):
        self.page = page

    def open(self):
        self.page.goto("/login")
        return self

    def log_in(self, email: str, password: str) -> "CostPage":
        self.page.get_by_label("Email address").fill(email)
        self.page.get_by_label("Password").fill(password)
        self.page.get_by_role("button", name="Log in").click()
        return CostPage(self.page)


class CostPage:
    def __init__(self, page: Page):
        self.page = page
        self.heading = page.get_by_role("heading", level=1)
        self.night_total = page.get_by_test_id("night-total")

    def open_this_month(self):
        self.page.get_by_role("link", name="Costs this month").click()
        return self

    def row_for(self, moment: str):
        return self.page.get_by_role("row", name=re.compile(moment))

The test now reads like the user story:

import os

from playwright.sync_api import Page, expect

from pages import LoginPage


def test_night_reading_is_billed_at_night_rate(page: Page):
    costs = (
        LoginPage(page).open()
        .log_in("test.klant06@example.com", os.environ["PORTAL_TEST_PASSWORD"])
        .open_this_month()
    )
    expect(costs.row_for("5 October 2026, 23:15")).to_contain_text("€0.34")

Keep assertions in the tests, not in the page objects: a page object answers "how do I do this on this page?", the test decides what is right. Martin Fowler's short essay "PageObject" describes the pattern in more detail.

Visual regression testing

Functional tests check text and behavior; they do not notice that the night column is suddenly white on white or that the chart overlaps the table. Visual regression testing compares screenshots of a page or element with approved reference images (baselines) and fails when they differ by more than a set tolerance. Playwright Test (the JavaScript runner) has it built in:

import { test, expect } from "@playwright/test";

test("September cost chart looks as approved", async ({ page }) => {
  await page.goto("/costs?month=2026-09");
  await expect(page.getByRole("figure", { name: "Costs per day" }))
    .toHaveScreenshot("costs-september.png", { maxDiffPixelRatio: 0.01 });
});

The first run writes the baseline; later runs compare against it, and an intended design change is accepted by updating the baselines (npx playwright test --update-snapshots) in the same pull request, where a reviewer sees the new images. In Python, Playwright's assertions do not include a screenshot comparison; teams take screenshots with page.screenshot() and compare them with a separate plugin or service.

Visual tests are useful and noisy. To keep them honest:

  • Fix everything that changes between runs: test data, the date shown on the page (use a fixed month, as above), fonts, the browser version and the operating system that renders the screenshot. A baseline made on a Mac will not match a Linux CI runner.
  • Compare components (the chart, the table), not whole pages, so a changed footer does not fail every test.
  • Hide or mask dynamic parts such as "last updated at 14:03" and advertisements.

Traces and debugging

When an end-to-end test fails in the pipeline, "element not found" is rarely enough to know why. Was the page still loading, did the login fail, was a cookie banner in the way? Record what happened. Playwright's trace viewer is a tool that replays a recorded test run step by step: for every action it shows the page as it looked before and after (a DOM snapshot you can inspect), the console messages, the network requests and the locator that was used. With pytest-playwright:

# Record a trace, but keep it only when a test fails
pytest tests/e2e --tracing retain-on-failure --screenshot only-on-failure

# Open the trace from the test-results folder (or the pipeline's artifacts)
playwright show-trace test-results/<folder of the failed test>/trace.zip

Let the pipeline upload the test-results/ folder as an artifact, so whoever looks at the red build can open the trace without rerunning anything. Other useful tools: --headed --slowmo 500 to watch a test in slow motion, and playwright codegen, which records your clicks as test code (a starting point to clean up, not a finished test).

End-to-end tests in the pipeline

Some rules that keep end-to-end tests an asset rather than a burden:

  • Run them against a dedicated test environment with synthetic test customers (see test data management), never against production with real accounts. A separate, read-only check in production is synthetic monitoring, a different tool with different rules.
  • Make each test independent. Every test logs in and creates its own data; no test depends on what an earlier one clicked. Then they can run in parallel and in any order.
  • Run them after the fast checks, and on the paths that matter: login, viewing costs, changing a payment method. Unit, integration and contract tests run first and fail faster.
  • Treat a flaky end-to-end test as a bug. Record it (the Portal team uses a BUG ticket), find the cause (it is almost always waiting or test data), and quarantine it if it cannot be fixed today. Retrying until green hides real failures; see Flaky Tests, Test Maintenance and Testing in Production.
  • Review generated tests like any code. Since version 1.56, Playwright ships test agents that plan, generate and "heal" tests with an AI coding assistant, and recorders such as codegen produce tests in seconds. They save typing; they do not know which journeys matter or what the right price is. A generated test that asserts what the page currently shows will happily confirm a bug.