Running Selenium Suites with pytest
Fixtures that pick a browser from the command line, parametrised cross-browser runs, parallel execution with xdist, and JUnit XML for CI.
The previous lessons wrote scripts. A suite needs fixtures, a way to choose a browser, and a
run that finishes before anyone loses interest. pytest supplies all three.
conftest.py
# conftest.py
import pytest
from selenium import webdriver
def pytest_addoption(parser):
parser.addoption("--browser", default="chrome",
choices=["chrome", "firefox", "edge"],
help="browser to run against")
parser.addoption("--headed", action="store_true", help="show the browser window")
parser.addoption("--base-url", default="https://demo.playwright.dev/todomvc")
def _make_driver(name, headed):
if name == "chrome":
options = webdriver.ChromeOptions()
if not headed:
options.add_argument("--headless=new")
options.add_argument("--window-size=1440,900")
options.add_argument("--lang=en-GB")
return webdriver.Chrome(options=options)
if name == "firefox":
options = webdriver.FirefoxOptions()
if not headed:
options.add_argument("-headless")
return webdriver.Firefox(options=options)
options = webdriver.EdgeOptions()
if not headed:
options.add_argument("--headless=new")
return webdriver.Edge(options=options)
@pytest.fixture
def base_url(request):
return request.config.getoption("--base-url")
@pytest.fixture
def driver(request):
drv = _make_driver(request.config.getoption("--browser"),
request.config.getoption("--headed"))
drv.set_page_load_timeout(30)
drv.implicitly_wait(0) # explicit waits only — see lesson 3
yield drv
drv.quit()
@pytest.fixture
def todos(driver, base_url):
from pages.todo_page import TodoPage
return TodoPage(driver, base_url).open()
# test_todos.py
def test_adds_a_todo(todos):
todos.add("buy milk")
assert todos.titles == ["buy milk"]
def test_completes_a_todo(todos):
todos.add("buy milk").complete("buy milk")
assert todos.counter_text == "0 items left"
pytest -v
========================= test session starts =========================
collected 2 items
test_todos.py::test_adds_a_todo PASSED [ 50%]
test_todos.py::test_completes_a_todo PASSED [100%]
========================== 2 passed in 5.02s ==========================
pytest --browser firefox --headed -v
test_todos.py::test_adds_a_todo PASSED [ 50%]
test_todos.py::test_completes_a_todo PASSED [100%]
========================== 2 passed in 8.44s ==========================
Note implicitly_wait(0). Setting it explicitly documents the decision from lesson 3 and
protects against a default changing underneath you.
The todos fixture is the one that makes tests short: it depends on driver, constructs the
page object and opens it, so a test starts at the point it cares about.
Cross-browser in one run
@pytest.fixture(params=["chrome", "firefox"], ids=["chrome", "firefox"])
def driver(request):
drv = _make_driver(request.param, request.config.getoption("--headed"))
drv.implicitly_wait(0)
yield drv
drv.quit()
pytest -v
test_todos.py::test_adds_a_todo[chrome] PASSED [ 25%]
test_todos.py::test_adds_a_todo[firefox] PASSED [ 50%]
test_todos.py::test_completes_a_todo[chrome] PASSED [ 75%]
test_todos.py::test_completes_a_todo[firefox] FAILED [100%]
=============================== FAILURES ==============================
______________ test_completes_a_todo[firefox] _________________________
E selenium.common.exceptions.TimeoutException: Message: text never became
'0 items left' in ('class name', 'todo-count')
===================== 3 passed, 1 failed in 18.02s ====================
Every test runs once per browser, and the failure names which one. A browser-specific bug becomes a single red test rather than an intermittent mystery.
Use the parametrised fixture in CI and the --browser option locally, so a developer
debugging one browser is not paying for three.
Parametrising data
import pytest
@pytest.mark.parametrize("title,expected", [
("buy milk", "1 item left"),
("", None),
(" spaces ", "1 item left"),
("a" * 200, "1 item left"),
("<script>alert(1)</script>", "1 item left"),
])
def test_add_handles_various_input(todos, title, expected):
todos.add(title)
if expected is None:
assert todos.titles == []
else:
assert todos.counter_text == expected
test_todos.py::test_add_handles_various_input[buy milk-1 item left] PASSED [ 20%]
test_todos.py::test_add_handles_various_input[-None] PASSED [ 40%]
test_todos.py::test_add_handles_various_input[ spaces -1 item left] PASSED [ 60%]
test_todos.py::test_add_handles_various_input[aaaaaaaaaa...-1 item left] PASSED [ 80%]
test_todos.py::test_add_handles_various_input[<script>alert(1)</script>-1 item left] PASSED [100%]
========================== 5 passed in 11.20s =========================
Five cases, one test body, each reported separately. The empty-string case is the one worth having — TodoMVC ignores a blank submit, and asserting that pins the behaviour.
Markers
# pytest.ini
[pytest]
markers =
smoke: fast critical-path checks
slow: takes more than 30 seconds
quarantine: known flaky, not blocking
addopts = --strict-markers -ra
@pytest.mark.smoke
def test_page_loads(todos):
assert todos.counter_text is not None
@pytest.mark.slow
def test_bulk_add(todos):
todos.add(*[f"task {i}" for i in range(200)])
assert len(todos.titles) == 200
pytest -m smoke -v
pytest -m "not slow and not quarantine"
test_todos.py::test_page_loads PASSED [100%]
========================== 1 passed in 3.02s ==========================
========================= 12 passed in 42.11s =========================
--strict-markers turns a typo’d marker into an error instead of a silently-skipped filter.
Parallel execution
pip install pytest-xdist
pytest -n auto -v
created: 8/8 workers
8 workers [24 items]
........................ [100%]
========================= 24 passed in 31.44s =========================
# same suite, sequential
========================= 24 passed in 186.02s ========================
186 seconds to 31. Each worker is a separate process with its own driver, so browser startup overlaps with test execution rather than serialising.
Parallelism requires independence, and it finds violations immediately:
========================= 22 passed, 2 failed in 38.11s ===============
E AssertionError: assert ['buy milk', 'task 3'] == ['buy milk']
Two tests sharing an account, or a fixed record id, now collide. That is worth knowing — the fix is to give each worker its own data:
@pytest.fixture
def unique_prefix(worker_id):
return f"{worker_id}-{uuid.uuid4().hex[:6]}"
def test_adds_a_todo(todos, unique_prefix):
title = f"{unique_prefix} buy milk"
todos.add(title)
assert title in todos.titles
8 workers [24 items]
........................ [100%]
========================= 24 passed in 30.88s =========================
worker_id is supplied by xdist (gw0, gw1, … or master when run sequentially), so the
same test works both ways.
Distribution modes matter when tests are uneven:
pytest -n 4 --dist loadscope # group by class or module
pytest -n 4 --dist loadfile # group by file — keeps file-level fixtures shared
pytest -n 4 --dist load # default: whichever worker is free
loadfile is the pragmatic choice when a module has an expensive module-scoped fixture.
Sharing the browser, carefully
@pytest.fixture(scope="session")
def session_driver(request):
drv = _make_driver(request.config.getoption("--browser"), False)
yield drv
drv.quit()
@pytest.fixture
def driver(session_driver):
yield session_driver
session_driver.delete_all_cookies()
session_driver.execute_script(
"try { window.localStorage.clear(); window.sessionStorage.clear(); } catch (e) {}")
# function-scoped driver
========================= 24 passed in 96.02s =========================
# session-scoped driver + cleanup
========================= 24 passed in 41.18s =========================
Less than half the time, because eight seconds of browser startup happens once rather than 24 times. The trade is real: any state the cleanup misses — a service worker, an IndexedDB database, an auth token in a cookie on another domain — leaks between tests. Reach for this when startup dominates and you have proved the cleanup is complete.
Reports for CI
pip install pytest-html
pytest --junitxml=results.xml --html=report.html --self-contained-html -n auto
8 workers [24 items]
........................ [100%]
- generated xml file: /home/you/suite/results.xml -
- Generated html report: file:///home/you/suite/report.html -
========================= 24 passed in 31.44s =========================
JUnit XML is what CI systems parse natively for per-test history and inline failure annotations. Attach the artefacts from lesson 7 so a red build links to its screenshot:
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
driver = item.funcargs.get("driver")
if driver:
path = f"artifacts/{item.name}.png"
driver.save_screenshot(path)
extra = getattr(report, "extras", [])
extra.append(pytest_html.extras.image(path))
report.extras = extra
test_todos.py::test_completes_a_todo FAILED
- screenshot attached: artifacts/test_completes_a_todo.png -
A workflow
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome, firefox]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- run: pip install -r requirements.txt
- run: pytest -n auto --browser ${{ matrix.browser }} --junitxml=results-${{ matrix.browser }}.xml
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: results-${{ matrix.browser }}
path: |
results-*.xml
artifacts/
Run pytest -n auto --browser chrome --junitxml=results-chrome.xml
4 workers [24 items]
........................ [100%]
========================= 24 passed in 44.02s =========================
if: ${{ !cancelled() }} on the upload matters — the default success() skips exactly the
runs whose artefacts you need. Ubuntu runners ship Chrome and Firefox, and Selenium Manager
resolves the drivers, so there is no browser installation step.
Practice
1. Add a --browser option and run against two browsers.
pytest --browser chrome → 2 passed in 5.02s
pytest --browser firefox → 2 passed in 8.44s
One suite, two browsers, no code change. Firefox being slower is normal — its driver startup costs more than Chrome’s.
2. Parametrise the driver fixture and find a browser-specific failure.
test_completes_a_todo[chrome] PASSED
test_completes_a_todo[firefox] FAILED
The failure names the browser in the test id, so it is one red test rather than an intermittent one. That is the whole argument for parametrising rather than looping.
3. Run with -n auto and compare wall time.
sequential: 186.02s
8 workers: 31.44s
6× on eight cores. If tests start failing under -n, they were never independent — the
parallel run found real coupling.
4. Switch to a session-scoped driver without clearing storage.
E AssertionError: assert ['buy milk', 'walk the dog'] == ['walk the dog']
State leaked from the previous test. Session scope is a real speed-up and it requires the cleanup fixture to be genuinely complete.
Next: Selenium Grid — running the same suite across machines and browser versions.