Custom Fixtures for Playwright Python Tests (Page Objects, Login, Factories)

The built-in page fixture gives every test an empty browser tab. Real tests need more: an opened login page, a user who is already logged in, products in the cart, a check that no JavaScript errors happened. Custom fixtures turn that preparation into one reusable word in the test’s parameter list. In this lesson you will build a small fixture library for the SauceDemo shop on top of pytest-playwright’s fixtures, including a session-scoped login that is reused by every test.

Advertisement

📖 What You’ll Learn: why custom fixtures keep Playwright tests short, a page object fixture, chaining fixtures into a logged-in page, logging in once per session with storage_state and new_context, factory fixtures that take arguments, yield teardown, a fixture that collects console and page errors, parametrized fixtures for desktop and mobile, the ScopeMismatch error and the scope rules for fixtures that use page.


🧩 Why Custom Fixtures?

Compare two versions of the same test. Without fixtures, every test repeats the setup:

def test_cart_badge(page):
    page.goto("https://www.saucedemo.com/")
    page.get_by_placeholder("Username").fill("standard_user")
    page.get_by_placeholder("Password").fill("secret_sauce")
    page.get_by_role("button", name="Login").click()
    # ... and only now the actual test starts

With fixtures, the test says only what it is about:

def test_cart_badge(add_to_cart):
    page = add_to_cart("Sauce Labs Backpack")
    expect(page.locator(".shopping_cart_badge")).to_have_text("1")

You already know how fixtures work (Module 3). This lesson is about Playwright-specific fixtures: building on page, new_context and browser, choosing the right scope, and keeping tests isolated.

All examples use SauceDemo (www.saucedemo.com), a public practice shop. Its login page lists the demo user names and the shared password secret_sauce.

📝 Note: SauceDemo is a shared public site. While this lesson was being prepared, roughly one navigation in six stalled for more than 30 seconds, and the tests then failed with Page.goto: Timeout 30000ms exceeded. If that happens to you, simply run the tests again. Lesson 44 shows how to handle such flaky failures with automatic retries.


🏗️ The Fixture Library: conftest.py

Here is the start of conftest.py. The rest of the file follows in the next sections.

📄 conftest.py

import pytest
from playwright.sync_api import Browser, Page, expect

SAUCE = "https://www.saucedemo.com/"
USER = {"username": "standard_user", "password": "secret_sauce"}   # public demo account


class LoginPage:
    """A tiny page object for the SauceDemo login page."""

    def __init__(self, page: Page):
        self.page = page
        self.username = page.get_by_placeholder("Username")
        self.password = page.get_by_placeholder("Password")
        self.button = page.get_by_role("button", name="Login")
        self.error = page.locator("[data-test='error']")

    def open(self) -> "LoginPage":
        self.page.goto(SAUCE, wait_until="domcontentloaded")
        return self

    def login(self, username: str, password: str) -> None:
        self.username.fill(username)
        self.password.fill(password)
        self.button.click()


# 1. A page object fixture: the login page, already opened
@pytest.fixture
def login_page(page: Page) -> LoginPage:
    return LoginPage(page).open()


# 2. Chained fixture: a page where the user is logged in
@pytest.fixture
def logged_in_page(login_page: LoginPage) -> Page:
    login_page.login(**USER)
    expect(login_page.page).to_have_url(f"{SAUCE}inventory.html")
    return login_page.page

1. A page object fixture

  • LoginPage groups the login page’s locators and actions in one class. This is the Page Object Model, covered in depth in Module 10.
  • The login_page fixture builds on pytest-playwright’s page, opens the page and returns the object. A test that asks for login_page starts on the login screen.
  • wait_until="domcontentloaded": as you saw in Module 5, a public site’s load event can be slow because of third-party resources. The locators wait for the form anyway.

2. Chaining fixtures into a logged-in page

  • logged_in_page asks for login_page, which asks for page. pytest resolves the chain: page → login_page → logged_in_page.
  • The fixture ends with an expect(): if the login fails, the fixture fails with a clear message, and pytest reports the test as an error in setup rather than a failure in the test body. That tells you immediately that the problem is in the preparation.

📄 tests/test_login.py

from playwright.sync_api import Page, expect


def test_locked_out_user_sees_error(login_page):
    login_page.login("locked_out_user", "secret_sauce")
    expect(login_page.error).to_have_text("Epic sadface: Sorry, this user has been locked out.")


def test_products_are_listed(logged_in_page: Page):
    expect(logged_in_page.locator(".title")).to_have_text("Products")
    expect(logged_in_page.locator(".inventory_item")).to_have_count(6)

🔐 Logging In Once: a Session Fixture with storage_state

Logging in through the form in every test costs time. A faster pattern: log in once per session, save the browser’s cookies and storage to a file, and start every test’s context from that file.

📄 conftest.py

# 3. Log in once for the whole session and save the browser state
@pytest.fixture(scope="session")
def auth_state(browser: Browser, tmp_path_factory) -> str:
    context = browser.new_context()
    page = context.new_page()
    LoginPage(page).open().login(**USER)
    page.wait_for_url("**/inventory.html")

    path = tmp_path_factory.mktemp("auth") / "state.json"
    context.storage_state(path=path)       # cookies + localStorage as JSON
    context.close()
    print(f"\n[auth_state] logged in once, saved {path.name}")
    return str(path)


# 4. Each test gets a NEW context that starts from the saved state
@pytest.fixture
def fast_logged_in_page(new_context, auth_state: str) -> Page:
    page = new_context(storage_state=auth_state).new_page()
    page.goto(f"{SAUCE}inventory.html", wait_until="domcontentloaded")
    return page
  • auth_state is session-scoped, so it may only use session-scoped fixtures. It uses browser (session) and pytest’s built-in tmp_path_factory (session), not page (function).
  • context.storage_state(path=...) writes the context’s cookies and localStorage to a JSON file. SauceDemo keeps the login in a cookie named session-username.
  • fast_logged_in_page calls new_context(storage_state=...): a fresh, isolated context per test, which already contains the login cookie. The test lands directly on the products page.

📄 tests/test_fast_login.py

from playwright.sync_api import Page, expect


def test_first_fast_login(fast_logged_in_page: Page):
    expect(fast_logged_in_page.locator(".title")).to_have_text("Products")


def test_second_fast_login(fast_logged_in_page: Page):
    expect(fast_logged_in_page).to_have_url("https://www.saucedemo.com/inventory.html")

Run them with --setup-show to see that the login happens only once (an excerpt; ... marks lines removed for brevity):

⌨️ Terminal

pytest -q --setup-show tests/test_fast_login.py

🖥️ Output

...
SETUP    S browser (fixtures used: launch_browser)
...
SETUP    S tmp_path_factory
SETUP    S auth_state (fixtures used: browser, tmp_path_factory)
        ...
        SETUP    F new_context (fixtures used: _artifacts_recorder, browser, browser_context_args)
        SETUP    F fast_logged_in_page (fixtures used: auth_state, new_context)
        tests/test_fast_login.py::test_first_fast_login[chromium] (fixtures used: ...) .
        TEARDOWN F fast_logged_in_page
        TEARDOWN F new_context
        ...
        SETUP    F new_context (fixtures used: _artifacts_recorder, browser, browser_context_args)
        SETUP    F fast_logged_in_page (fixtures used: auth_state, new_context)
        tests/test_fast_login.py::test_second_fast_login[chromium] (fixtures used: ...) .
        TEARDOWN F fast_logged_in_page
        TEARDOWN F new_context
        ...
TEARDOWN S auth_state
TEARDOWN S tmp_path_factory
...
2 passed, 1 warning in 8.65s

SETUP S auth_state appears once, before the first test; both tests then create their own F fast_logged_in_page. Module 8 builds on this pattern for real authentication setups.

⚠️ Watch out: Never commit real credentials in conftest.py. The SauceDemo values are public demo data. For your own applications, read user names and passwords from environment variables or a secrets store (Module 7).


🏭 Factory Fixtures: Fixtures That Take Arguments

A fixture cannot receive arguments from the test directly. The solution is a factory: the fixture returns a function, and the test calls it with the arguments it needs.

📄 conftest.py

# 5. A factory fixture: the test decides which products to add
@pytest.fixture
def add_to_cart(logged_in_page: Page):
    added = []

    def _add(product: str) -> Page:
        item = logged_in_page.locator(".inventory_item").filter(has_text=product)
        item.get_by_role("button", name="Add to cart").click()
        added.append(product)
        return logged_in_page

    yield _add

    # Teardown: runs after the test, even if it failed
    print(f"\n[add_to_cart] teardown, products added in this test: {added}")

📄 tests/test_cart.py

from playwright.sync_api import expect


def test_one_product(add_to_cart):
    page = add_to_cart("Sauce Labs Backpack")
    expect(page.locator(".shopping_cart_badge")).to_have_text("1")


def test_two_products(add_to_cart):
    add_to_cart("Sauce Labs Backpack")
    page = add_to_cart("Sauce Labs Bike Light")
    expect(page.locator(".shopping_cart_badge")).to_have_text("2")
  • yield _add hands the inner function to the test. Everything after yield is teardown.
  • The added list remembers what the test did, so teardown knows what to clean up.
  • Here teardown only prints, because the browser context, and with it SauceDemo’s cart, is thrown away after each test anyway. In a real application the cart or order may live in a database: that is where teardown matters, for example to delete the order through an API call.

🚨 A Fixture That Watches the Page: Console and Page Errors

Fixtures can also attach listeners to the page. This one collects JavaScript errors so a test can assert that none happened:

📄 conftest.py

# 6. Collect console errors and uncaught exceptions of the page
@pytest.fixture
def js_errors(page: Page) -> list[str]:
    errors: list[str] = []
    page.on("console", lambda msg: errors.append(f"console: {msg.text}") if msg.type == "error" else None)
    page.on("pageerror", lambda exc: errors.append(f"pageerror: {exc}"))
    return errors

📄 tests/test_js_errors.py

from playwright.sync_api import Page, expect


def test_page_with_a_broken_script(page: Page, js_errors):
    page.set_content("""
        <button onclick="undefinedFunction()">Buy</button>
        <script>console.error('Payment widget failed to load')</script>
    """)
    page.get_by_role("button", name="Buy").click()
    expect(page.get_by_role("button")).to_be_visible()      # gives the events time to arrive

    print("\ncollected:", js_errors)
    assert len(js_errors) == 2
  • page.on("console", ...) receives every console message; the fixture keeps those of type "error".
  • page.on("pageerror", ...) receives uncaught JavaScript exceptions, such as calling a function that does not exist.
  • Both js_errors and the test receive the same page object: within one test, a function-scoped fixture is created only once and shared.

🔀 Parametrized Fixtures: Desktop and Mobile

A fixture with params runs every test that uses it once per parameter:

📄 conftest.py

# 7. One test, two screen sizes
@pytest.fixture(
    params=[{"width": 1280, "height": 720}, {"width": 390, "height": 844}],
    ids=["desktop", "mobile"],
)
def sized_page(request, new_context) -> Page:
    page = new_context(viewport=request.param).new_page()
    page.goto(SAUCE, wait_until="domcontentloaded")
    return page

📄 tests/test_sizes.py

from playwright.sync_api import Page, expect


def test_login_form_fits(sized_page: Page):
    print(f"\n{sized_page.viewport_size['width']} px wide")
    expect(sized_page.get_by_role("button", name="Login")).to_be_in_viewport()
  • request.param holds the current parameter; ids gives readable names in the test id.
  • new_context(viewport=...) creates a context with that window size, without changing the project’s defaults.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

.
[add_to_cart] teardown, products added in this test: ['Sauce Labs Backpack']
.
[add_to_cart] teardown, products added in this test: ['Sauce Labs Backpack', 'Sauce Labs Bike Light']

[auth_state] logged in once, saved state.json
..
collected: ['console: Payment widget failed to load', 'pageerror: undefinedFunction is not defined']
...
1280 px wide
.
390 px wide
.
9 passed, 1 warning in 36.71s
  • The [add_to_cart] lines come from the fixture’s teardown, after each cart test.
  • The [auth_state] line is printed once, when the first test that needs it runs; the second fast-login test reuses the saved file.

List the generated test ids with --collect-only:

⌨️ Terminal

pytest -q --collect-only tests/test_sizes.py

🖥️ Output

tests/test_sizes.py::test_login_form_fits[desktop-chromium]
tests/test_sizes.py::test_login_form_fits[mobile-chromium]

2 tests collected in 0.00s

Your fixture’s ids come first and pytest-playwright’s browser name second.


⛔ The Scope Rule: ScopeMismatch

A fixture can only use fixtures with the same or a wider scope. page is function-scoped, so a session-scoped fixture cannot use it:

📄 demos/test_scope_error.py

import pytest
from playwright.sync_api import Page


@pytest.fixture(scope="session")
def shared_home(page: Page) -> Page:          # session fixture asking for a function fixture
    page.goto("https://www.saucedemo.com/", wait_until="domcontentloaded")
    return page


def test_uses_shared_home(shared_home: Page):
    assert shared_home.url.startswith("https://www.saucedemo.com")

⌨️ Terminal

pytest -q demos/test_scope_error.py

🖥️ Output

E                                                                        [100%]
=================================== ERRORS ====================================
______________ ERROR at setup of test_uses_shared_home[chromium] ______________
ScopeMismatch: You tried to access the function scoped fixture page with a session scoped request object. Requesting fixture stack:
demos\test_scope_error.py:5:  def shared_home(page: 'Page') -> 'Page'
Requested fixture:
..\_venv0\Lib\site-packages\pytest_playwright\pytest_playwright.py:623:  def page(context: 'BrowserContext') -> 'Page'
=========================== short test summary info ===========================
ERROR demos/test_scope_error.py::test_uses_shared_home[chromium] - Failed: Sc...
1 error in 0.03s
  • pytest refuses to start the test and reports a ScopeMismatch error in setup.
  • The fix depends on your goal. To share something expensive across tests, build it from session fixtures (browser, playwright, tmp_path_factory), as auth_state does. To share a page, do not: share its saved state instead.
Your fixture’s scopeMay use
functionEverything: page, context, new_context, browser, playwright
class / modulebrowser, playwright, browser_name and other session fixtures
sessionSession fixtures only: browser, playwright, browser_type, tmp_path_factory, pytestconfig

✅ Good Practices for Playwright Fixtures

  1. Name fixtures after the state they give you: logged_in_page, cart_with_two_items, admin_api.
  2. Assert inside setup fixtures (as logged_in_page does) so a broken setup shows up as a setup error.
  3. Keep tests isolated: share saved state (cookies, tokens) across tests, never a live page or context.
  4. Clean up server-side data after yield; browser data disappears with the context automatically.
  5. **Put shared fixtures in conftest.py** and keep page objects in their own module as the project grows.

❓ Frequently Asked Questions

🤔 Can a custom fixture use the page fixture?

Yes, as long as your fixture is function-scoped (the default). The fixture and the test then share the same page object.

🤔 How do I reuse a login across tests without logging in every time?

Log in once in a session-scoped fixture that uses browser, save context.storage_state(path=...), and create each test’s context with new_context(storage_state=path).

🤔 Why is my test reported as an error instead of a failure?

The exception happened in a fixture (setup or teardown), not in the test body. Check the fixture named in the traceback.

🤔 Can I pass arguments to a fixture?

Not directly. Return a function from the fixture (a factory) and call it in the test with the arguments, or use params or indirect parametrization for fixed values.


🏁 Summary

  • Custom fixtures package preparation, such as an opened page object, a logged-in user or products in the cart, into one parameter.
  • Chain fixtures: page → login_page → logged_in_page → add_to_cart.
  • Log in once with a session fixture built on browser, save storage_state, and start each test from it with new_context(storage_state=...).
  • Factory fixtures accept arguments; yield gives you teardown.
  • Listener fixtures, such as js_errors, watch the page during the test.
  • Session fixtures cannot use page: pytest raises ScopeMismatch.

Next, you will control which browsers each test runs on with skip_browser, only_browser and per-test launch options.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

Leave a Comment