Skip to main content
Selenium intermediate Lesson 8 of 10

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.

Frequently Asked Questions

Should the WebDriver fixture be function-scoped or session-scoped?
Function-scoped by default — each test gets a clean browser profile, so no test can pollute another. Session scope saves the two-second startup per test but reintroduces shared state; if you use it, clear cookies and storage between tests.
How do I run the same Selenium test against several browsers?
Parametrise the driver fixture over browser names, so every test using it runs once per browser and appears separately in the report. Combine with a command-line option so a local run can target one browser and CI can run them all.
How do I run Selenium tests in parallel?
`pytest-xdist` with `-n auto` distributes tests across worker processes, each with its own driver. It only works if the tests are independent — shared state or shared test data will surface immediately, which is a feature rather than a problem.
What report format should CI consume?
JUnit XML with `--junitxml`, which every CI system understands natively for per-test history and failure annotation. Add an HTML report for humans, and attach screenshots from the failure hook so a red build links to its evidence.