Going beyond simply connecting Playwright and pytest — designing your folder structure, fixture hierarchy, and mark strategy from the start is what lets you build a test automation foundation that stays maintainable long-term.
📌 Who This Article Is For
- Engineers who’ve started writing E2E tests with Playwright and pytest but find their codebase getting messy
- QA engineers who want to use conftest.py, fixtures, and marks correctly
- Those who want a reusable, team-friendly test structure template
- Anyone looking to refactor existing test code
✅ What You’ll Learn
- Production-ready folder structure, conftest.py, and pytest.ini configurations
- Fixture hierarchy design and scope selection with real examples
- How to manage tests smartly using smoke, regression, and e2e marks
👤 About the Author
Working as a QA engineer using Selenium, Playwright, and pytest in real-world test automation projects. The structure shared in this article is based on trial and error across actual projects — only the patterns that genuinely work in production are included. Code is publicly available on GitHub: github.com/YOSHITSUGU728/automated-testing-portfolio
📌 The 3 Pillars of Best Practices
- Standardize your folder structure: Separate code into pages/, tests/, and utils/ layers with clear responsibilities
- Design fixtures in layers: Use session → module → function scopes intentionally based on purpose
- Control execution with marks: Group tests as smoke, regression, and e2e to integrate smartly with CI/CD
Combining Playwright and Python makes it easy to write E2E tests. But as your test suite grows, you’ll often run into problems like these:
- Fixtures multiply and you lose track of where to put them
- Selectors end up scattered throughout your test files
- It becomes unclear which tests to run and when in CI/CD
This article walks you through the Playwright × pytest best practices structure that prevents these problems — from folder design to fixtures, conftest.py, and pytest.ini, all in one place.
- ① Recommended Folder Structure
- ② pytest.ini — Complete Configuration
- ③ conftest.py — Layered Design
- ④ Page Object Model Implementation
- ⑤ Test File Implementation
- ⑥ Controlling Test Execution with Marks
- ⑦ GitHub Actions Integration
- ⑧ Sample Execution Output
- FAQ
- ⚠️ 7 Common Pitfalls in Playwright + pytest Project Structure
- 📋 Summary
① Recommended Folder Structure
Let’s start with the overall folder structure. Two patterns are introduced: a simple version for small projects and an expanded version for medium-to-large teams.
Simple Version (Small Projects / Learning)
my_project/
├── pages/ # Page Object Model (page interaction classes)
│ ├── __init__.py
│ ├── login_page.py
│ └── inventory_page.py
├── tests/ # Test files
│ ├── conftest.py # Shared fixtures
│ ├── test_login.py
│ └── test_inventory.py
├── pytest.ini # pytest configuration
└── requirements.txtExpanded Version (Medium–Large Projects / Team Use)
my_project/
├── pages/ # Page Object Model
│ ├── __init__.py
│ ├── base_page.py # Base class shared by all pages
│ ├── login_page.py
│ └── inventory_page.py
├── tests/ # Test files
│ ├── conftest.py # Session/browser-level fixtures
│ ├── e2e/ # E2E tests (full user flows)
│ │ ├── conftest.py # E2E-specific fixtures
│ │ └── test_purchase_flow.py
│ ├── smoke/ # Smoke tests (minimal sanity checks)
│ │ └── test_login_smoke.py
│ └── regression/ # Regression tests
│ └── test_cart.py
├── utils/ # Helper functions and constants
│ ├── __init__.py
│ └── constants.py # URL and test data constants
├── reports/ # Test report output
├── pytest.ini
└── requirements.txt② pytest.ini — Complete Configuration
Place pytest.ini at the project root. With this in place, every team member runs tests with identical settings.
# pytest.ini
[pytest]
# Test discovery path
testpaths = tests
# Default options
addopts =
-v
--tb=short
--html=reports/report.html
--self-contained-html
# Custom mark registration (unregistered marks produce warnings)
markers =
smoke: Smoke tests (minimal sanity checks, run on every push)
regression: Regression tests (run before release)
e2e: E2E tests (full flows, time-consuming)
slow: Tests that take a long time to run
# Log settings
log_cli = true
log_cli_level = INFO
log_format = %(asctime)s %(levelname)s %(message)s
log_date_format = %Y-%m-%d %H:%M:%S③ conftest.py — Layered Design
The best practice for conftest.py is to separate fixtures by scope — placing them in the directory that matches their intended reach.
tests/conftest.py (Shared Across the Entire Project)
# tests/conftest.py
import pytest
from playwright.sync_api import sync_playwright
# ============================================================
# Browser / page fixtures (session scope)
# ============================================================
@pytest.fixture(scope="session")
def browser_instance():
"""Share a single browser instance across the entire test session"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
yield browser
browser.close()
@pytest.fixture(scope="function")
def page(browser_instance):
"""Create a fresh page (tab) for each test"""
context = browser_instance.new_context(
viewport={"width": 1280, "height": 720}
)
page = context.new_page()
yield page
context.close() # close the context after each test
# ============================================================
# Authentication fixtures
# ============================================================
@pytest.fixture(scope="session")
def base_url():
"""Return the base URL for the test target"""
# In actual code, add https:// prefix (e.g. "https://www.saucedemo.com")
return "www.saucedemo.com"
@pytest.fixture
def valid_credentials():
"""Login credentials for happy-path tests"""
return {
"username": "standard_user",
"password": "secret_sauce"
}
@pytest.fixture
def locked_out_credentials():
"""Login credentials for locked-out user (error case tests)"""
return {
"username": "locked_out_user",
"password": "secret_sauce"
}
# ============================================================
# Pre-logged-in page fixture (commonly needed — centralize it)
# ============================================================
@pytest.fixture
def logged_in_page(page, base_url, valid_credentials):
"""Return a page fixture already in the logged-in state"""
page.goto(f"https://{base_url}/")
page.fill("#user-name", valid_credentials["username"])
page.fill("#password", valid_credentials["password"])
page.click("#login-button")
page.wait_for_url("**/inventory.html")
return pagetests/e2e/conftest.py (E2E Tests Only)
# tests/e2e/conftest.py
import pytest
@pytest.fixture
def cart_items():
"""Test data for cart items used in E2E tests"""
return ["Sauce Labs Backpack", "Sauce Labs Bike Light"]
@pytest.fixture
def shipping_info():
"""Shipping information for E2E checkout tests"""
return {
"first_name": "Test",
"last_name": "User",
"postal_code": "10001"
}tests/conftest.py. Fixtures only needed for a specific category belong in that category’s own conftest.py — this prevents unrelated fixtures from mixing together.④ Page Object Model Implementation
Here’s how to implement Page Object Model alongside pytest. The key is putting shared behavior in a base class.
pages/base_page.py (Base Class)
# pages/base_page.py
from playwright.sync_api import Page
class BasePage:
"""Base class containing operations common to all pages"""
def __init__(self, page: Page):
self.page = page
def navigate(self, url: str):
"""Navigate to the specified URL"""
self.page.goto(url)
def get_title(self) -> str:
"""Return the current page title"""
return self.page.title()
def take_screenshot(self, name: str):
"""Save a screenshot to the reports folder"""
self.page.screenshot(path=f"reports/{name}.png")pages/login_page.py
# pages/login_page.py
from pages.base_page import BasePage
class LoginPage(BasePage):
"""Page Object for the login page"""
# Locator constants (manage all selectors in one place)
USERNAME_INPUT = "#user-name"
PASSWORD_INPUT = "#password"
LOGIN_BUTTON = "#login-button"
ERROR_MESSAGE = ".error-message-container h3"
def login(self, username: str, password: str):
"""Perform a login action"""
self.page.fill(self.USERNAME_INPUT, username)
self.page.fill(self.PASSWORD_INPUT, password)
self.page.click(self.LOGIN_BUTTON)
def get_error_message(self) -> str:
"""Return the error message text"""
return self.page.locator(self.ERROR_MESSAGE).text_content()⑤ Test File Implementation
Here’s the complete test file combining fixtures and Page Objects.
tests/smoke/test_login_smoke.py
# tests/smoke/test_login_smoke.py
import pytest
from pages.login_page import LoginPage
@pytest.mark.smoke
def test_login_success(page, base_url, valid_credentials):
"""Smoke: verify that a valid login succeeds"""
login_page = LoginPage(page)
login_page.navigate(f"https://{base_url}/")
login_page.login(
valid_credentials["username"],
valid_credentials["password"]
)
assert page.url.endswith("/inventory.html"), \
f"URL after login does not match expected: {page.url}"
@pytest.mark.smoke
def test_login_wrong_password(page, base_url):
"""Smoke: verify that a wrong password shows an error message"""
login_page = LoginPage(page)
login_page.navigate(f"https://{base_url}/")
login_page.login("standard_user", "wrong_password")
error = login_page.get_error_message()
assert "Epic sadface" in error
@pytest.mark.smoke
def test_login_locked_out_user(page, base_url, locked_out_credentials):
"""Smoke: verify that a locked-out user sees an error message"""
login_page = LoginPage(page)
login_page.navigate(f"https://{base_url}/")
login_page.login(
locked_out_credentials["username"],
locked_out_credentials["password"]
)
error = login_page.get_error_message()
assert "locked out" in errortests/e2e/test_purchase_flow.py
# tests/e2e/test_purchase_flow.py
import pytest
@pytest.mark.e2e
@pytest.mark.slow
def test_purchase_flow_success(logged_in_page, cart_items, shipping_info):
"""E2E: test the full flow from login to order completion"""
page = logged_in_page
# Add items to cart
page.click("[data-test='add-to-cart-sauce-labs-backpack']")
page.click("[data-test='add-to-cart-sauce-labs-bike-light']")
# Navigate to cart
page.click(".shopping_cart_link")
assert page.url.endswith("/cart.html")
# Begin checkout
page.click("[data-test='checkout']")
page.fill("[data-test='firstName']", shipping_info["first_name"])
page.fill("[data-test='lastName']", shipping_info["last_name"])
page.fill("[data-test='postalCode']", shipping_info["postal_code"])
page.click("[data-test='continue']")
# Confirm and complete order
page.click("[data-test='finish']")
success_msg = page.locator(".complete-header").text_content()
assert "Thank you" in success_msg⑥ Controlling Test Execution with Marks
With marks in place, you can flexibly switch which tests to run depending on the phase of your CI/CD pipeline.
| Mark | Purpose | When to run | Typical duration |
|---|---|---|---|
smoke | Minimal sanity check | Every PR and push | ~2 min |
regression | Verify existing features haven’t broken | Before merging to main | ~10 min |
e2e | Full user flow verification | Pre-release / nightly schedule | ~30 min |
slow | Long-running tests | Nightly schedule only | 30+ min |
# Run only smoke tests (every PR/push)
pytest -m smoke
# Run smoke + regression (before merging to main)
pytest -m "smoke or regression"
# Run everything except E2E (for fast feedback)
pytest -m "not e2e"
# Run everything except slow tests
pytest -m "not slow"
# Run all tests (nightly schedule)
pytest⑦ GitHub Actions Integration
Combining marks with GitHub Actions lets you automatically run different test sets depending on the pipeline phase.
# .github/workflows/playwright.yml (excerpt)
jobs:
smoke:
name: Smoke Tests (PR / push)
runs-on: ubuntu-latest
steps:
# ... setup omitted ...
- name: Run smoke tests
run: pytest -m smoke
regression:
name: Regression Tests (after merge to main)
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
# ... setup omitted ...
- name: Run regression tests
run: pytest -m "smoke or regression"
nightly:
name: Nightly Full Test Suite
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
# ... setup omitted ...
- name: Run all tests
run: pytest⑧ Sample Execution Output
$ pytest -m smoke -v
========================= test session starts ==========================
platform linux -- Python 3.11.0, pytest-7.4.0
collected 8 items / 5 deselected / 3 selected
tests/smoke/test_login_smoke.py::test_login_success PASSED [ 33%]
tests/smoke/test_login_smoke.py::test_login_wrong_password PASSED [ 66%]
tests/smoke/test_login_smoke.py::test_login_locked_out_user PASSED [100%]
========================== 3 passed in 5.21s ===========================FAQ
Q. Should I use the built-in pytest-playwright page fixture or write my own?
For small projects or learning, the built-in page fixture from pytest-playwright is the easiest option. In production, you’ll often need fine-grained control over viewport size, network settings, and authentication injection — which is why custom fixtures are more common in real-world setups. The code in this article uses a custom fixture.
Q. Should I keep everything in one conftest.py or split it?
For smaller suites under 20 tests, keeping everything in tests/conftest.py is fine. As the suite grows, split by putting session/browser fixtures in tests/conftest.py and category-specific fixtures in each subdirectory’s own conftest.py. Splitting makes it immediately clear which fixtures are available to which tests.
Q. Should I adopt Page Object Model from the very beginning?
For 5–10 tests or fewer, you can get by without POM. The signal to introduce it is when you find yourself writing the same selector in more than one test file. The earlier you introduce POM, the lower the refactoring cost — so adopting it from the start is generally recommended.
Q. Won’t using session scope for the browser cause state to leak between tests?
Sharing a browser instance at session scope is safe. What matters is that pages (tabs) must always use function scope so a fresh one is created per test. The code in this article uses session for the browser and function for the page, which prevents state from leaking between tests.
⚠️ 7 Common Pitfalls in Playwright + pytest Project Structure
Even with a solid structure in place, there are traps that are easy to fall into. Knowing them in advance saves you a lot of unnecessary debugging.
① Setting browser to function scope makes tests painfully slow
Browser startup is expensive. With scope="function", the browser launches and closes for every single test — with 50 tests, startup alone can eat several minutes. The most efficient setup is scope="session" for the browser and scope="function" for the page.
② Forgetting to close the context causes memory leaks
Any context created with browser.new_context() must be closed with context.close() after the test. Forgetting to add this after the yield in a fixture means memory usage grows with every test that runs.
③ Setting logged_in_page to session scope causes tests to interfere with each other
Making logged_in_page session-scoped to “avoid logging in every test” means cart operations and page navigation from one test carry over into the next, causing unexpected failures. If you want to share authentication state, use storage_state to save session info to a file — that approach is safe.
④ Hardcoding selectors in test files defeats the purpose of POM
Even if you have Page Objects, writing page.click("#login-button") directly in test files means you’ll have to update tests everywhere whenever a selector changes. Selectors belong as constants in Page Object classes — test code should only ever interact through Page Object methods.
⑤ Running -m smoke without registering the mark in pytest.ini doesn’t filter correctly
Adding @pytest.mark.smoke as a decorator alone isn’t enough. Without registering it under [pytest] markers = in pytest.ini, you’ll see PytestUnknownMarkWarning. Worse, running pytest -m smoke with an unregistered mark may result in all tests running instead of just the filtered set.
⑥ Missing __init__.py in pages/ causes import errors
Python needs an empty __init__.py file in the pages/ folder to recognize it as a package. Without it, from pages.login_page import LoginPage will throw a ModuleNotFoundError. The same applies to the utils/ folder.
⑦ Running pytest from the wrong directory causes ModuleNotFoundError
Always run pytest from the project root — the directory containing pytest.ini. Running it from inside tests/ means Python can’t find the pages module.
# ❌ Wrong
cd tests
pytest
# ✅ Correct
cd my_project # the directory with pytest.ini
pytest📖 Related Articles
📋 Summary
- Folder structure: Splitting into pages/, tests/, and utils/ makes responsibilities clear and keeps the codebase maintainable
- conftest.py: Layer by scope — browser at session level, page at function level is the baseline
- Page Object Model: Define selectors as constants in Page Objects; never hardcode them in test files
- Marks: Classifying tests as smoke, regression, and e2e enables precise execution control per CI/CD phase
- pytest.ini: Defining markers, addopts, and testpaths ensures every team member runs tests with identical settings
You don’t need to aim for the perfect structure from day one. Start with the simple folder layout, then gradually expand toward the full version as your test suite grows — that’s the practical approach that works in the real world.

