pytest Fixtures for Playwright

9 min read

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:

FixtureTypeScopeWhen you'd use it
pagePagefunctionThe default — a fresh tab per test, isolated cookies/storage.
contextBrowserContextfunctionWhen you need cookies, storage_state, multiple pages in one test.
browserBrowsersessionRare — when you want to manage contexts manually.
browser_namestrsessionThe current browser id — "chromium", "firefox", "webkit".
playwrightPlaywrightsessionThe 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 context
def test_clears_cookies_mid_test(context, page: Page):
    page.goto("/")
    # ... do stuff ...
    context.clear_cookies()
    page.reload()
    # Page is now logged out

Both 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 page

Every 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 run

A 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 is yield row followed 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 Page shared 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 the logged_in_page fixture. pytest reports fixture 'loggedinpage' not found. Match the name letter for letter.

🎯 Practice task

Build the fixtures you'll reuse in every later chapter. 25-30 minutes.

  1. In your playwright-python-tests/ project, create tests/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
  2. Create tests/test_inventory.py and write three tests that use logged_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")
  3. 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.

  4. 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 -s lets you see prints) and confirm the test passes; the teardown runs cleanly.

  5. Add a session-scoped fixture. Add print statements before and after the yield in a scope="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.

  6. 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: pagelogged_in_pageadmin_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.

// tip to track lessons you complete and pick up where you left off across devices.