Exit course
Chapter 5 of 10 Intermediate 6 min read 2 quick checks Updated

pytest fixtures for Selenium: conftest.py and scope

On this page
  1. How a fixture works
  2. conftest.py
  3. Fixture scope
  4. Parametrising test data
  5. Markers and pytest.ini

Every test file so far has carried its own copy of the same five-line driver fixture. This chapter moves it to one place, explains what yield and scope do, adds command-line switches for the browser and headless mode, and uses parametrisation to run one test with several inputs.

How a fixture works#

A fixture is a function decorated with @pytest.fixture. When a test lists the fixture's name as an argument, pytest calls the fixture first and passes in what it returns or yields:

@pytest.fixture
def driver():
    driver = webdriver.Chrome()   # setup
    yield driver                  # the test runs here
    driver.quit()                 # teardown

Everything before yield is setup, everything after it is teardown. pytest runs the teardown whether the test passed or failed, which is what the try/finally in chapter 1 did by hand. If the setup itself raises (Chrome fails to start, say), the test is reported as an error rather than a failure, and the teardown is skipped because there is nothing to clean up.

conftest.py#

pytest automatically loads a file named conftest.py and makes its fixtures available to every test in that directory and below, without any import. Put it in the project root, next to pytest.ini:

import pytest
from selenium import webdriver


def pytest_addoption(parser):
    parser.addoption("--browser", action="append", choices=["chrome", "firefox"],
                     help="browser to run on; repeat for several (default: chrome)")
    parser.addoption("--headless", action="store_true", help="run browsers headless")


def pytest_generate_tests(metafunc):
    if "browser" in metafunc.fixturenames:
        metafunc.parametrize("browser", metafunc.config.getoption("browser") or ["chrome"])


@pytest.fixture
def driver(request, browser):
    headless = request.config.getoption("headless")
    if browser == "firefox":
        options = webdriver.FirefoxOptions()
        if headless:
            options.add_argument("-headless")
        driver = webdriver.Firefox(options=options)
    else:
        options = webdriver.ChromeOptions()
        if headless:
            options.add_argument("--headless=new")
            options.add_argument("--window-size=1920,1080")
        driver = webdriver.Chrome(options=options)

    yield driver
    driver.quit()

Delete the local driver fixtures from your test files: a fixture defined in a test module overrides the one in conftest.py, so a forgotten copy silently bypasses everything below. pytest --fixtures-per-test shows which fixture definition each test actually gets.

Three pieces work together here:

  • pytest_addoption is a hook that registers command-line options. action="append" lets you pass --browser more than once.
  • pytest_generate_tests is a hook pytest calls for every test function. If the test needs browser (directly, or through the driver fixture that asks for it), the hook parametrises it with the browsers from the command line.
  • request is a built-in fixture that gives access to the test being run: request.config for options, request.node for the test item itself (chapter 5 uses that).

Now one set of tests runs on whatever you choose:

pytest -v --headless --browser chrome --browser firefox
tests/test_form.py::test_submit_shows_confirmation[chrome] PASSED        [ 12%]
tests/test_form.py::test_submit_shows_confirmation[firefox] PASSED       [ 25%]
tests/test_form.py::test_dropdown_value[chrome-One-1] PASSED             [ 37%]
...
============================== 8 passed in 12.56s ==============================

The browser name in square brackets is part of each test's ID, so a failure tells you exactly which browser it happened on.

Fixture scope#

@pytest.fixture(scope=...) decides how often a fixture is created:

Scope Created once per Browser-test use
function (default) test one fresh browser per test: the safe default
class test class rarely worth it
module test file a read-only smoke file where speed matters more than isolation
session whole run (per worker with pytest-xdist) expensive setup that is not a browser: test users, API tokens, a seeded database

A wider scope is tempting because starting a browser takes a second or two. Here is what it costs. With a module-scoped browser, these three tests share one Chrome:

@pytest.fixture(scope="module")
def shared_driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    driver = webdriver.Chrome(options=options)
    yield driver
    driver.quit()


def test_types_into_the_field(shared_driver):
    shared_driver.get(URL)
    shared_driver.find_element(By.ID, "my-text-id").send_keys("left over")


def test_expects_an_empty_field(shared_driver):
    # Same browser as the previous test. Passes only because get() reloads the page;
    # cookies and localStorage from the previous test would still be there.
    shared_driver.get(URL)
    assert shared_driver.find_element(By.ID, "my-text-id").get_attribute("value") == ""


def test_relies_on_previous_state(shared_driver):
    # No get(): this test silently depends on the one before it.
    assert shared_driver.title == "Web form"

Run the file and all three pass. Run only the last one, with pytest tests/test_scope_demo.py::test_relies_on_previous_state, and it fails with assert '' == 'Web form': on its own, the browser never opened the page. Tests that pass together but fail alone (or in a different order, or on a different parallel worker) are the hardest kind of flaky test to diagnose. Keep the browser function-scoped and you never have that problem.

A suite passes when run in full but one test fails when run alone with pytest path::test_name. The driver fixture has scope="session". What is the most likely cause?

With one shared browser, a test can quietly rely on what earlier tests did. Run alone, that state is missing. conftest.py is always loaded, and session fixtures do tear down, just once at the end.

Parametrising test data#

@pytest.mark.parametrize runs one test function once per set of values:

@pytest.mark.parametrize("option, value", [
    ("One", "1"),
    ("Two", "2"),
    ("Three", "3"),
])
def test_dropdown_value(driver, option, value):
    driver.get(URL)
    dropdown = Select(driver.find_element(By.NAME, "my-select"))
    dropdown.select_by_visible_text(option)
    assert dropdown.first_selected_option.get_attribute("value") == value

The parameter sets combine with the browser parameter, which is why the run above has [chrome-One-1] and [firefox-Three-3]: three values times two browsers is six tests. Each is reported, and can fail, independently, which beats a loop inside one test that stops at the first bad value. Add ids=[...] to the decorator if the generated IDs get hard to read.

Markers and pytest.ini#

Create pytest.ini in the project root:

[pytest]
testpaths = tests
addopts = -ra
markers =
    smoke: fast checks that run on every commit
  • testpaths tells pytest where to look, so a stray script elsewhere is never collected.
  • addopts = -ra prints a short summary of every non-passing test at the end of each run.
  • markers registers custom marks. Decorate a test with @pytest.mark.smoke, then run just that group with pytest -m smoke or everything else with pytest -m "not smoke". Registering marks means a typo such as @pytest.mark.smok produces a warning instead of a silently empty selection.

You changed the driver fixture in conftest.py to run headless, but one test file still opens a visible browser. What should you check first?

A fixture defined in a test module takes precedence over one with the same name in conftest.py. The file is clearly being collected, since its tests run, and conftest.py must never be imported: pytest loads it automatically.

The fixture now owns the browser's whole lifetime, which makes it the natural place for the next feature: when a test fails, take a screenshot before the browser closes.