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.
The recommended structure
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_*.pyor*_test.py. The first form is overwhelmingly more common in Python — use it. - Test classes:
Test...(capital T, no underscore).class TestLogin:, notclass LoginTest:orclass testLogin:. - Test functions/methods:
test_*.def test_login_succeeds(...), notdef login_succeeds_test(). - Page object files:
<noun>_page.py.login_page.py,product_page.py. The_pagesuffix 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
- – 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 substringStack 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.PytestUnknownMarkWarningFive things:
addopts— flags applied on every run. Override per-call withpytest --browser firefox.base_url— the URLpage.goto("/")resolves against. Override with--base-url.markers— registered marker names. Without registration, custom markers throw warnings.testpaths— restricts discovery totests/. Stops pytest scanningpages/orutils/for test files.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__.py—from .login_page import LoginPageso consumers canfrom pages import LoginPageinstead offrom 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:
- Move page objects into
pages/. One file per class. Update imports in every test file that uses them. - Identify test data hardcoded in tests. Move JSON-shaped data into
fixtures/. Keep dataclass definitions inutils/. - Create feature subfolders in
tests/. Movetest_*.pyfiles into the right folder. Delete any folder-level fixtures duplicated across features back to the rootconftest.py. - Tag tests with markers.
@pytest.mark.smokefor the critical paths,@pytest.mark.regressionfor the rest. Register them inpytest.ini. - Add
pytest.iniif it didn't exist.testpaths,markers,filterwarningsset 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→ Pythonpages/login_page.py - TS
tests/auth/login.spec.ts→ Pythontests/auth/test_login.py - TS
playwright.config.ts→ Pythonpytest.iniplusconftest.py - TS
fixtures/data.json→ Pythonfixtures/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.pymixes test code with production code, creates ambiguous import paths, and confuses pytest discovery (testpathsgets weird). Keep tests in their own top-leveltests/directory; import the production code from there. - A single 5000-line
conftest.pyat the root. Once your root conftest has more than a few dozen fixtures, split it. Move feature-specific fixtures intotests/<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 writeclass LoginTest:, some writeclass TestLogin:. Only the third is picked up by pytest. Document the conventions in the README, lint them withrufforflake8rules, and fix violations in the PR before merge.
🎯 Practice task
Refactor a flat-layout test project into the convention. 30-40 minutes.
-
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 -
Restructure into feature folders:
tests/ ├── conftest.py ├── auth/ │ ├── conftest.py │ └── test_login.py ├── products/ │ ├── conftest.py │ └── test_products.py └── checkout/ └── test_checkout.py -
Create top-level
pages/andutils/directories. Move any inline page object classes from test files intopages/<noun>_page.py. Create empty__init__.pyin each. -
Create
fixtures/users/standard.jsonand move any hardcoded user dicts from test files into the fixture file. Update tests to load viajson.load(...). -
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 -
Tag a couple of tests with
@pytest.mark.smokeand a couple with@pytest.mark.regression. Runpytest -m smoke -vandpytest -m regression -vto confirm the markers work. -
Run
pytest tests/auth/ -v— only auth tests run. Runpytest -k "login" -v— only tests with "login" in the name run. -
Stretch: add
pages/topytest.ini's--ignore=pages(or just confirmtestpaths = testskeeps pytest out ofpages/). Add a deliberatedef test_x(): ...insidepages/login_page.pyand confirm pytest does not try to run it — the test path filter excludes everything outsidetests/.
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.