conftest.py Patterns for Test Setup

9 min read

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-playwright fixtures (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

conftest.py hierarchy
  • – 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:

  1. browser_context_args override — viewport, locale, timezone, permissions for every test, set once.
  2. authenticated_page — the workhorse fixture you'll inject into 80% of your tests.
  3. log_test_name — autouse fixture that prints which test is running. Cheap and helpful when CI logs scroll.
  4. screenshot_on_failure — autouse fixture plus a small hook that captures a full-page screenshot when a test fails.
  5. pytest_runtest_makereport hook — 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:3000

Or 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 --headed

Or per-run:

pytest --browser firefox
pytest --browser chromium --browser firefox       # run on both
pytest --headless                                 # force headless
pytest --slowmo 100                               # 100ms between actions

addopts 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_args fixture in conftest
  • playwright.config.ts projects: array → --browser CLI flags or addopts in pytest.ini
  • TS test.extend({ loggedInPage: ... })@pytest.fixture + conftest.py
  • TS test.beforeEach(...)autouse=True fixture 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_args in 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.py inside the package's __init__.py-only folders. pytest only picks up conftest.py in folders it actually scans for tests. If your conftest lives in a folder pytest never walks (e.g., a sibling of tests/), its fixtures never load. Keep conftest files inside the test tree.
  • Defining the same fixture name in two sibling conftests. tests/auth/conftest.py and tests/products/conftest.py can both define current_user with 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.

  1. 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
    
  2. In tests/conftest.py define:

    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
  3. In tests/auth/conftest.py add a feature-specific fixture:

    import pytest
     
    @pytest.fixture
    def invalid_credentials():
        return {"username": "locked_out_user", "password": "secret_sauce"}
  4. In tests/auth/test_login.py write a test that uses both the feature-local invalid_credentials and the root page:

    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")
  5. In tests/inventory/test_products.py write a test that uses the root logged_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)
  6. Run pytest tests/ -v -s. Both tests pass. The autouse log_test_name fixture prints for every test; the invalid_credentials fixture is only available inside tests/auth/.

  7. Force a fixture mismatch. Add invalid_credentials as a parameter on the inventory test. pytest reports fixture 'invalid_credentials' not found — feature-specific fixtures don't leak to siblings, by design.

  8. Stretch: add a screenshot_on_failure autouse fixture (using the hook pattern from earlier in the lesson). Force a test to fail (assert against a wrong value), confirm a failures/<test_name>.png file 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.

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