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 Kommandozeilen-Optionen 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():
    """TasksDB object connected to a temporary database"""
    with TemporaryDirectory() as db_dir:
        db_path = Path(db_dir)
        db_ = tasks.TasksDB(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):
    """TasksDB object connected to a temporary database"""
    db_path = tmp_path_factory.mktemp("tasks_db")
    db_ = tasks.TasksDB(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 Tasks-Beispielprojekt hat deswegen auch eine Kommandozeilen-Schnittstelle, die wir nun testen wollen.

Der Befehl cusy.tasks version soll die Version ausgeben:

$ uv run cusy.tasks version
0.1.0

Die Version ist auch via Python verfügbar:

>>> from cusy import tasks
>>> tasks.__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

from cusy import tasks


def test_version():
    process = subprocess.run(
        ["tasks", "version"], capture_output=True, text=True
    )
    output = process.stdout.rstrip()
    assert output == tasks.__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:

from cusy import tasks


def test_version(capsys):
    tasks.cli.version()
    output = capsys.readouterr().out.rstrip()
    assert output == tasks.__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.

from cusy import tasks


def test_stdout():
    version = tasks.__version__
    print("\ntasks " + version)

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

$ uv run 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 Kommandozeilen-Sitzung 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:

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

 tests/test_output.py
 cusy.tasks 0.1.0
 .

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

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

from cusy import tasks


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

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

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

tests/test_output.py
cusy.tasks 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 Logging-Daten, s.a. How to manage logging

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 Tasks-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

from cusy import tasks


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

Wir werden diese Methode der Ausgabentests als Ausgangspunkt für die restlichen Tests der Tasks-CLI verwenden. Ich habe mit den CLI-Tests begonnen, indem ich die Tasks-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

monkeypatch.setattr(obj, name, value, raising=True) [1]

setzt ein Attribut

monkeypatch.delattr(obj, name, raising=True) [1]

löscht ein Attribut

monkeypatch.setitem(mapping, name, value)

setzt einen Dict-Eintrag

monkeypatch.delitem(obj, name, raising=True) [1]

löscht einen Dict-Eintrag

monkeypatch.setenv(name, value, prepend=None) [2]

setzt eine Umgebungsvariable

monkeypatch.delenv(name, raising=True) [1]

löscht eine Umgebungsvariable

monkeypatch.syspath_prepend(path)

erweitert den Pfad sys.path

monkeypatch.chdir(path)

wechselt das aktuelle Arbeitsverzeichnis

monkeypatch.context()

wechselt den aktuellen Kontext

Bemerkung

Bei agentischer Software-Entwicklung versuchen wir, Monkeypatching so gut wie möglich zu vermeiden:

AGENTS.md
- Prefer testing real code where possible. Use mocks and `monkeypatch` when absolute necessary. Try to avoid mocking as much as possible.

Siehe auch

Monkeypatching von Umgebungsvariablen

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/cusy/tasks/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() / "tasks_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/cusy/tasks/cli.py:

@app.command()
def config():
    """Return the path to the Tasks db."""
    with tasks_db() as db:
        print(db.path())
$ uv run cusy.tasks config
/Users/veit/tasks_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_tasks_cli, die dasselbe ausgibt wie tasks auf der Kommandozeile:

from typer.testing import CliRunner

from cusy import tasks


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

Anschließend können wir dann unseren Test schreiben, der einen Patch für die get_path()-Funktion enthält:

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

    monkeypatch.setattr(tasks.cli, "get_path", fake_get_path)
    assert run_tasks_cli("config") == str(tmp_path)

Die Funktion get_path() aus tasks.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):
    tasks_dir = tmp_path / "tasks_db"

    def fake_home():
        return tmp_path

    monkeypatch.setattr(tasks.cli.pathlib.Path, "home", fake_home)
    assert run_tasks_cli("config") == str(tasks_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_tasks_cli("config") == str(tmp_path)

Monkeypatching von Dictionaries

Der Pfad hätte auch in einem Dictionary angegeben sein können, z. B.:

conf.py
DEFAULT_CONFIG = {"database": "tasks_db"}


def create_connection(config=None):
    """Create a connection string from input or defaults."""
    config = config or DEFAULT_CONFIG
    return f"Location={config['database']};"

Zum Testen können wir die Werte im DEFAULT_CONFIG-Dictionary ändern:

tests/test_conf.py
from cusy.tasks import conf


def test_connection(monkeypatch):
    monkeypatch.setitem(conf.DEFAULT_CONFIG, "database", "test_db")

Alternativ hättet ihr auch ein Fixture definieren können mit:

tests/conftest.py
@pytest.fixture
def mock_test_database(monkeypatch):
    """Set the DEFAULT_CONFIG database to test_db."""
    monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")

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