conftest.py is pytest's most powerful single feature. It's a regular Python file pytest treats specially — fixtures, hooks, and plugins defined there are auto-discovered by every test in the same directory and below, with no import statements. For a Playwright Python suite, conftest.py is where you wire up your authenticated page, override the browser context defaults, configure base URLs, and run the cross-cutting setup every test silently depends on. This lesson covers the directory hierarchy, the special browser_context_args fixture, the production patterns, and the trade-offs to know.
The directory hierarchy — conftest.py at every level
pytest scans for conftest.py files at every directory from the root down to each test file. Inner conftests are additive over outer ones — fixtures from the root conftest are visible everywhere, and an inner conftest can override a parent fixture by redefining it.
tests/
├── conftest.py ← fixtures for ALL tests
├── auth/
│ ├── conftest.py ← fixtures for auth tests only
│ └── test_login.py
└── products/
├── conftest.py ← fixtures for product tests only
└── test_search.py
A test inside tests/auth/test_login.py can use:
- Fixtures from
tests/auth/conftest.py(its own folder) - Fixtures from
tests/conftest.py(parent folder) - Built-in
pytest-playwrightfixtures (page,context, etc.)
A test inside tests/products/test_search.py cannot see fixtures defined in tests/auth/conftest.py — they're scoped to the auth folder. Use this to isolate fixture names that only make sense in one feature area.
How conftest scopes interact
- – Cross-cutting fixtures: logged_in_page, api_token
- – browser_context_args override (viewport, locale, timezone)
- – Autouse hooks: log_test_name, screenshot_on_failure
- – Auth-specific fixtures: admin_page, oauth_token
- – Visible only inside tests/auth/
- – Can override root fixtures by redefining them
- Can use any fixture visible from their position –
- Local fixtures can override conftest fixtures –
- Inherit autouse fixtures automatically –
The mental model: each test file sits in a cone of conftest files from itself up to the project root. Fixtures resolve from inside-out — the closest definition wins.
A production-grade root conftest.py
The five things almost every Playwright Python project's root conftest.py does:
# tests/conftest.py
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
"""Override defaults for every browser context Playwright opens."""
return {
**browser_context_args,
"viewport": {"width": 1280, "height": 720},
"locale": "en-GB",
"timezone_id": "Europe/London",
"permissions": ["geolocation"],
"ignore_https_errors": True,
}
@pytest.fixture
def authenticated_page(page: Page) -> Page:
"""A page already logged in as the standard test user."""
page.goto("/login")
page.get_by_label("Email").fill("admin@test.com")
page.get_by_label("Password").fill("AdminPass")
page.get_by_role("button", name="Login").click()
expect(page).to_have_url("/dashboard")
return page
@pytest.fixture(autouse=True)
def log_test_name(request):
"""Print which test is running — useful for debugging long CI runs."""
print(f"\n--- Running: {request.node.name} ---")
yield
@pytest.fixture(autouse=True)
def screenshot_on_failure(request, page: Page):
"""Save a screenshot when a test fails — invaluable for CI debugging."""
yield
if request.node.rep_call.failed:
path = f"failures/{request.node.name}.png"
page.screenshot(path=path, full_page=True)
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
"""Hook required for screenshot_on_failure to access test result."""
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)Five distinct things:
browser_context_argsoverride — viewport, locale, timezone, permissions for every test, set once.authenticated_page— the workhorse fixture you'll inject into 80% of your tests.log_test_name— autouse fixture that prints which test is running. Cheap and helpful when CI logs scroll.screenshot_on_failure— autouse fixture plus a small hook that captures a full-page screenshot when a test fails.pytest_runtest_makereporthook — the wiring that lets the screenshot fixture see the test outcome.
browser_context_args — the magic override fixture
pytest-playwright exposes a fixture called browser_context_args that controls every BrowserContext it creates. Override it in your conftest to set defaults for the whole suite:
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args, # keep the plugin's defaults
"viewport": {"width": 1280, "height": 720},
"locale": "en-GB",
"timezone_id": "Europe/London",
"test_id_attribute": "data-cy", # if your team uses data-cy
"ignore_https_errors": True,
"record_video_dir": "videos/", # record video of every test
}The function signature is unusual: it takes a parameter also called browser_context_args. That's pytest fixture overriding — your override receives the plugin's default version, and you spread it (**browser_context_args) before adding your own keys. Forgetting the spread loses the plugin defaults — easy mistake.
The full list of options matches browser.new_context(...): viewport, locale, timezone, permissions, geolocation, color_scheme, user_agent, storage_state, etc. Configure your test environment here once.
Configuring base URL
Two ways. Static, in pytest.ini:
[pytest]
base_url = http://localhost:3000Or per-run via the CLI:
pytest --base-url https://staging.myapp.com
pytest --base-url https://production.myapp.com tests/smoke/This is the same base_url from chapter 1. Combine with environment variables for CI flexibility:
[pytest]
base_url = ${BASE_URL}pytest.ini doesn't expand env vars on its own — the canonical Python way is the pytest-env plugin, or set the URL via the --base-url flag in your CI config and not in pytest.ini at all.
Configuring the browser
Same two patterns. pytest.ini:
[pytest]
addopts = --browser chromium --headedOr per-run:
pytest --browser firefox
pytest --browser chromium --browser firefox # run on both
pytest --headless # force headless
pytest --slowmo 100 # 100ms between actionsaddopts is a flag list pytest applies to every run. Drop --headed from pytest.ini for CI (no display server) and rely on the dev-machine command-line flag when you want to watch tests.
Feature-specific conftest.py
Inside tests/auth/conftest.py, define fixtures that only make sense for auth tests:
# tests/auth/conftest.py
import pytest
@pytest.fixture
def admin_credentials():
return {"email": "admin@test.com", "password": "AdminPass"}
@pytest.fixture
def oauth_redirect_url():
return "https://oauth.example.com/authorize?response_type=code&..."Tests in tests/auth/ see both these fixtures and every fixture from the root tests/conftest.py. Tests in tests/products/ see only the root fixtures — no name pollution from auth-specific ones. As your suite grows past a few feature folders, this isolation pays for itself.
A custom assertion helper — the small fixture pattern
Sometimes you want a function available to every test, not a value. The trick: a fixture that returns a callable.
@pytest.fixture
def expect_toast(page: Page):
def _expect_toast(message: str, kind: str = "success"):
toast = page.get_by_test_id(f"toast-{kind}")
expect(toast).to_be_visible()
expect(toast).to_contain_text(message)
return _expect_toast
def test_save_shows_success_toast(authenticated_page: Page, expect_toast):
authenticated_page.get_by_role("button", name="Save").click()
expect_toast("Profile saved")The fixture returns the inner function; tests call it with the message they expect. Bottles up a four-line assertion into a one-liner that reads like English.
Coming from Playwright TypeScript?
In the TypeScript course, this configuration lives in playwright.config.ts plus per-test test.extend(). In Python:
playwright.config.ts use:block →browser_context_argsfixture in conftestplaywright.config.ts projects:array →--browserCLI flags oraddoptsin pytest.ini- TS
test.extend({ loggedInPage: ... })→@pytest.fixture+conftest.py - TS
test.beforeEach(...)→autouse=Truefixture or in-test fixture parameter - TS
test.afterEach(...)→ the post-yield half of a yielding fixture
Functionally identical. The Python version reads more like ordinary Python (fixtures are just decorated functions); the TypeScript version benefits from full TypeScript types on the test object. Pick the ergonomics that fit your team.
⚠️ Common mistakes
- Forgetting to spread
**browser_context_argsin the override. Returning{"viewport": ...}instead of{**browser_context_args, "viewport": ...}replaces the plugin's defaults instead of merging — losing properties like the test_id_attribute the plugin sets internally. Always spread the parent first. - Putting
conftest.pyinside the package's__init__.py-only folders. pytest only picks upconftest.pyin folders it actually scans for tests. If your conftest lives in a folder pytest never walks (e.g., a sibling oftests/), its fixtures never load. Keep conftest files inside the test tree. - Defining the same fixture name in two sibling conftests.
tests/auth/conftest.pyandtests/products/conftest.pycan both definecurrent_userwith different bodies — that's fine. But two parallel conftest files at the same level with the same fixture name in their visible scope confuses the resolution. Prefer feature-specific naming (auth_user,product_user) when in doubt.
🎯 Practice task
Build a real, multi-folder conftest layout. 30 minutes.
-
Restructure your
tests/folder so it has two feature directories:tests/ ├── conftest.py ├── auth/ │ ├── conftest.py │ └── test_login.py └── inventory/ ├── conftest.py └── test_products.py -
In
tests/conftest.pydefine:import pytest from playwright.sync_api import Page, expect @pytest.fixture(scope="session") def browser_context_args(browser_context_args): return { **browser_context_args, "viewport": {"width": 1280, "height": 720}, "locale": "en-GB", } @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 @pytest.fixture(autouse=True) def log_test_name(request): print(f"\n--- Running: {request.node.name} ---") yield -
In
tests/auth/conftest.pyadd a feature-specific fixture:import pytest @pytest.fixture def invalid_credentials(): return {"username": "locked_out_user", "password": "secret_sauce"} -
In
tests/auth/test_login.pywrite a test that uses both the feature-localinvalid_credentialsand the rootpage:from playwright.sync_api import Page, expect def test_locked_out_user_sees_error(page: Page, invalid_credentials): page.goto("/") page.get_by_placeholder("Username").fill(invalid_credentials["username"]) page.get_by_placeholder("Password").fill(invalid_credentials["password"]) page.get_by_role("button", name="Login").click() expect(page.get_by_test_id("error")).to_contain_text("locked out") -
In
tests/inventory/test_products.pywrite a test that uses the rootlogged_in_page:from playwright.sync_api import Page, expect def test_six_products_visible(logged_in_page: Page): expect(logged_in_page.locator(".inventory_item")).to_have_count(6) -
Run
pytest tests/ -v -s. Both tests pass. The autouselog_test_namefixture prints for every test; theinvalid_credentialsfixture is only available insidetests/auth/. -
Force a fixture mismatch. Add
invalid_credentialsas a parameter on the inventory test. pytest reportsfixture 'invalid_credentials' not found— feature-specific fixtures don't leak to siblings, by design. -
Stretch: add a
screenshot_on_failureautouse fixture (using the hook pattern from earlier in the lesson). Force a test to fail (assert against a wrong value), confirm afailures/<test_name>.pngfile lands in the project root.
You've got the conftest blueprint. The next lesson is pytest.mark.parametrize — pytest's data-driven testing engine, which turns one test function into N parameterised runs without ever copy-pasting the body.