Project Structure for Large Python Test Suites

9 min read

A 30-test prototype lives happily in one folder; a 300-test suite that nine engineers contribute to needs structure. The wrong folder layout makes every change harder than it should be — fixtures live in random places, page objects collide with utility code, conftest hierarchies tangle. The right layout is boring, predictable, and makes "where does this go?" answerable in one second by anyone on the team. This lesson covers the convention that production Playwright Python projects converge on, the rules pytest enforces, the discovery filters that let you slice a big suite cleanly, and the refactor sequence for taking an old flat layout to a feature-organised one.

playwright-tests/
├── pages/                       ← page objects
│   ├── __init__.py
│   ├── base_page.py
│   ├── login_page.py
│   ├── product_page.py
│   └── checkout_page.py
├── fixtures/                    ← test data (JSON, YAML, CSV)
│   ├── users/
│   │   ├── admin.json
│   │   └── standard.json
│   └── products/
│       └── sample_list.json
├── utils/                       ← shared utilities
│   ├── __init__.py
│   ├── data_factory.py
│   ├── api_client.py
│   └── constants.py
├── tests/                       ← test files
│   ├── conftest.py              ← root fixtures
│   ├── auth/
│   │   ├── conftest.py
│   │   └── test_login.py
│   ├── products/
│   │   ├── conftest.py
│   │   └── test_search.py
│   ├── checkout/
│   │   └── test_checkout.py
│   └── api/
│       └── test_users_api.py
├── reports/                     ← generated reports (gitignored)
├── .auth/                       ← storage state files (gitignored)
├── requirements.txt
├── pytest.ini
└── README.md

Five top-level directories, each with one job:

  • pages/ — page objects, mirrored loosely to product surfaces.
  • fixtures/ — version-controlled test data, JSON/YAML files non-engineers can edit.
  • utils/ — reusable helpers that aren't page objects (factories, API clients, constants).
  • tests/ — actual test files, organised by feature.
  • reports/ and .auth/ — generated artefacts, never committed.

The first four are committed. The last two live in .gitignore.

Feature-based test organisation

The most important rule: organise tests/ by feature, not by test type. The wrong way:

tests/
├── unit/
├── integration/
└── e2e/

A developer changing the checkout flow has to update files in three places. A new contributor doesn't know where to look for an existing checkout test. There's no scoping benefit — tests/unit/ and tests/e2e/ get the same fixtures from the same conftest.

The right way:

tests/
├── auth/
├── products/
├── checkout/
└── api/

Now: changing the checkout flow means changes inside tests/checkout/. Looking for an existing search test? tests/products/. Each folder has its own conftest.py for feature-specific fixtures, and the root conftest.py holds cross-cutting fixtures. Test type goes on a marker (@pytest.mark.smoke, @pytest.mark.api); folder structure goes by feature.

File and class naming — pytest's discovery rules

pytest only runs tests that match its naming conventions. Memorise these:

  • Test files: test_*.py or *_test.py. The first form is overwhelmingly more common in Python — use it.
  • Test classes: Test... (capital T, no underscore). class TestLogin:, not class LoginTest: or class testLogin:.
  • Test functions/methods: test_*. def test_login_succeeds(...), not def login_succeeds_test().
  • Page object files: <noun>_page.py. login_page.py, product_page.py. The _page suffix tells you at a glance what a file is.
  • Page object classes: <Noun>Page. class LoginPage:, class ProductPage:.

Break any of these rules and pytest will silently not run your test. There's no error — the file or function is just skipped. When something doesn't run that should, naming is the first thing to check.

Project structure as a concept map

Playwright Python project structure
  • – Organised by feature (auth, products, checkout)
  • – Each feature has its own conftest.py
  • – Test type as a marker, not a folder
  • – One file per page or component
  • – BasePage at the root of the inheritance tree
  • – Imported as 'from pages.login_page import LoginPage'
  • – fixtures/: version-controlled test data, JSON/YAML
  • – utils/: data factories, API clients, constants
  • – Both committed to git, both shared across feature folders
  • Storage state JSON files — gitignored –
  • Generated screenshots and HTML reports — gitignored –
  • Recreated on every CI run, never committed –

Five branches, each a single responsibility. New folders that don't fit one of the five are usually a sign of accumulated complexity that needs refactoring back to the convention.

Running subsets — the daily workflow

A 300-test suite that runs end-to-end takes minutes. Most of the time you want to run a slice. The four ways to slice:

pytest tests/auth/                    # one feature
pytest tests/auth/test_login.py       # one file
pytest tests/auth/test_login.py::TestLogin::test_admin_login  # one method
pytest -m smoke                       # by marker
pytest -k "login and not admin"       # by name substring

Stack the filters:

pytest tests/auth/ -m smoke -k "not slow"

Smoke tests in tests/auth/ whose names don't contain "slow." Three filters layered, no test ever runs that doesn't match all three. This is the workflow that keeps a big suite usable — never run more tests than you actually need.

pytest.ini — the project's runtime config

The single source of truth for runtime defaults:

[pytest]
addopts = --browser chromium --headed
base_url = https://staging.myapp.com
markers =
    smoke: critical-path tests run on every PR
    regression: full regression run nightly
    slow: tests that take more than 30 seconds
    api: API-only tests (no browser)
    visual: visual regression tests
    a11y: accessibility tests
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
filterwarnings =
    error::pytest.PytestUnknownMarkWarning

Five things:

  1. addopts — flags applied on every run. Override per-call with pytest --browser firefox.
  2. base_url — the URL page.goto("/") resolves against. Override with --base-url.
  3. markers — registered marker names. Without registration, custom markers throw warnings.
  4. testpaths — restricts discovery to tests/. Stops pytest scanning pages/ or utils/ for test files.
  5. filterwarnings — turning unknown-marker warnings into errors keeps the marker registry honest.

Set this up on day one. Adding to it later is fine; missing it on day one means tests run with surprising defaults the team doesn't know about.

What goes in __init__.py

Two patterns:

  • Empty __init__.py — marks the folder as a Python package so imports work. The minimum.
  • Re-export __init__.pyfrom .login_page import LoginPage so consumers can from pages import LoginPage instead of from pages.login_page import LoginPage.

The re-export pattern is convenient for short imports but easy to abuse — once a folder has 30 page objects, the __init__.py becomes its own maintenance burden. Most teams keep __init__.py files empty and accept the longer imports; the IDE auto-completes them anyway.

A quick refactor sequence

Got an old flat layout — every test in one folder, every page object inline in the test file? The order to refactor:

  1. Move page objects into pages/. One file per class. Update imports in every test file that uses them.
  2. Identify test data hardcoded in tests. Move JSON-shaped data into fixtures/. Keep dataclass definitions in utils/.
  3. Create feature subfolders in tests/. Move test_*.py files into the right folder. Delete any folder-level fixtures duplicated across features back to the root conftest.py.
  4. Tag tests with markers. @pytest.mark.smoke for the critical paths, @pytest.mark.regression for the rest. Register them in pytest.ini.
  5. Add pytest.ini if it didn't exist. testpaths, markers, filterwarnings set so the suite is strict and predictable.

Each step is a small commit. Don't try to refactor the whole tree in one PR — the diff is too big to review and breaks too much at once. One feature folder per commit, half a day per commit, done in a sprint.

Coming from Playwright TypeScript?

The TS course's recommended structure mirrors this lesson with TS-specific names:

  • TS pages/login.page.ts → Python pages/login_page.py
  • TS tests/auth/login.spec.ts → Python tests/auth/test_login.py
  • TS playwright.config.ts → Python pytest.ini plus conftest.py
  • TS fixtures/data.json → Python fixtures/data.json (identical)

The conceptual layout is identical; the file extensions and config formats are different. A TS Playwright engineer joining a Python suite finds their way around in minutes.

⚠️ Common mistakes

  • Putting tests next to the source code they test. src/login/login.test.py mixes test code with production code, creates ambiguous import paths, and confuses pytest discovery (testpaths gets weird). Keep tests in their own top-level tests/ directory; import the production code from there.
  • A single 5000-line conftest.py at the root. Once your root conftest has more than a few dozen fixtures, split it. Move feature-specific fixtures into tests/<feature>/conftest.py; keep the root for genuinely cross-cutting concerns (auth, API client, screenshot-on-failure). The hierarchy is the point.
  • Inconsistent naming conventions across the team. Some people write class Login_Test:, some write class LoginTest:, some write class TestLogin:. Only the third is picked up by pytest. Document the conventions in the README, lint them with ruff or flake8 rules, and fix violations in the PR before merge.

🎯 Practice task

Refactor a flat-layout test project into the convention. 30-40 minutes.

  1. Take an existing flat-layout project (or build a tiny one to refactor):

    tests/
    ├── conftest.py
    ├── test_login.py
    ├── test_products.py
    └── test_checkout.py
    
  2. Restructure into feature folders:

    tests/
    ├── conftest.py
    ├── auth/
    │   ├── conftest.py
    │   └── test_login.py
    ├── products/
    │   ├── conftest.py
    │   └── test_products.py
    └── checkout/
        └── test_checkout.py
    
  3. Create top-level pages/ and utils/ directories. Move any inline page object classes from test files into pages/<noun>_page.py. Create empty __init__.py in each.

  4. Create fixtures/users/standard.json and move any hardcoded user dicts from test files into the fixture file. Update tests to load via json.load(...).

  5. Update pytest.ini:

    [pytest]
    addopts = --browser chromium
    base_url = https://www.saucedemo.com
    testpaths = tests
    markers =
        smoke: critical-path tests
        regression: full regression
    filterwarnings =
        error::pytest.PytestUnknownMarkWarning
  6. Tag a couple of tests with @pytest.mark.smoke and a couple with @pytest.mark.regression. Run pytest -m smoke -v and pytest -m regression -v to confirm the markers work.

  7. Run pytest tests/auth/ -v — only auth tests run. Run pytest -k "login" -v — only tests with "login" in the name run.

  8. Stretch: add pages/ to pytest.ini's --ignore=pages (or just confirm testpaths = tests keeps pytest out of pages/). Add a deliberate def test_x(): ... inside pages/login_page.py and confirm pytest does not try to run it — the test path filter excludes everything outside tests/.

You've got the project structure. The next lesson digs into the building blocks that fill it — base page classes, shared utilities, type hints, and the API client pattern.

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