pytest is built from plugins. Even its core features, such as assertion rewriting and the terminal report, are plugins, and pytest-playwright is one too. In this lesson you will install and control third-party plugins, then write your own hooks in conftest.py: a custom –env command-line option, automatic markers based on folder names, a header line with run information, and a screenshot-on-failure hook for Playwright tests that attaches the image to the test report.
📖 What You’ll Learn: what plugins and hooks are, installing and listing plugins, three plugins every Playwright team uses (rerunfailures, xdist, pytest-html), disabling plugins with -p no:, adding command-line options with pytest_addoption, reading them in fixtures, pytest_report_header, pytest_collection_modifyitems, and a pytest_runtest_makereport hook wrapper that saves a screenshot when a test fails.
🔌 Plugins: pytest’s Building Blocks
A plugin is a Python package (or a conftest.py file) that adds behaviour to pytest by implementing hook functions: functions with special names, starting with pytest_, that pytest calls at defined moments. pytest itself is assembled from dozens of internal plugins, and the ecosystem has well over a thousand external ones.
Installed plugins activate automatically. The run header lists them:
🖥️ Output
plugins: html-4.2.0, metadata-3.1.1, playwright-0.10.0, rerunfailures-16.7, xdist-3.8.0
(pytest-metadata is installed automatically as a dependency of pytest-html.)
Three plugins every Playwright Python team uses
| Plugin | Install | What it adds |
|---|---|---|
| pytest-rerunfailures | pip install pytest-rerunfailures | --reruns N re-runs failed tests (Module 6) |
| pytest-xdist | pip install pytest-xdist | -n auto runs tests in parallel processes (Module 12) |
| pytest-html | pip install pytest-html | --html=report.html writes an HTML report (Module 11) |
A quick taste of the first two. This test fails on its first attempt only (plugins_demo/test_flaky.py):
📄 plugins_demo/test_flaky.py
import pathlib
def test_sometimes_fails(tmp_path_factory):
marker = tmp_path_factory.getbasetemp() / "attempted"
if not marker.exists():
marker.touch()
assert False, "first attempt fails"
assert True
⌨️ Terminal
pytest -q --reruns 2 plugins_demo/test_flaky.py
🖥️ Output
R. [100%]
1 passed, 1 rerun in 0.03s
R marks the rerun: the first attempt failed, the second passed. And pytest-xdist spreads tests over worker processes (plugins_demo/test_parallel.py):
📄 plugins_demo/test_parallel.py
import os
import time
import pytest
@pytest.mark.parametrize("n", range(4))
def test_slow_step(n):
time.sleep(1)
assert os.environ.get("PYTEST_XDIST_WORKER", "").startswith("gw")
⌨️ Terminal
pytest -q -n 2 plugins_demo/test_parallel.py
🖥️ Output
bringing up nodes...
bringing up nodes...
.... [100%]
4 passed in 2.98s
Four tests of one second each finished in under 3 seconds with two workers (gw0 and gw1, including the time to start them), instead of 4+ seconds in one process. Each worker sets the PYTEST_XDIST_WORKER environment variable, which the test checks.
Disabling plugins
-p no:NAMEdisables one plugin for a run, for example-p no:playwright(you used it in earlier lessons) or-p no:cacheprovider.- The environment variable
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1disables all automatically loaded plugins; you then enable the ones you need with-p NAME.
🪝 Hooks in conftest.py
You do not need to publish a package to write a plugin: hook functions in conftest.py are a local plugin. All the hooks below live in the project’s root conftest.py.
1. A custom command-line option: pytest_addoption
Real suites run against several environments. Add an --env option and turn it into a fixture:
📄 conftest.py
import pathlib
import pytest
ENVIRONMENTS = {
"qa": "https://qa.example.com",
"staging": "https://staging.example.com",
"prod": "https://sqatools.in",
}
def pytest_addoption(parser):
parser.addoption(
"--env",
action="store",
default="staging",
choices=sorted(ENVIRONMENTS),
help="environment to test against: qa, staging or prod",
)
@pytest.fixture(scope="session")
def env_name(pytestconfig) -> str:
return pytestconfig.getoption("--env")
@pytest.fixture(scope="session")
def app_url(env_name: str) -> str:
return ENVIRONMENTS[env_name]
# 2. Show the environment in the report header
def pytest_report_header(config):
return f"environment: {config.getoption('--env')}"
# 3. Mark every test in tests/smoke automatically
def pytest_collection_modifyitems(config, items):
for item in items:
if "smoke" in item.path.parts:
item.add_marker(pytest.mark.smoke)
# 4. Screenshot on failure, attached to the report
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
report = yield # let pytest build the report first
page = item.funcargs.get("page")
if report.when == "call" and report.failed and page is not None:
folder = pathlib.Path("test-results") / "failures"
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"{item.name}.png".replace("[", "_").replace("]", "")
page.screenshot(path=str(path), full_page=True)
report.sections.append(("screenshot", str(path)))
return report
Let us go through each hook.
**pytest_addoption(parser)** registers new options. parser.addoption accepts the same arguments as Python’s argparse: action, default, choices (only these values are accepted) and help (shown in pytest --help). Option hooks must be in the root conftest.py or a plugin, because pytest reads them before collecting tests.
Reading the option: the env_name fixture uses the built-in pytestconfig fixture and getoption("--env"). app_url builds on it, so tests simply ask for app_url.
📄 pytest.ini
[pytest]
testpaths = tests
markers =
smoke: quick checks of the most important flows
filterwarnings =
ignore:Session-scoped fixtures defined as instance methods:pytest.PytestRemovedIn10Warning
📄 tests/test_env.py
from playwright.sync_api import Page, expect
def test_env_url(env_name: str, app_url: str):
print(f"\n testing {env_name} at {app_url}")
assert app_url.startswith("https://")
def test_env_home_page(page: Page, env_name: str, app_url: str):
if env_name != "prod":
return # the example qa/staging hosts are placeholders
page.goto(app_url + "/automation-practice-page/")
expect(page).to_have_title("Automation Practice Page - SQA Tools")
⌨️ Terminal
pytest --env prod -k env
🖥️ Output
============================= test session starts =============================
platform win32 -- Python 3.15.1, pytest-9.2.0, pluggy-1.7.0
environment: prod
rootdir: C:\pwv\pytest-plugins-hooks
configfile: pytest.ini
testpaths: tests
plugins: html-4.2.0, metadata-3.1.1, playwright-0.10.0, rerunfailures-16.7, xdist-3.8.0
collected 4 items / 2 deselected / 2 selected
tests\test_env.py .. [100%]
======================= 2 passed, 2 deselected in 2.81s =======================
The line environment: prod in the header comes from **pytest_report_header**, which returns text (or a list of lines) to show at the top of a run. It records which environment a CI log belongs to. The header is hidden in quiet mode (-q), so run without -q when you need it.
An invalid value is rejected before any test runs, thanks to choices:
⌨️ Terminal
python -m pytest -q --env qaa
🖥️ Output
ERROR: usage: python -m pytest [options] [file_or_dir] [file_or_dir] [...]
python -m pytest: error: argument --env: invalid choice: 'qaa', maybe you meant 'qa'? (choose from 'prod', 'qa', 'staging')
pytest exits with code 4 (usage error), and Python’s argparse even suggests the closest valid value.
(With --env qa the tests run against the placeholder URL https://qa.example.com, which is just an example host here.)
3. Changing collected tests: pytest_collection_modifyitems
This hook receives the list of collected test items after collection, and may add markers, reorder or deselect them. The version above adds the smoke marker to every test inside a tests/smoke/ folder, so nobody can forget the decorator:
📄 tests/smoke/test_login_smoke.py
from playwright.sync_api import Page, expect
def test_login_button_visible(page: Page):
page.goto("https://sqatools.in/login-page/")
expect(page.get_by_role("button", name="Log in")).to_be_visible()
⌨️ Terminal
pytest -q --collect-only -m smoke
🖥️ Output
tests/smoke/test_login_smoke.py::test_login_button_visible[chromium]
1/4 tests collected (3 deselected) in 0.01s
The test has no @pytest.mark.smoke decorator, yet -m smoke selected it, because the hook marked it by folder. (item.path is the test file’s path as a pathlib.Path, and .parts splits it into folder names.)
4. Screenshot on failure: pytest_runtest_makereport
pytest-playwright already has --screenshot only-on-failure. Writing the hook yourself shows how such features work, and lets you customise them, for example by attaching the path to the report or uploading to a test-management tool.
@pytest.hookimpl(wrapper=True)makes the hook a wrapper: code beforeyieldruns before pytest’s own implementation,yieldreturns the finished report, and the wrapper mustreturnit.- pytest calls this hook three times per test: for
setup,call(the test body) andteardown.report.when == "call" and report.failedselects a failure in the test body. item.funcargsholds the fixture values of the test; if it usedpage, we take a full-page screenshot.report.sections.append(...)adds a section to the failure report, so the path is printed with the failure details.
A deliberately failing test (tests/test_broken.py):
📄 tests/test_broken.py
from playwright.sync_api import Page, expect
def test_broken_heading(page: Page):
page.goto("https://sqatools.in/automation-practice-page/")
expect(page.get_by_role("heading", name="Web Table")).to_have_text("Web Tables", timeout=1000)
⌨️ Terminal
pytest -q -k broken
🖥️ Output
E AssertionError: Locator expected to have text 'Web Tables'
E Actual value: Web Table
...
tests\test_broken.py:6: AssertionError
--------------------------------- screenshot ----------------------------------
test-results\failures\test_broken_heading_chromium.png
=========================== short test summary info ===========================
FAILED tests/test_broken.py::test_broken_heading[chromium] - AssertionError: ...
1 failed, 3 deselected in 3.93s
The “screenshot” section printed under the failure is the one the hook appended to report.sections.
The screenshot file exists next to the report:
🖥️ Output
test_broken_heading_chromium.png
⚠️ Watch out: pytest-playwright clears test-results at the start of every run (Lesson 5). Save custom artifacts in a sub-folder you create during the run, as here, or in a different folder.
📚 Other Useful Hooks
| Hook | Called when | Typical use |
|---|---|---|
pytest_configure(config) | After options are parsed | Register markers in code, set global state |
pytest_sessionstart(session) / pytest_sessionfinish(session, exitstatus) | Start / end of the whole run | Start or stop services, write a summary file |
pytest_runtest_setup(item) | Before each test’s setup | Skip tests based on markers or options |
pytest_terminal_summary(terminalreporter, exitstatus, config) | At the end of the terminal output | Print custom statistics |
❓ Frequently Asked Questions
🤔 Should I write hooks in conftest.py or make a plugin package?
Start in conftest.py. When several projects need the same hooks, move them into a package and install it; the code stays almost the same.
🤔 Why do I see two versions of hook wrappers online?
Older examples use @pytest.hookimpl(hookwrapper=True) with outcome = yield and outcome.get_result(). The newer form shown here, @pytest.hookimpl(wrapper=True) with report = yield and return report, is the recommended style in current pytest and pluggy.
🤔 Can I make my custom option required?
You can set required=True in parser.addoption, but a sensible default is usually friendlier, so a plain pytest still works.
🤔 How do I find all available hooks?
The pytest documentation’s “API Reference” lists every hook with its arguments. Plugins such as pytest-xdist add their own hooks too.
🏁 Summary
- Plugins add features through hook functions; installed plugins load automatically,
-p no:NAMEdisables one. - rerunfailures (
--reruns), xdist (-n) and pytest-html (--html) are standard in Playwright projects. pytest_addoptionpluspytestconfig.getoption()gives you options like--env;pytest_report_headerlabels runs.pytest_collection_modifyitemschanges collected tests; apytest_runtest_makereportwrapper can screenshot failures.
That completes Module 3. Test yourself with the Module 3 mock interview, then move on to Module 4: locators and interactions.
🎬 Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page