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.

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
pytestThree files:
pyproject.tomlholds pytest's settings: test paths,base_url, strict markers.conftest.pyholds your fixtures: context options and a signed-in page.tests/test_*.pyholds tests that ask forpageand assert withexpect.
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 chromiumpip 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.0Save 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:8000collected 2 items
tests/test_login.py .. [100%]
============================== 2 passed in 1.62s ===============================What happened:
pageis a fixture. pytest-playwright launched Chromium headless, opened a new browser context and a page for each test, and closed them afterwards. You never callsync_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 plainassert 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:
testpathsmakes a barepytestlook only intests/, so it never wanders into.venv.pythonpath = ["."]lets tests import your own modules, likefrom pages.login_page import LoginPage.--import-mode=importlibis 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_urlis read by pytest-playwright. Since 0.10 the plugin provides--base-urland thebase_urlsetting itself when the separatepytest-base-urlplugin isn't installed (pip install "pytest-playwright[base-url]"brings that plugin back).--strict-markersturns a typo into an error instead of a silently empty run. With@pytest.mark.smokin 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 pytestTwo 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 |

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_urlwaits for proof that sign-in finished. - Build the context with
new_context, notbrowser.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_factorykeeps 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] PASSEDGive 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.76sA 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 autocreated: 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-failureWhen 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.pngOpen 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: ignoreWhy each piece is there:
STAGING_URLis a repository variable and the credentials are repository secrets. GitHub masks secrets in logs.--with-depsinstalls 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: ignorekeeps a green run green, sincetest-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, withrequirements.txtpinningpytest>=8.0,<10,pytest-playwright>=0.10,<0.11andplaywright>=1.52,<2. - Page objects as classes in snake_case modules in
pages/(class LoginPageinpages/login_page.py), with their selectors inlocators/. - 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_, sopytest -m tag_smokeworks 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.