Skip to content

Blog / Playwright Python and pytest

Playwright Python and pytest

Playwright with pytest: A Practical pytest-playwright Setup

From an empty folder to a CI-ready Playwright Python suite with pytest, with every file shown and every command tested.

October 23, 2026 · Blendra Team · 12 min read

pytest-playwright is the official plugin that runs Playwright for Python inside pytest. It hands each test a fresh browser page as a fixture, adds command-line flags for browsers, traces and videos, and leaves the rest to pytest: conftest files, markers, parametrize and plugins like pytest-xdist. This guide builds a small suite from an empty folder to a CI job. Every file is shown in full, and every command was run before it went into this post.

Versions used, as of October 2026: Python 3.12, Playwright for Python 1.64.0, pytest 9.1.1, pytest-playwright 0.10.0 and pytest-xdist 3.8.0.

TL;DR

Five commands:

python -m venv .venv
source .venv/bin/activate
pip install pytest-playwright pytest-xdist
playwright install chromium
pytest

Three files:

  • pyproject.toml holds pytest's settings: test paths, base_url, strict markers.
  • conftest.py holds your fixtures: context options and a signed-in page.
  • tests/test_*.py holds tests that ask for page and assert with expect.

Then add flags as you need them: -m smoke picks tests by marker, --numprocesses auto runs them in parallel, and --tracing retain-on-failure keeps a trace for every failed test.

Install pytest-playwright

Playwright for Python 1.64 and pytest-playwright 0.10 need Python 3.10 or later. Start with a virtual environment. On Debian 12, Ubuntu 23.04 and later, and Homebrew Python, a bare pip install fails with error: externally-managed-environment, and a venv is the clean fix anyway.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install pytest-playwright pytest-xdist
playwright install chromium

pip install pytest-playwright pulls in playwright and pytest. It does not download browsers. playwright install chromium does that, into a shared cache (~/.cache/ms-playwright on Linux). Leave off chromium to get Chromium, Firefox and WebKit. On a fresh Linux machine or CI runner, add --with-deps so it also installs the system libraries the browsers need.

Pin what you tested with. A minor release of pytest-playwright can change defaults (0.10 made the pytest-base-url dependency optional, for example), so your CI should install the same versions as your laptop:

pytest==9.1.1
pytest-playwright==0.10.0
playwright==1.64.0
pytest-xdist==3.8.0

Save that as requirements.txt and install with pip install -r requirements.txt.

Your first Playwright Python test with pytest

The examples run against a tiny demo shop on http://localhost:8000 with a sign-in page, a dashboard and a cart. Swap in your own app's URL and labels.

Create tests/test_login.py:

from playwright.sync_api import Page, expect


def test_sign_in_page_loads(page: Page) -> None:
    page.goto("/login/")
    expect(page).to_have_title("Sign in · Demo shop")
    expect(page.get_by_role("heading", level=1)).to_have_text("Sign in")


def test_wrong_password_shows_an_error(page: Page) -> None:
    page.goto("/login/")
    page.get_by_label("Email").fill("ada@example.com")
    page.get_by_label("Password").fill("not-the-password")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_role("alert")).to_have_text("Wrong email or password")

Run it:

pytest --base-url http://localhost:8000
collected 2 items

tests/test_login.py ..                                                   [100%]

============================== 2 passed in 1.62s ===============================

What happened:

  • page is a fixture. pytest-playwright launched Chromium headless, opened a new browser context and a page for each test, and closed them afterwards. You never call sync_playwright() yourself.
  • page.goto("/login/") is a relative URL. It resolves against --base-url.
  • expect(...) is a web-first assertion. It retries until the condition holds or the timeout runs out, which is 5 seconds by default (Playwright docs). The demo's error message appears 300 ms after the click, and the test needs no sleep. A plain assert page.get_by_role("alert").inner_text() == ... would read the text once and fail if the page was still working.
  • The test ids get the browser as a parameter: test_sign_in_page_loads[chromium]. That is how one test runs once per browser later.

For which locator to reach for first, see Playwright locators. For what expect can check, see Playwright assertions.

Configure pytest in pyproject.toml

Typing --base-url on every run gets old. Put pytest's settings in pyproject.toml:

[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
pythonpath = ["."]
addopts = "--strict-markers --import-mode=importlib"
base_url = "http://localhost:8000"
markers = [
    "smoke: fast checks that the app is up and the main paths work",
    "regression: the wider suite that runs before a release",
]

Line by line:

  • testpaths makes a bare pytest look only in tests/, so it never wanders into .venv.
  • pythonpath = ["."] lets tests import your own modules, like from pages.login_page import LoginPage.
  • --import-mode=importlib is the mode the pytest docs recommend for new projects. It also stops two test files with the same name in different folders from clashing.
  • base_url is read by pytest-playwright. Since 0.10 the plugin provides --base-url and the base_url setting itself when the separate pytest-base-url plugin isn't installed (pip install "pytest-playwright[base-url]" brings that plugin back).
  • --strict-markers turns a typo into an error instead of a silently empty run. With @pytest.mark.smok in a test file, pytest stops at collection:
ERROR tests/test_broken.py - Failed: 'smok' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!

To point a run at another environment without editing the file, set PYTEST_BASE_URL. In pytest-playwright 0.10 it is the default for --base-url, and it beats the base_url line in pyproject.toml:

PYTEST_BASE_URL=https://staging.example.com pytest

Two notes on config files. pytest 9 also reads a native TOML table, [tool.pytest], where addopts is a list instead of a string. [tool.pytest.ini_options] works on pytest 8 and 9, so it is the safer choice while your team upgrades. And pytest never merges config files: the first one it finds wins, and a pytest.ini beats pyproject.toml (pytest docs). A forgotten pytest.ini is a common reason "my settings are ignored".

pytest-playwright fixtures and how to override them

The plugin's fixtures come in two scopes (Playwright docs):

Fixture Scope What it is
playwright, browser_type, browser, browser_name session The driver and one launched browser, shared by the tests in a process
browser_context_args, browser_type_launch_args session Dicts of options for browser.new_context() and browser_type.launch()
context, page function A new, isolated context and page for every test
new_context function A factory for extra contexts, for example two users in one test

Scopes of pytest-playwright fixtures from session browser to per-test page

The browser is expensive, so it lives for the whole session. Contexts are cheap and isolated (cookies, localStorage, cache), so every test gets its own. That split is why tests don't leak sign-in state into each other.

To change context options for every test, override browser_context_args in conftest.py at the project root. Ask for the original fixture and spread it, or you lose what the plugin put there (base_url, the video directory, --device settings):

import pytest


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args: dict) -> dict:
    return {
        **browser_context_args,
        "viewport": {"width": 1280, "height": 800},
        "locale": "en-GB",
        "timezone_id": "Europe/London",
    }

Keep the scope="session". The original is session-scoped, and a function-scoped override fails with a ScopeMismatch error as soon as a session fixture depends on it.

For one test, use the marker instead. It merges into the session options:

@pytest.mark.browser_context_args(locale="de-DE", timezone_id="Europe/Berlin")
def test_german_locale(page: Page) -> None:
    ...

browser_type_launch_args works the same way for launch options such as slow_mo or args. One trap from the docs: the --tracing, --video and --screenshot flags apply only to the plugin's own context and page (and contexts from new_context). A context you open yourself with browser.new_context() records nothing.

A logged-in page fixture in conftest.py

Most tests need a signed-in user, and signing in through the form before each one is slow. Sign in once, save the browser's storage, and start each test's context from it. Here is the complete conftest.py, including the override from the last section:

import os
from pathlib import Path

import pytest
from playwright.sync_api import Browser, Page

from pages.login_page import LoginPage


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args: dict) -> dict:
    # Keep what pytest-playwright already set (base_url, video, --device) and add to it.
    return {
        **browser_context_args,
        "viewport": {"width": 1280, "height": 800},
        "locale": "en-GB",
        "timezone_id": "Europe/London",
    }


@pytest.fixture(scope="session")
def credentials() -> tuple[str, str]:
    email = os.environ.get("E2E_EMAIL")
    password = os.environ.get("E2E_PASSWORD")
    if not email or not password:
        pytest.fail("Set E2E_EMAIL and E2E_PASSWORD to run signed-in tests.", pytrace=False)
    return email, password


@pytest.fixture(scope="session")
def auth_state(
    browser: Browser,
    browser_context_args: dict,
    credentials: tuple[str, str],
    tmp_path_factory: pytest.TempPathFactory,
) -> Path:
    # Sign in once per session (once per worker under pytest-xdist) and save cookies + localStorage.
    context = browser.new_context(**browser_context_args)
    page = context.new_page()
    login = LoginPage(page)
    login.goto()
    login.sign_in(*credentials)
    page.wait_for_url("**/dashboard/")  # save the state only after sign-in has finished
    path = tmp_path_factory.mktemp("auth") / "state.json"
    context.storage_state(path=path)
    context.close()
    return path


@pytest.fixture
def logged_in_page(new_context, auth_state: Path) -> Page:
    # new_context applies browser_context_args and the --tracing/--video/--screenshot flags.
    context = new_context(storage_state=auth_state)
    return context.new_page()

A test asks for logged_in_page instead of page:

import pytest
from playwright.sync_api import Page, expect


@pytest.mark.smoke
def test_dashboard_greets_the_user(logged_in_page: Page) -> None:
    logged_in_page.goto("/dashboard/")
    expect(logged_in_page.get_by_role("heading", level=1)).to_have_text("Welcome, Ada")


@pytest.mark.regression
def test_signed_out_visitor_is_sent_to_sign_in(page: Page) -> None:
    page.goto("/dashboard/")
    expect(page.get_by_role("heading", level=1)).to_have_text("Sign in")

Details that matter:

  • Wait before you save. storage_state() captures whatever is there at that moment. Save it right after the click and you may store a signed-out state, and every signed-in test fails later with a confusing redirect. wait_for_url waits for proof that sign-in finished.
  • Build the context with new_context, not browser.new_context(), so the test's context gets your options and the trace and video flags.
  • Secrets come from the environment. Without them, the signed-in tests error at setup with one clear line, and the others still run:
ERROR at setup of test_dashboard_greets_the_user[chromium]
Set E2E_EMAIL and E2E_PASSWORD to run signed-in tests.
  • The state file is a credential. It holds session cookies. tmp_path_factory keeps it out of your repo.

The Playwright authentication post covers more cases: several roles, tokens that expire, and API sign-in.

Page objects in Python

The fixture above already uses a page object. It is a plain class that wraps a Page:

from playwright.sync_api import Page


class LoginPage:
    def __init__(self, page: Page) -> None:
        self.page = page
        self.email = page.get_by_label("Email")
        self.password = page.get_by_label("Password")
        self.submit = page.get_by_role("button", name="Sign in")
        self.error = page.get_by_role("alert")

    def goto(self) -> None:
        self.page.goto("/login/")

    def sign_in(self, email: str, password: str) -> None:
        self.email.fill(email)
        self.password.fill(password)
        self.submit.click()

Save it as pages/login_page.py, next to an empty pages/__init__.py. Locators are lazy, so defining them in __init__ costs nothing. Nothing touches the page until an action or assertion runs. Keep assertions in the test, where a reader expects them:

import pytest
from playwright.sync_api import Page, expect

from pages.login_page import LoginPage


@pytest.mark.regression
def test_wrong_password_shows_an_error(page: Page) -> None:
    login = LoginPage(page)
    login.goto()
    login.sign_in("ada@example.com", "not-the-password")
    expect(login.error).to_have_text("Wrong email or password")

When page objects pay off, and how to hand them to tests as fixtures, is the topic of page objects in Playwright Python.

Data-driven tests with pytest.mark.parametrize

Three coupon codes, one test body:

import pytest
from playwright.sync_api import Page, expect


@pytest.mark.regression
@pytest.mark.parametrize(
    ("code", "message"),
    [
        pytest.param("SAVE10", "10% off applied", id="percent-off"),
        pytest.param("FREESHIP", "Free shipping applied", id="free-shipping"),
        pytest.param("SUMMER23", "This coupon has expired", id="expired"),
    ],
)
def test_coupon_codes(logged_in_page: Page, code: str, message: str) -> None:
    page = logged_in_page
    page.goto("/cart/")
    page.get_by_label("Coupon code").fill(code)
    page.get_by_role("button", name="Apply").click()
    expect(page.get_by_role("status")).to_have_text(message)

Each case is its own test with its own fresh context, and the ids read well in the output:

tests/test_coupons.py::test_coupon_codes[chromium-percent-off] PASSED
tests/test_coupons.py::test_coupon_codes[chromium-free-shipping] PASSED
tests/test_coupons.py::test_coupon_codes[chromium-expired] PASSED

Give every case an id. Without one, pytest builds ids from the values, which is fine for short strings and unreadable for dicts. Run one case with pytest -k expired.

pytest markers for smoke and regression runs

The markers are registered in pyproject.toml, so -m can select them. In this suite, test_sign_in_page_loads and test_dashboard_greets_the_user carry @pytest.mark.smoke, and the other five tests carry @pytest.mark.regression:

pytest -m smoke
pytest -m "regression and not smoke"
======================= 2 passed, 5 deselected in 1.04s ========================
5 passed, 2 deselected in 4.76s

A common split: smoke on every pull request, the full suite nightly or before a release. Choosing what goes in each bucket is its own question, covered in smoke vs regression testing. Markers on a class or module (pytestmark = pytest.mark.smoke) apply to every test inside it.

Run in parallel with pytest-xdist

With pytest-xdist installed:

pytest --numprocesses auto
created: 16/16 workers
16 workers [7 items]
.......                                                                  [100%]
============================== 7 passed in 4.56s ===============================

auto starts one worker per CPU that pytest-xdist detects (16 on the machine above). The Playwright docs advise a number between 2 and your CPU count, and warn that going higher can cause unexpected behaviour. On a small CI runner, pick a fixed --numprocesses 2 and measure.

Each worker is its own Python process, and session fixtures run once per worker, not once per run (pytest-xdist docs). So auth_state signs in once in every worker. That is usually fine. If your app allows only one session per user, give each worker its own user, using the worker_id fixture xdist provides (gw0, gw1, ...). If something truly must happen once, the xdist docs show a file-lock pattern.

Parallel runs also expose tests that share data. Two workers editing the same cart will fail at random. See Playwright test data for ways to keep tests apart.

Traces, video and screenshots on failure

Three flags, each recording only what you need:

pytest -m smoke --numprocesses auto --tracing retain-on-failure --video retain-on-failure --screenshot only-on-failure

When a test fails, the output names the expected and actual values and the locator's call log:

E       AssertionError: Locator expected to have text 'Log in'
E       Actual value: Sign in
E       Call log:
E         - Expect "to_have_text" get_by_role("heading", level=1) with timeout 1000ms
E         - waiting for get_by_role("heading", level=1)
E           11 × locator resolved to <h1>Sign in</h1>
E              - unexpected value "Sign in"

And the artifacts land in test-results/, one folder per failed test:

test-results/tests-test-broken-py-test-heading-is-wrong-on-purpose-chromium/trace.zip
test-results/tests-test-broken-py-test-heading-is-wrong-on-purpose-chromium/video.webm
test-results/tests-test-broken-py-test-heading-is-wrong-on-purpose-chromium/test-failed-1.png

Open the trace with playwright show-trace test-results/<folder>/trace.zip. It shows every action, the DOM before and after, network calls and console output. The Playwright trace viewer post walks through it. --output moves the folder. When every test passes, the folder isn't created.

pytest has no built-in retries. pytest-rerunfailures adds --reruns 1, and pytest-playwright 0.10 keeps each attempt's trace in its own folder (...-chromium and ...-chromium-retry1), so you can compare the attempts. Use retries to find flaky tests, not to hide them; see Playwright retries.

Run it in CI

A GitHub Actions job that installs the pinned versions, runs against staging and keeps the artifacts of failed tests. Save it as .github/workflows/e2e.yml:

name: e2e

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      PYTEST_BASE_URL: ${{ vars.STAGING_URL }}
      E2E_EMAIL: ${{ secrets.E2E_EMAIL }}
      E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-python@v6
        with:
          python-version: '3.13'
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Install Chromium and its system libraries
        run: python -m playwright install --with-deps chromium

      - name: Run tests
        run: >
          pytest --numprocesses 2
          --tracing retain-on-failure
          --video retain-on-failure
          --screenshot only-on-failure

      - name: Upload traces, videos and screenshots
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-artifacts
          path: test-results/
          if-no-files-found: ignore

Why each piece is there:

  • STAGING_URL is a repository variable and the credentials are repository secrets. GitHub masks secrets in logs.
  • --with-deps installs the Linux libraries Chromium needs. It is the step people forget, and the browser then fails to launch.
  • if: ${{ !cancelled() }} uploads artifacts even when the test step failed, which is the only time you want them.
  • if-no-files-found: ignore keeps a green run green, since test-results/ only exists after a failure.

The action versions match the Playwright Python CI guide as of October 2026.

How Blendra's Python export lays this out

Blendra records your app's flows and exports them as a test suite in your repo. Its Playwright Python export (Beta, on Pro+ and Team) writes the same shape of project this guide built by hand:

  • pytest-playwright with the sync API, configured in pyproject.toml, with requirements.txt pinning pytest>=8.0,<10, pytest-playwright>=0.10,<0.11 and playwright>=1.52,<2.
  • Page objects as classes in snake_case modules in pages/ (class LoginPage in pages/login_page.py), with their selectors in locators/.
  • Test data as JSON, with each sensitive value stored as a {{env:NAME}} reference and listed in .env.example, never the value itself.
  • One pytest marker per Blendra tag, prefixed with tag_, so pytest -m tag_smoke works and a tag can never switch on one of pytest's own markers.
  • A results reporter that uses only Python's standard library.

One difference from this guide: the export picks its environment with TEST_ENV (local, staging or production) and BASE_URL, not --base-url. One honest limit: it adds no retry plugin, so a flaky test fails the run.

FAQ

Should I use the sync or async API with pytest?

Use sync unless your test code already lives in asyncio. pytest-playwright is sync. For async tests, Playwright points to a separate plugin, pytest-playwright-asyncio, with pytest-asyncio; the plugin refuses to load if both are installed. The trade-offs are in Playwright Python: sync or async?.

Can I use unittest instead of pytest?

You can, inside pytest. The Playwright docs show a unittest.TestCase with an autouse fixture that stores page on self. Inside a TestCase you lose fixture arguments and parametrize, and the docs note that --browser no longer produces a matrix. If you are starting fresh, write plain pytest functions.

How do I debug a failing test step by step?

Run PWDEBUG=1 pytest -s -k test_name. It opens the browser headed with the Playwright Inspector and turns off timeouts, so you can step through actions (Playwright debugging docs). Put page.pause() in the test to stop at a given line. --headed --slowmo 500 is a lighter option when you only want to watch.

Which browsers can I run?

Chromium, Firefox and WebKit. Install them with playwright install, then repeat the flag: pytest --browser chromium --browser webkit runs every test once per browser, with ids like test_sign_in_page_loads[webkit]. For branded Chrome or Edge, use --browser-channel chrome or --browser-channel msedge. To skip a test in one browser, mark it @pytest.mark.skip_browser("webkit").

Why does every test start signed out?

Because each test gets a fresh context, with no cookies and no localStorage. That isolation is what makes tests independent. To start signed in, load saved storage with a fixture like logged_in_page above, rather than sharing one context across tests.

PlaywrightpytestPython

Start with the flow you test by hand every release.

Start free →