Becoming a Wandmaker: Writing Custom Plugins


Pytest Hooks — Crafting Your Own Magic

🪄 Becoming a Wandmaker

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.

1. What Are Hooks? — The Wand Cores

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:

HookWhen It FiresAnalogy
pytest_addoptionWhen CLI options are registeredChoosing the wand wood
pytest_configureAfter CLI parsing, before collectionSelecting the core
pytest_collection_modifyitemsAfter tests are collectedSorting the wand blanks
pytest_report_headerWhen header info is printedEngraving the maker's mark
pytest_runtest_makereportAfter each test phase (setup/call/teardown)Inspecting the finished wand

2. conftest.py as a Local Plugin — The Workshop

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.

3. Example: Randomizing Test Order — Shuffling the Wand Blanks

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.

4. Example: Adding Custom CLI Options — Choosing the Wood

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

5. Example: Custom Reporting — Inspecting the Finished Wand

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'])}")

6. Packaging as an Installable Plugin — Opening Your Own Shop

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.

Summary — The Wandmaker's Creed

"The wand chooses the wizard, Mr. Potter." — Ollivander. But the wandmaker chooses the hooks. Now go craft something magical.