You've written a few tests. Each one repeats the same setup — go to the login page, fill credentials, click submit, navigate to the feature you actually want to test. Fixtures are how you stop repeating that. They're the most useful thing pytest gives you, and pytest-playwright builds on the same mechanism — every page, context, and browser your tests use is a fixture. This lesson explains both halves: the built-in fixtures pytest-playwright provides, and how to write your own to encapsulate setup once and reuse it across the suite.
What pytest-playwright gives you out of the box
The plugin registers five fixtures via its entry point — they appear automatically the moment you pip install pytest-playwright, no imports required:
| Fixture | Type | Scope | When you'd use it |
|---|---|---|---|
page | Page | function | The default — a fresh tab per test, isolated cookies/storage. |
context | BrowserContext | function | When you need cookies, storage_state, multiple pages in one test. |
browser | Browser | session | Rare — when you want to manage contexts manually. |
browser_name | str | session | The current browser id — "chromium", "firefox", "webkit". |
playwright | Playwright | session | The root Playwright handle. Used to access playwright.devices. |
The pattern: name the fixture as a parameter and pytest wires it up.
from playwright.sync_api import Page, expect
def test_homepage(page: Page):
page.goto("/")
expect(page).to_have_title("My App")You met page in chapter 1 — it's a fresh Page backed by a fresh BrowserContext per test. State doesn't bleed across tests because every test gets a clean profile in milliseconds. The browser process stays warm session-wide; only contexts and pages rebuild.
When to take context instead of page
The page fixture covers 95% of tests. Reach for context when you genuinely need it:
def test_with_two_pages(context):
page1 = context.new_page()
page2 = context.new_page()
page1.goto("/")
page2.goto("/dashboard")
# Two pages share cookies and storage in the same contextdef test_clears_cookies_mid_test(context, page: Page):
page.goto("/")
# ... do stuff ...
context.clear_cookies()
page.reload()
# Page is now logged outBoth context and page from the same test share state — the page fixture is context.new_page() under the hood. Asking for both gives you direct access to cookies, permissions, and storage state on the context that backs your page.
Writing your own fixtures — the basic shape
Custom fixtures live in conftest.py (covered in detail next lesson) or directly in test files. Use @pytest.fixture:
# tests/conftest.py
import pytest
from playwright.sync_api import Page
@pytest.fixture
def logged_in_page(page: Page) -> Page:
page.goto("/login")
page.get_by_label("Email").fill("alice@test.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
page.wait_for_url("/dashboard")
return pageEvery test that names logged_in_page as a parameter receives a Page already on /dashboard:
def test_dashboard_shows_welcome(logged_in_page: Page):
expect(logged_in_page.get_by_role("heading")).to_have_text("Dashboard")The -> Page return type isn't required, but it lights up IDE autocomplete on every logged_in_page.click(), .fill(), etc. — pure upside.
Fixtures with cleanup — the yield pattern
For setup that needs teardown, use yield instead of return. Anything before yield is setup; anything after is cleanup:
import time
import pytest
from playwright.sync_api import Page
@pytest.fixture
def test_user(page: Page):
# Setup — create a user via the API
response = page.request.post("/api/users", data={
"name": "Test User",
"email": f"test-{time.time()}@test.com",
})
user = response.json()
yield user # test runs with `user`
# Teardown — runs even if the test failed
page.request.delete(f"/api/users/{user['id']}")The teardown runs whether the test passed or failed (Python's try/finally semantics under the hood — pytest wraps the yield). This is the right shape for any resource that needs releasing: created users, uploaded files, database rows, opened files.
Fixture scopes — controlling how often setup runs
@pytest.fixture defaults to scope="function" — runs once per test. The other scopes:
@pytest.fixture # function — every test (default)
@pytest.fixture(scope="class") # class — once per test class
@pytest.fixture(scope="module") # module — once per test file
@pytest.fixture(scope="session") # session — once per pytest runA typical use of scope="session" for expensive shared setup:
@pytest.fixture(scope="session")
def api_token():
"""Authenticate once at the start of the test run, reuse the token everywhere."""
response = httpx.post("/api/auth/login", json={"email": "admin@test.com", "password": "..."})
return response.json()["token"]The token is fetched once when the first test that needs it runs, and reused for the rest of the session. Saves N login round-trips on a 500-test suite.
Caveat for Playwright fixtures specifically: the built-in page fixture is function-scoped on purpose — to keep tests isolated. If you build a session-scoped fixture that holds a Page, the page is shared and state bleeds across tests. For "logged-in once, used by all tests" the right pattern is to share credentials (storage state) at session scope, then have each test build a fresh page from those credentials. We'll do this properly in chapter 5.
autouse=True — fixtures that run without being requested
If a fixture should run for every test in scope automatically, use autouse:
@pytest.fixture(autouse=True)
def log_test_name(request):
print(f"\n--- Running: {request.node.name} ---")
yield
print(f"--- Finished: {request.node.name} ---")The fixture runs around every test in the directory, even tests that don't name it as a parameter. Useful for setup/teardown that's truly cross-cutting — logging, metrics, shared instrumentation. Be careful: autouse fixtures change the behaviour of every test, including tests where the author didn't ask for it. Use sparingly.
How a fixture actually runs — the lifecycle
Step 1 of 5
1. Discover
pytest collects the test and inspects its parameters. For each parameter name, it looks up the matching fixture in conftest.py or the test file.
The order matters: setup is outermost-first (parent fixtures before children), teardown is innermost-first (children before parents). It's the same as Python's with statement nesting. If your logged_in_page depends on page, page is built first and torn down last.
Coming from Playwright TypeScript?
In the TypeScript course, custom fixtures are wired up via test.extend<T>({ ... }):
const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
await page.goto("/login");
// ... login ...
await use(page);
},
});Python's pytest.fixture is simpler — no extend, no generics, no typed test interface. Just a function with a decorator. The trade-off is that pytest fixtures aren't typed in the same way TS fixtures are; you rely on -> Page return types and IDE inference to fill the gap. For most QA suites, that's fine; the fixture story in Python is more familiar to anyone who's written pytest before, even outside Playwright.
⚠️ Common mistakes
- Returning instead of yielding when you need cleanup. A fixture that creates a database row and
returns the row leaks the row when the test ends. The fix isyield rowfollowed by the teardown DELETE. If a fixture has any setup-then-teardown shape, default to yielding. - Putting a session-scoped Page in a fixture. A
Pageshared across tests means cookies, history, and storage bleed across tests — the most common cause of "this test passes alone but fails in the full suite." Session-scope credentials (a token, a storage_state path), not session-scope live browser objects. - Forgetting that fixtures named in test parameters are looked up by exact name.
def test_(loggedinpage)(no underscore) won't match thelogged_in_pagefixture. pytest reportsfixture 'loggedinpage' not found. Match the name letter for letter.
🎯 Practice task
Build the fixtures you'll reuse in every later chapter. 25-30 minutes.
-
In your
playwright-python-tests/project, createtests/conftest.py:import pytest from playwright.sync_api import Page, expect @pytest.fixture def logged_in_page(page: Page) -> Page: page.goto("/") page.get_by_placeholder("Username").fill("standard_user") page.get_by_placeholder("Password").fill("secret_sauce") page.get_by_role("button", name="Login").click() expect(page).to_have_url("/inventory.html") return page -
Create
tests/test_inventory.pyand write three tests that uselogged_in_page:from playwright.sync_api import Page, expect class TestInventory: def test_six_products_shown(self, logged_in_page: Page): expect(logged_in_page.locator(".inventory_item")).to_have_count(6) def test_first_product_is_backpack(self, logged_in_page: Page): first = logged_in_page.locator(".inventory_item").first expect(first.locator(".inventory_item_name")).to_have_text("Sauce Labs Backpack") def test_can_add_to_cart(self, logged_in_page: Page): logged_in_page.locator(".inventory_item").first \ .get_by_role("button", name="Add to cart").click() expect(logged_in_page.locator(".shopping_cart_badge")).to_have_text("1") -
Run with
pytest tests/test_inventory.py -v. Three tests pass; the login flow runs once per test (function-scoped fixture), giving each its own clean state. -
Add a yielding fixture with cleanup. In
conftest.py, add a fixture that adds a product to the cart and removes it after the test:@pytest.fixture def cart_with_backpack(logged_in_page: Page): backpack = logged_in_page.locator(".inventory_item").filter(has_text="Backpack") backpack.get_by_role("button", name="Add to cart").click() yield logged_in_page backpack.get_by_role("button", name="Remove").click()Write a test that uses it:
def test_cart_shows_one_item(cart_with_backpack): expect(cart_with_backpack.locator(".shopping_cart_badge")).to_have_text("1"). Run with-v -s(the-slets you see prints) and confirm the test passes; the teardown runs cleanly. -
Add a session-scoped fixture. Add
printstatements before and after the yield in ascope="session"fixture that just prints "session starting" and "session done." Run the whole test file. The session fixture prints once each at the start and end, while function-scoped fixtures print per test. -
Stretch: add a fixture that depends on another fixture —
admin_page(logged_in_page)that performs an admin-specific navigation step on top of the standard logged-in page. Use it in one test. pytest resolves the dependency chain automatically:page→logged_in_page→admin_page.
You've got the fixture toolkit. The next lesson goes deeper into conftest.py itself — directory hierarchy, browser_context_args for global config, and the production patterns that turn a 50-test prototype into a maintainable 500-test suite.