Schreiben von Hook-Funktionen

Hook-Funktionsvalidierung und -ausführung

pytest ruft Hook-Funktionen von registrierten Plugins für jede gegebene Hook-Spezifikation auf. Schauen wir uns eine typische Hook-Funktion für den Hook pytest_collection_modifyitems(session, config, items) an, den pytest nach Abschluss der Sammlung aller Testelemente aufruft.

Wenn wir eine pytest_collection_modifyitems Funktion in unserem Plugin implementieren, prüft pytest bei der Registrierung, ob Sie Argumentnamen verwenden, die mit der Spezifikation übereinstimmen, und bricht ab, wenn dies nicht der Fall ist.

Schauen wir uns eine mögliche Implementierung an

def pytest_collection_modifyitems(config, items):
    # called after collection is completed
    # you can modify the ``items`` list
    ...

Hier übergibt pytest config (das pytest-Konfigurationsobjekt) und items (die Liste der gesammelten Testelemente), aber nicht das session Argument, da wir es nicht in der Funktionssignatur aufgeführt haben. Dieses dynamische "Beschneiden" von Argumenten ermöglicht es pytest, "zukunftssicher" zu sein: Wir können neue benannte Hook-Parameter einführen, ohne die Signaturen bestehender Hook-Implementierungen zu brechen. Dies ist einer der Gründe für die allgemeine langlebige Kompatibilität von pytest-Plugins.

Beachten Sie, dass Hook-Funktionen, die nicht pytest_runtest_* sind, keine Ausnahmen auslösen dürfen. Andernfalls wird der pytest-Lauf unterbrochen.

firstresult: Stoppen beim ersten Nicht-None-Ergebnis

Die meisten Aufrufe von pytest-Hooks ergeben eine **Liste von Ergebnissen**, die alle Nicht-None-Ergebnisse der aufgerufenen Hook-Funktionen enthält.

Einige Hook-Spezifikationen verwenden die Option firstresult=True, sodass der Hook-Aufruf nur so lange ausgeführt wird, bis die erste von N registrierten Funktionen ein Nicht-None-Ergebnis zurückgibt, das dann als Ergebnis des gesamten Hook-Aufrufs übernommen wird. Die übrigen Hook-Funktionen werden in diesem Fall nicht aufgerufen.

Hook-Wrapper: Ausführung um andere Hooks herum

pytest-Plugins können Hook-Wrapper implementieren, die die Ausführung anderer Hook-Implementierungen umschließen. Ein Hook-Wrapper ist eine Generatorfunktion, die genau einmal yielded. Wenn pytest Hooks aufruft, werden zuerst Hook-Wrapper ausgeführt und dieselben Argumente übergeben wie an die regulären Hooks.

Am Yield-Punkt des Hook-Wrappers führt pytest die nächsten Hook-Implementierungen aus und gibt deren Ergebnis an den Yield-Punkt zurück oder gibt eine Ausnahme weiter, wenn diese ausgelöst wurde.

Hier ist ein Beispiel für die Definition eines Hook-Wrappers

import pytest


@pytest.hookimpl(wrapper=True)
def pytest_pyfunc_call(pyfuncitem):
    do_something_before_next_hook_executes()

    # If the outcome is an exception, will raise the exception.
    res = yield

    new_res = post_process_result(res)

    # Override the return value to the plugin system.
    return new_res

Der Hook-Wrapper muss ein Ergebnis für den Hook zurückgeben oder eine Ausnahme auslösen.

In vielen Fällen muss der Wrapper nur Tracing oder andere Nebeneffekte um die eigentlichen Hook-Implementierungen herum durchführen, in diesem Fall kann er den Rückgabewert des yield zurückgeben. Der einfachste (wenn auch nutzlose) Hook-Wrapper ist return (yield).

In anderen Fällen möchte der Wrapper das Ergebnis anpassen oder adaptieren, in diesem Fall kann er einen neuen Wert zurückgeben. Wenn das Ergebnis des zugrunde liegenden Hooks ein veränderliches Objekt ist, kann der Wrapper dieses Ergebnis modifizieren, aber es ist wahrscheinlich besser, dies zu vermeiden.

Wenn die Hook-Implementierung mit einer Ausnahme fehlgeschlagen ist, kann der Wrapper diese Ausnahme mit einem try-catch-finally um das yield behandeln, indem er sie weitergibt, unterdrückt oder eine andere Ausnahme auslöst.

Weitere Informationen finden Sie in der pluggy-Dokumentation zu Hook-Wrappern.

Hook-Funktionsreihenfolge / Aufrufbeispiel

Für jede gegebene Hook-Spezifikation kann es mehr als eine Implementierung geben, und wir betrachten die Hook-Ausführung im Allgemeinen als einen 1:N-Funktionsaufruf, wobei N die Anzahl der registrierten Funktionen ist. Es gibt Möglichkeiten, zu beeinflussen, ob eine Hook-Implementierung vor oder nach anderen kommt, d. h. die Position in der N-großen Liste von Funktionen

# Plugin 1
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(items):
    # will execute as early as possible
    ...


# Plugin 2
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(items):
    # will execute as late as possible
    ...


# Plugin 3
@pytest.hookimpl(wrapper=True)
def pytest_collection_modifyitems(items):
    # will execute even before the tryfirst one above!
    try:
        return (yield)
    finally:
        # will execute after all non-wrappers executed
        ...

Hier ist die Ausführungsreihenfolge

  1. Plugin3s pytest_collection_modifyitems wurde bis zum Yield-Punkt aufgerufen, da es sich um einen Hook-Wrapper handelt.

  2. Plugin1s pytest_collection_modifyitems wird aufgerufen, da es mit tryfirst=True markiert ist.

  3. Plugin2s pytest_collection_modifyitems wird aufgerufen, da es mit trylast=True markiert ist (aber auch ohne diese Markierung würde es nach Plugin1 kommen).

  4. Plugin3s pytest_collection_modifyitems führt dann den Code nach dem Yield-Punkt aus. Das Yield empfängt das Ergebnis des Aufrufs der Nicht-Wrapper oder löst eine Ausnahme aus, wenn die Nicht-Wrapper eine Ausnahme ausgelöst haben.

Es ist möglich, tryfirst und trylast auch auf Hook-Wrappern zu verwenden, in diesem Fall beeinflusst dies die Reihenfolge der Hook-Wrapper untereinander.

Deklarieren neuer Hooks

Hinweis

Dies ist ein kurzer Überblick darüber, wie neue Hooks hinzugefügt werden und wie sie im Allgemeinen funktionieren, aber ein vollständigerer Überblick finden Sie in der pluggy-Dokumentation.

Plugins und conftest.py-Dateien können neue Hooks deklarieren, die dann von anderen Plugins implementiert werden können, um das Verhalten zu ändern oder mit dem neuen Plugin zu interagieren

pytest_addhooks(pluginmanager)[Quellcode]

Wird zur Plugin-Registrierungszeit aufgerufen, um das Hinzufügen neuer Hooks durch einen Aufruf von pluginmanager.add_hookspecs(module_or_class, prefix) zu ermöglichen.

Parameter:

pluginmanager (PytestPluginManager) – Der pytest Plugin-Manager.

Hinweis

Dieser Hook ist mit Hook-Wrappern inkompatibel.

Verwendung in conftest-Plugins

Wenn ein Conftest-Plugin diesen Hook implementiert, wird es sofort aufgerufen, wenn das Conftest registriert wird.

Hooks werden normalerweise als do-nothing-Funktionen deklariert, die nur Dokumentation enthalten, die beschreibt, wann der Hook aufgerufen wird und welche Rückgabewerte erwartet werden. Die Namen der Funktionen müssen mit pytest_ beginnen, sonst erkennt pytest sie nicht.

Hier ist ein Beispiel. Nehmen wir an, dieser Code befindet sich im Modul sample_hook.py.

def pytest_my_hook(config):
    """
    Receives the pytest config and does things with it
    """

Um die Hooks bei pytest zu registrieren, müssen sie in ihrem eigenen Modul oder ihrer eigenen Klasse strukturiert werden. Diese Klasse oder dieses Modul kann dann mit der Funktion pytest_addhooks (die selbst ein von pytest bereitgestellter Hook ist) an den pluginmanager übergeben werden.

def pytest_addhooks(pluginmanager):
    """This example assumes the hooks are grouped in the 'sample_hook' module."""
    from my_app.tests import sample_hook

    pluginmanager.add_hookspecs(sample_hook)

Ein reales Beispiel finden Sie unter newhooks.py von xdist.

Hooks können sowohl von Fixtures als auch von anderen Hooks aufgerufen werden. In beiden Fällen werden Hooks über das hook-Objekt aufgerufen, das im config-Objekt verfügbar ist. Die meisten Hooks erhalten direkt ein config-Objekt, während Fixtures das pytestconfig-Fixture verwenden können, das dasselbe Objekt bereitstellt.

@pytest.fixture()
def my_fixture(pytestconfig):
    # call the hook called "pytest_my_hook"
    # 'result' will be a list of return values from all registered functions.
    result = pytestconfig.hook.pytest_my_hook(config=pytestconfig)

Hinweis

Hooks erhalten Parameter nur über Schlüsselwortargumente.

Jetzt ist Ihr Hook einsatzbereit. Um eine Funktion am Hook zu registrieren, müssen andere Plugins oder Benutzer nun einfach die Funktion pytest_my_hook mit der richtigen Signatur in ihrer conftest.py definieren.

Beispiel

def pytest_my_hook(config):
    """
    Print all active hooks to the screen.
    """
    print(config.hook)

Hinweis

Im Gegensatz zu anderen Hooks wird der Hook pytest_generate_tests auch dann erkannt, wenn er innerhalb eines Testmoduls oder einer Testklasse definiert ist. Andere Hooks müssen sich in conftest.py Plugins oder externen Plugins befinden. Siehe So parametrisieren Sie Fixtures und Testfunktionen und die Hooks.

Verwendung von Hooks in pytest_addoption

Gelegentlich ist es notwendig, die Art und Weise, wie Befehlszeilenoptionen von einem Plugin definiert werden, basierend auf Hooks in einem anderen Plugin zu ändern. Zum Beispiel kann ein Plugin eine Befehlszeilenoption bereitstellen, für die ein anderes Plugin den Standardwert definieren muss. Der Plugin-Manager kann verwendet werden, um Hooks zu installieren und zu verwenden, um dies zu erreichen. Das Plugin würde die Hooks definieren und hinzufügen und pytest_addoption wie folgt verwenden

# contents of hooks.py


# Use firstresult=True because we only want one plugin to define this
# default value
@hookspec(firstresult=True)
def pytest_config_file_default_value():
    """Return the default value for the config file command line option."""


# contents of myplugin.py


def pytest_addhooks(pluginmanager):
    """This example assumes the hooks are grouped in the 'hooks' module."""
    from . import hooks

    pluginmanager.add_hookspecs(hooks)


def pytest_addoption(parser, pluginmanager):
    default_value = pluginmanager.hook.pytest_config_file_default_value()
    parser.addoption(
        "--config-file",
        help="Config file to use, defaults to %(default)s",
        default=default_value,
    )

Das conftest.py, das myplugin verwendet, würde einfach den Hook wie folgt definieren

def pytest_config_file_default_value():
    return "config.yaml"

Optionale Verwendung von Hooks von Drittanbieter-Plugins

Die Verwendung neuer Hooks von Plugins, wie oben erläutert, kann aufgrund des Standardmechanismus zur Validierung etwas knifflig sein: Wenn Sie von einem nicht installierten Plugin abhängen, schlägt die Validierung fehl und die Fehlermeldung wird für Ihre Benutzer nicht viel Sinn ergeben.

Ein Ansatz besteht darin, die Hook-Implementierung an ein neues Plugin zu delegieren, anstatt die Hook-Funktionen direkt in Ihrem Plugin-Modul zu deklarieren, zum Beispiel

# contents of myplugin.py


class DeferPlugin:
    """Simple plugin to defer pytest-xdist hook functions."""

    def pytest_testnodedown(self, node, error):
        """standard xdist hook function."""


def pytest_configure(config):
    if config.pluginmanager.hasplugin("xdist"):
        config.pluginmanager.register(DeferPlugin())

Dies hat den zusätzlichen Vorteil, dass Sie Hooks bedingt installieren können, je nachdem, welche Plugins installiert sind.

Speichern von Daten auf Items über Hook-Funktionen hinweg

Plugins müssen oft Daten in einer Hook-Implementierung auf Items speichern und in einer anderen darauf zugreifen. Eine gängige Lösung ist, einfach ein privates Attribut direkt auf dem Item zuzuweisen, aber Typ-Checker wie mypy sind dagegen, und es kann auch zu Konflikten mit anderen Plugins kommen. Daher bietet pytest einen besseren Weg, dies zu tun: item.stash.

Um den "Stash" in Ihren Plugins zu verwenden, erstellen Sie zuerst "Stash-Schlüssel" irgendwo auf der obersten Ebene Ihres Plugins

been_there_key = pytest.StashKey[bool]()
done_that_key = pytest.StashKey[str]()

Verwenden Sie dann die Schlüssel, um Ihre Daten an einem bestimmten Punkt zu speichern

def pytest_runtest_setup(item: pytest.Item) -> None:
    item.stash[been_there_key] = True
    item.stash[done_that_key] = "no"

und sie an einem anderen Punkt abzurufen

def pytest_runtest_teardown(item: pytest.Item) -> None:
    if not item.stash[been_there_key]:
        print("Oh?")
    item.stash[done_that_key] = "yes!"

Stashes sind auf allen Knotentypen (wie Class, Session) und auch auf Config verfügbar, falls erforderlich.