Structuring a suite: page objects, pytest and parallelism
Organise a growing Selenium suite with page objects, fixtures and markers, then run it in parallel without the tests fighting each other.
Fixtures and the driver lifecycle
# conftest.py
import os, pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
BASE_URL = os.environ.get("BASE_URL", "http://localhost:3000")
def make_driver():
options = Options()
if os.environ.get("CI"):
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1440,900")
return webdriver.Chrome(options=options)
@pytest.fixture(scope="session")
def base_url():
return BASE_URL
@pytest.fixture
def driver(base_url):
d = make_driver()
d.implicitly_wait(0) # explicit waits only
yield d
d.quit()
@pytest.fixture
def logged_in(driver, base_url):
# restored session, see the cookies and session reuse lesson
restore_state(driver, base_url)
return driver- One driver per test, created and quit by the fixture. A module-scoped driver shares cookies and page state between tests and produces order-dependent failures.
- Set
implicitly_wait(0)explicitly. An implicit wait silently changes the meaning of every explicit wait you write. yieldguaranteesquit()runs even when the test raises, which is what stops leaked browser processes.- Put shared fixtures in
conftest.pyso they are available without imports.
💡
Keeping one browser process per test is slower but predictable. Sharing a browser across tests couples them through cookies, storage and history, and the resulting failures are the hardest kind to debug. If startup time becomes the bottleneck, parallelise instead of sharing.
Page objects and components
# pages/login.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class LoginPage:
EMAIL = (By.ID, "email")
PASSWORD = (By.ID, "password")
SUBMIT = (By.ID, "submit")
ERROR = (By.CSS_SELECTOR, "[role=alert]")
def __init__(self, driver, base_url):
self.driver = driver
self.url = base_url + "/login"
def open(self):
self.driver.get(self.url)
return self
def submit(self, email, password):
self.driver.find_element(*self.EMAIL).send_keys(email)
self.driver.find_element(*self.PASSWORD).send_keys(password)
self.driver.find_element(*self.SUBMIT).click()
return DashboardPage(self.driver)
def error(self):
return WebDriverWait(self.driver, 5).until(
EC.visibility_of_element_located(self.ERROR)
).text# tests/test_login.py
import pytest
from pages.login import LoginPage
@pytest.mark.smoke
def test_rejects_a_wrong_password(driver, base_url):
page = LoginPage(driver, base_url).open()
page.submit("[email protected]", "wrong")
assert "Incorrect email or password" in page.error()- Locators live in the page class, never in the test. When a designer renames a class, you fix one line.
- Methods should return the next page object, so the test reads as a sequence of user actions.
- Page objects expose meaning, not mechanisms:
submit()rather thanclick_submit_button(). - For repeated UI blocks such as a nav bar or a table row, write a component class and compose it into pages.
Markers and parallel execution
# pytest.ini
[pytest]
markers =
smoke: fast checks on the critical path
slow: long-running integration flows
auth: exercises the real login mechanism
addopts = -ra --strict-markerspytest -m smoke -q # the fast subset
pytest -m "not slow" -q # everything except the slow flows
pytest -m "not auth" -q # runs that reuse a saved session
pytest -n 4 --dist loadfile -q # four workers, one file per worker
pytest --reruns 2 --reruns-delay 1 -m smoke -q| Concern | With -n | Mitigation |
|---|---|---|
| Shared test account | Two workers log in as the same user | One account per worker, or API-created users |
| Shared database rows | Tests overwrite each other | Unique data per test, or a schema per worker |
| Download directory | Files collide between workers | A temp directory per worker |
| Session-scoped fixture | Created once per worker, not once per run | Accept it, or move setup to an external step |
| Reporting | Interleaved output | A JUnit XML report per worker |
pytest -n 4 --junitxml=reports/junit.xml -q
# then fail the build on the XML, not on stdout formattingFAQ
Should page objects contain assertions?
Prefer exposing state and asserting in the test, so the same page object serves tests that expect success and tests that expect failure. A small number of assertions inside a page object are acceptable when they define the page's contract, such as waiting for it to load.
How many workers should I use?
Start with the number of CPU cores on the machine running the browsers, then watch memory. Each Chrome instance is expensive; past a certain point extra workers make the suite slower and flakier rather than faster.
Related
Cookies, storage and session reuse Selenium Grid, Docker and CI
Last refreshed 2026-09-18.