Ollivander doesn't just sell wands — he crafts them. Each wand has a unique core, length, and flexibility, tailored to its owner. Similarly, writing custom pytest plugins means crafting hooks — extension points where you inject your own behavior into pytest's execution pipeline. You become the wandmaker of your testing framework.
Hooks are well-defined functions that pytest calls at specific moments during execution. By implementing these functions, you alter pytest's behavior. Key hooks include:
| Hook | When It Fires | Analogy |
|---|---|---|
pytest_addoption | When CLI options are registered | Choosing the wand wood |
pytest_configure | After CLI parsing, before collection | Selecting the core |
pytest_collection_modifyitems | After tests are collected | Sorting the wand blanks |
pytest_report_header | When header info is printed | Engraving the maker's mark |
pytest_runtest_makereport | After each test phase (setup/call/teardown) | Inspecting the finished wand |
Before packaging a full plugin, use conftest.py as your local workshop. Any hook implemented there applies to all tests in that directory and below:
# conftest.py — your local wandmaker's workshop
# pytest auto-discovers this file. No registration needed!
def pytest_report_header(config):
"""Add a custom header line — the maker's mark."""
return "âš¡ Powered by Ollivander's Custom Test Framework âš¡"
Run pytest and you'll see your mark in the output header. Simple, powerful, local.
Sometimes you want to shuffle test order to catch hidden dependencies (like choosing a random wand to see which one picks you):
# conftest.py
import random
def pytest_collection_modifyitems(items):
"""Shuffle test order — no test should depend on another's position."""
random.shuffle(items)
# 'items' is modified IN PLACE — no return needed
This is exactly what pytest-randomly does (with more sophistication). But here you've crafted it yourself — a bespoke wand for your specific needs.
Add a --hogwarts-house option that filters tests by house:
# conftest.py
import pytest
def pytest_addoption(parser):
"""Register a custom CLI option — choosing the wand's wood."""
parser.addoption(
"--hogwarts-house",
action="store",
default=None,
help="Only run tests marked with this Hogwarts house"
)
def pytest_configure(config):
"""Register custom markers."""
config.addinivalue_line("markers", "gryffindor: Tests for the brave")
config.addinivalue_line("markers", "slytherin: Tests for the cunning")
config.addinivalue_line("markers", "ravenclaw: Tests for the wise")
config.addinivalue_line("markers", "hufflepuff: Tests for the loyal")
def pytest_collection_modifyitems(config, items):
"""Filter tests by house if --hogwarts-house is specified."""
house = config.getoption("--hogwarts-house")
if house is None:
return # No filtering
selected = []
deselected = []
for item in items:
if house in [mark.name for mark in item.iter_markers()]:
selected.append(item)
else:
deselected.append(item)
items[:] = selected
config.hook.pytest_deselected(items=deselected)
Usage:
# test_spells.py
import pytest
@pytest.mark.gryffindor
def test_expelliarmus():
assert cast("Expelliarmus") == "disarmed"
@pytest.mark.slytherin
def test_avada_kedavra():
assert cast("Avada Kedavra") == "forbidden"
# Run only Gryffindor tests:
# pytest --hogwarts-house=gryffindor
Track which tests failed and report a summary at the end:
# conftest.py
import pytest
# Store results as we go
_results = {"passed": [], "failed": [], "error": []}
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""Intercept test results after the 'call' phase."""
outcome = yield
report = outcome.get_result()
if report.when == "call": # Only care about the actual test, not setup/teardown
if report.passed:
_results["passed"].append(item.name)
elif report.failed:
_results["failed"].append(item.name)
if report.when == "setup" and report.failed:
_results["error"].append(item.name)
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""Print a custom summary — the wandmaker's quality report."""
terminalreporter.write_sep("=", "âš¡ Ollivander's Quality Report âš¡")
terminalreporter.write_line(f" Wands that work (passed): {len(_results['passed'])}")
terminalreporter.write_line(f" Wands that backfired (failed): {len(_results['failed'])}")
if _results["failed"]:
for name in _results["failed"]:
terminalreporter.write_line(f" 💥 {name}")
terminalreporter.write_line(f" Wands that exploded in setup (error): {len(_results['error'])}")
Once your conftest hooks are mature, package them as a proper installable plugin using entry_points:
# Directory structure:
# pytest-ollivander/
# ├── setup.py (or pyproject.toml)
# └── pytest_ollivander.py (the plugin module)
# pytest_ollivander.py
"""Ollivander's custom pytest plugin."""
def pytest_report_header(config):
return "âš¡ Ollivander's Testing Framework v1.0 âš¡"
def pytest_collection_modifyitems(items):
import random
random.shuffle(items)
# setup.py
from setuptools import setup
setup(
name="pytest-ollivander",
version="1.0.0",
py_modules=["pytest_ollivander"],
entry_points={
# THIS is the magic line — pytest discovers plugins via this entry point
"pytest11": [
"ollivander = pytest_ollivander",
],
},
install_requires=["pytest>=7.0"],
)
# Install your plugin:
pip install -e ./pytest-ollivander
# Now it's auto-discovered by pytest everywhere!
# Verify:
pytest --co -q # You'll see "ollivander" in the plugins list
The key is the "pytest11" entry point group — this is how pytest discovers third-party plugins. Name it, package it, publish it to PyPI, and other wizards can install your wands.
"The wand chooses the wizard, Mr. Potter." — Ollivander. But the wandmaker chooses the hooks. Now go craft something magical.