pytest fixtures for Selenium: conftest.py and scope
On this page
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_addoptionis a hook that registers command-line options.action="append"lets you pass--browsermore than once. -
pytest_generate_testsis a hook pytest calls for every test function. If the test needsbrowser(directly, or through thedriverfixture that asks for it), the hook parametrises it with the browsers from the command line. -
requestis a built-in fixture that gives access to the test being run:request.configfor options,request.nodefor 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
-
testpathstells pytest where to look, so a stray script elsewhere is never collected. -
addopts = -raprints a short summary of every non-passing test at the end of each run. -
markersregisters custom marks. Decorate a test with@pytest.mark.smoke, then run just that group withpytest -m smokeor everything else withpytest -m "not smoke". Registering marks means a typo such as@pytest.mark.smokproduces 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.