Built-in Fixtures

Die Wiederverwendung gemeinsamer Fixtures ist eine so gute Idee, dass pytest einige häufig verwendete Fixtures integriert hat. Die eingebauten Fixtures, helfen euch, einige sehr nützliche Dinge in euren Tests einfach und konsistent zu tun. Unter anderem enthält pytest eingebaute Fixtures, die mit temporären Verzeichnissen und Dateien umgehen, auf Kommandozeilenoptionen zugreifen, zwischen Testsitzungen kommunizieren, Ausgabeströme validieren, Umgebungsvariablen verändern und Warnungen abfragen können.

tmp_path und tmp_path_factory

Die Fixtures tmp_path und tmp_path_factory werden verwendet, um temporäre Verzeichnisse zu erstellen. Die Fixture tmp_path für den function-Scope gibt eine pathlib.Path-Instanz zurück, die auf ein temporäres Verzeichnis verweist, das während des Tests und etwas länger bestehen bleibt. Die tmp_path_factory für eine session-Scope-Fixture gibt ein TempPathFactory-Objekt zurück. Dieses Objekt hat eine mktemp()-Funktion, die Path-Objekte zurückgibt. Mit mktemp() könnt ihr mehrere temporäre Verzeichnisse erstellen.

In Test-Fixtures haben wir die Standardbibliothek tempfile.TemporaryDirectory für unser db-Fixture verwendet:

from pathlib import Path
from tempfile import TemporaryDirectory


@pytest.fixture(scope="session")
def db():
    """ItemsDB object connected to a temporary database"""
    with TemporaryDirectory() as db_dir:
        db_path = Path(db_dir)
        db_ = items.ItemsDB(db_path)
        yield db_
        db_.close()

Lasst uns stattdessen eines der neuen Built-ins verwenden. Da unser db-Fixture im session-Scope liegt, können wir tmp_path nicht verwenden, da session-Scope-Fixtures keine function-Scope-Fixtures verwenden können. Wir können jedoch tmp_path_factory verwenden:

@pytest.fixture(scope="session")
def db(tmp_path_factory):
    """ItemsDB object connected to a temporary database"""
    db_path = tmp_path_factory.mktemp("items_db")
    db_ = items.ItemsDB(db_path)
    yield db_
    db_.close()

Bemerkung

Wir können dadurch auch zwei Import-Anweisungen entfernen, da wir weder pathlib noch tempfile importieren müssen.

Tipp

Verwendet nicht tmpdir oder tmpdir_factory, da diese py.path.local-Objekte bereitstellen, ein Legacy-Typ.

Das Basisverzeichnis für alle temporären pytest-Verzeichnisse ist system- und anwendungsabhängig. Es enthält einen pytest-NUM-Teil, wobei NUM bei jeder Sitzung erhöht wird. Das Basisverzeichnis wird unmittelbar nach einer Sitzung unverändert belassen, damit ihr es im Falle von Testfehlern untersuchen könnt. pytest räumt sie schließlich auf. Nur die letzten paar temporären Basisverzeichnisse werden auf dem System belassen.

Ihr könnt auch euer eigenes Basisverzeichnis angeben mit pytest --basetemp=MYDIR.

capsys

Manchmal soll der Anwendungscode etwas auf stdout, stderr usw. ausgeben. Das Items-Beispielprojekt hat deswegen auch eine Kommandozeilenschnittstelle, die wir nun testen wollen.

Der Befehl items version soll die Version ausgeben:

$ items version
0.1.0

Die Version ist auch via Python verfügbar:

>>> import items
>>> items.__version__
'0.1.0'

Eine Möglichkeit, dies zu testen, ist

  1. den Befehl mit subprocess.run() auszuführen

  2. die Ausgabe zu erfassen

  3. sie mit der Version aus der API zu vergleichen

import subprocess

import items


def test_version():
    process = subprocess.run(["items", "version"], capture_output=True, text=True)
    output = process.stdout.rstrip()
    assert output == items.__version__

Die Funktion rstrip() wird verwendet, um den Zeilenumbruch zu entfernen.

Das capsys-Fixture ermöglicht die Erfassung von Schreibvorgängen auf stdout und stderr. Wir können die Methode, die dies im CLI implementiert, direkt aufrufen und capsys zum Lesen der Ausgabe verwenden:

import items


def test_version(capsys):
    items.cli.version()
    output = capsys.readouterr().out.rstrip()
    assert output == items.__version__

Die Methode capsys.readouterr() gibt ein namedtuple zurück, das out und err enthält. Wir lesen nur den out-Teil und entfernen dann den Zeilenumbruch mit rstrip().

Eine weitere Funktion von capsys ist die Möglichkeit, die normale Ausgabeerfassung von pytest vorübergehend zu deaktivieren. pytest erfasst normalerweise die Ausgaben eurer Tests und des Anwendungscodes. Dies schließt print-Anweisungen ein.

import items


def test_stdout():
    version = items.__version__
    print("\nitems " + version)

Wenn wir den Test jedoch ausführen, sehen wir keine Ausgabe:

$ pytest tests/test_output.py
============================= test session starts ==============================
…
collected 1 item

tests/test_output.py .                                                   [100%]

============================== 1 passed in 0.00s ===============================

pytest fängt die gesamte Ausgabe auf. Dies hilft zwar, die Kommandozeilensitzung sauber zu halten, es kann jedoch vorkommen, dass wir die gesamte Ausgabe sehen wollen, auch bei bestandenen Tests. Hierfür können die Option -s oder --capture=no verwenden:

 $ pytest -s tests/test_output.py
 ============================= test session starts ==============================
 …
 collected 1 item

 tests/test_output.py
 items 0.1.0
 .

 ============================== 1 passed in 0.00s ===============================

Eine andere Möglichkeit, die Ausgabe immer einzuschließen, ist capsys.disabled():

import items


def test_stdout(capsys):
    with capsys.disabled():
        version = items.__version__
        print("\nitems " + version)

Nun wird sie Ausgabe im with-Block immer angezeigt, auch ohne die -s-Option:

$ pytest tests/test_output.py
============================= test session starts ==============================
…
collected 1 item

tests/test_output.py
items 0.1.0
.                                                   [100%]

============================== 1 passed in 0.00s ===============================

Siehe auch

capfd

Wie capsys, erfasst aber die Dateideskriptoren 1 und 2, die normalerweise dasselbe wie stdout und stderr

capsysbinary

Während capsys Text erfasst, erfasst capsysbinary Bytes

capfdbinary

erfasst Bytes in den Dateideskriptoren 1 und 2

caplog

erfasst Ausgaben, die mit dem Logging-Paket geschrieben wurden

monkeypatch

Mit capsys kann ich zwar gut die stdout und stderr-Ausgabe steuern, aber es ist immer noch nicht die Art, wie ich die CLI testen möchte. Die Items-Anwendung verwendet eine Bibliothek namens Typer, die eine Runner-Funktion enthält um unserem Code so zu testen, wie wir es von einem Befehlszeilentest erwarten würden, der im Prozess bleibt und uns mit Output-Hooks versorgt, z.B.:

from typer.testing import CliRunner

import items


def test_version():
    runner = CliRunner()
    result = runner.invoke(items.app, ["version"])
    output = result.output.rstrip()
    assert output == items.__version__

Wir werden diese Methode der Ausgabentests als Ausgangspunkt für die restlichen Tests der Items-CLI verwenden. Ich habe mit den CLI-Tests begonnen, indem ich die Items-Version getestet habe. Um den Rest der CLI zu testen, müssen wir die Datenbank in ein temporäres Verzeichnis umleiten, so wie wir es beim Testen der API unter Verwendung von Fixtures für Setup und Teardown getan haben. Hierfür verwenden wir nun monkeypatch:

Ein Monkey Patch ist eine dynamische Änderung einer Klasse oder eines Moduls während der Laufzeit. Während des Testens ist monkey patching eine bequeme Möglichkeit, einen Teil der Laufzeitumgebung des Anwendungscodes zu übernehmen und entweder Eingabe- oder Ausgabeabhängigkeiten durch Objekte oder Funktionen zu ersetzen, die für das Testen besser geeignet sind. Mit dem eingebauten Fixture monkeypatch könnt ihr dies im Kontext eines einzelnen Tests tun. Es wird verwendet, um Objekte, Dicts, Umgebungsvariablen, PYTHONPATH oder das aktuelle Verzeichnis zu ändern. Es ist wie eine Mini-Version von Mocking. Und wenn der Test endet, wird unabhängig davon, ob er bestanden wurde oder nicht, der ursprüngliche, ungepatchte Code wiederhergestellt und alles rückgängig gemacht, was durch den Patch geändert wurde.

Das monkeypatch-Fixture bietet die folgenden Funktionen:

Funktion

Beschreibung

setattr(TARGET, NAME, VALUE, raising=True) [1]

setzt ein Attribut

delattr(TARGET, NAME, raising=True) [1]

löscht ein Attribut

setitem(DICT, NAME, VALUE)

setzt einen Dict-Eintrag

delitem(DICT, NAME, raising=True) [1]

löscht einen Dict-Eintrag

setenv(NAME, VALUE, prepend=None) [2]

setzt eine Umgebungsvariable

delenv(NAME, raising=True) [1]

löscht eine Umgebungsvariable

syspath_prepend(PATH)

erweitert den Pfad sys.path

chdir(PATH)

wechselt das aktuelle Arbeitsverzeichnis

Wir können monkeypatch verwenden, um die CLI auf ein temporäres Verzeichnis für die Datenbank umzuleiten, und zwar auf zweierlei Weise. Beide Methoden erfordern Kenntnisse über den Anwendungscode. Schauen wir uns die Methode cli.get_path() in src/items/cli.py an:

import os
import pathlib


def get_path():
    db_path_env = os.getenv("ITEMS_DB_DIR", "")
    if db_path_env:
        db_path = pathlib.Path(db_path_env)
    else:
        db_path = pathlib.Path.home() / "items_db"
    return db_path

Diese Methode teilt dem restlichen CLI-Code mit, wo sich die Datenbank befindet. Um uns den Speicherort der Datenbank auf der Kommandozeile ausgeben zu lassen, definieren wir nun auch noch config() in src/items/cli.py:

@app.command()
def config():
    """Return the path to the Items db."""
    with items_db() as db:
        print(db.path())
$ items config
/Users/veit/items_db

Um diese Methoden zu testen, können wir nun entweder die gesamte get_path()-Funktion oder das pathlib.Path()-Attribut home patchen. Hierfür definieren wir in tests/test_config.py zunächst eine Hilfsfunktion run_items_cli, die dasselbe ausgibt wie items auf der Kommandozeile:

from typer.testing import CliRunner

import items


def run_items_cli(*params):
    runner = CliRunner()
    result = runner.invoke(items.app, params)
    return result.output.rstrip()

Anschließend können wir dann unseren Test schreiben, der die gesamte get_path()-Funktion patcht:

def test_get_path(monkeypatch, tmp_path):
    def fake_get_path():
        return tmp_path

    monkeypatch.setattr(items.cli, "get_path", fake_get_path)
    assert run_items_cli("config") == str(tmp_path)

Die Funktion get_path() aus items.cli kann nicht einfach durch tmp_path ersetzt werden, da dies ein pathlib.Path-Objekt ist, das nicht aufrufbar ist. Daher wird sie durch die fake_get_path()-Funktion ersetzt. Alternativ können wir jedoch auch das home-Attribut von pathlib.Path patchen:

def test_home(monkeypatch, tmp_path):
    items_dir = tmp_path / "items_db"

    def fake_home():
        return tmp_path

    monkeypatch.setattr(items.cli.pathlib.Path, "home", fake_home)
    assert run_items_cli("config") == str(items_dir)

Monkey patching und Mocking verkomplizieren jedoch das Testen, sodass wir nach Möglichkeiten suchen werden, dies zu vermeiden, wann immer es möglich ist. In unserem Fall könnte sinnvoll sein, eine Umgebungsvariable ITEMS_DB_DIR zu setzen, die einfach gepatcht werden kann:

def test_env_var(monkeypatch, tmp_path):
    monkeypatch.setenv("ITEMS_DB_DIR", str(tmp_path))
    assert run_items_cli("config") == str(tmp_path)

Verbleibende Built-in-Fixtures

Built-in-Fixture

Beschreibung

capfd, capfdbinary, capsysbinary

Varianten von capsys, die mit Dateideskriptoren und/oder binärer Ausgabe arbeiten.

caplog

ähnlich wie capsys; wird für Meldungen verwendet, die mit Pythons Logging-System erstellt werden.

cache

wird zum Speichern und Abrufen von Werten über mehrere Pytest-Läufe hinweg verwendet.

Es erlaubt last-failed, failed-first und ähnliche Optionen.

doctest_namespace

nützlich, wenn ihr pytest verwenden möchtet, um Doctests durchzuführen.

pytestconfig

wird verwendet, um Zugriff auf Konfigurationswerte, Plugin-Manager und -Hooks zu erhalten.

record_property, record_testsuite_property

wird verwendet, um dem Test oder der Testsuite zusätzliche Eigenschaften hinzuzufügen.

Besonders nützlich für das Hinzufügen von Daten zu einem Bericht, der von CI-Tools verwendet wird.

recwarn

wird verwendet, um Warnmeldungen zu testen.

request

wird verwendet, um Informationen über die ausgeführte Testfunktion bereitzustellen.

wird meist bei der Parametrisierung von Fixtures verwendet

pytester, testdir

Wird verwendet, um ein temporäres Testverzeichnis bereitzustellen, um die Ausführung und das Testen von pytest-Plugins zu unterstützen. pytester ist der pathlib-basierte Ersatz für das py.path-basierte testdir.

tmpdir, tmpdir_factory

ähnlich wie tmp_path und tmp_path_factory; dient der Rückgabe eines py.path.local-Objekts anstelle eines pathlib.Path-Objekts.

Ihr könnt die vollständige Liste der Built-in-Fixtures erhalten, indem ihr pytest --fixtures ausführt.

Siehe auch