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.
📖 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
LoginPagegroups the login page’s locators and actions in one class. This is the Page Object Model, covered in depth in Module 10.- The
login_pagefixture builds on pytest-playwright’spage, opens the page and returns the object. A test that asks forlogin_pagestarts on the login screen. wait_until="domcontentloaded": as you saw in Module 5, a public site’sloadevent 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_pageasks forlogin_page, which asks forpage. 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_stateis session-scoped, so it may only use session-scoped fixtures. It usesbrowser(session) and pytest’s built-intmp_path_factory(session), notpage(function).context.storage_state(path=...)writes the context’s cookies and localStorage to a JSON file. SauceDemo keeps the login in a cookie namedsession-username.fast_logged_in_pagecallsnew_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 _addhands the inner function to the test. Everything afteryieldis teardown.- The
addedlist 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_errorsand the test receive the samepageobject: 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.paramholds the current parameter;idsgives 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), asauth_statedoes. To share a page, do not: share its saved state instead.
| Your fixture’s scope | May use |
|---|---|
| function | Everything: page, context, new_context, browser, playwright |
| class / module | browser, playwright, browser_name and other session fixtures |
| session | Session fixtures only: browser, playwright, browser_type, tmp_path_factory, pytestconfig |
✅ Good Practices for Playwright Fixtures
- Name fixtures after the state they give you:
logged_in_page,cart_with_two_items,admin_api. - Assert inside setup fixtures (as
logged_in_pagedoes) so a broken setup shows up as a setup error. - Keep tests isolated: share saved state (cookies, tokens) across tests, never a live page or context.
- Clean up server-side data after
yield; browser data disappears with the context automatically. - **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, savestorage_state, and start each test from it withnew_context(storage_state=...). - Factory fixtures accept arguments;
yieldgives 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