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.
  • yield guarantees quit() runs even when the test raises, which is what stops leaked browser processes.
  • Put shared fixtures in conftest.py so 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 than click_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-markers
pytest -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
ConcernWith -nMitigation
Shared test accountTwo workers log in as the same userOne account per worker, or API-created users
Shared database rowsTests overwrite each otherUnique data per test, or a schema per worker
Download directoryFiles collide between workersA temp directory per worker
Session-scoped fixtureCreated once per worker, not once per runAccept it, or move setup to an external step
ReportingInterleaved outputA JUnit XML report per worker
pytest -n 4 --junitxml=reports/junit.xml -q
# then fail the build on the XML, not on stdout formatting

FAQ

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.

Cookies, storage and session reuse Selenium Grid, Docker and CI

Last refreshed 2026-09-18.