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
den Befehl mit
subprocess.run()auszuführendie Ausgabe zu erfassen
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
capfdWie
capsys, erfasst aber die Dateideskriptoren 1 und 2, die normalerweise dasselbe wiestdoutundstderrcapsysbinaryWährend capsys Text erfasst, erfasst capsysbinary Bytes
capfdbinaryerfasst Bytes in den Dateideskriptoren 1 und 2
caplogerfasst 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 |
|---|---|
setzt ein Attribut |
|
löscht ein Attribut |
|
setzt einen Dict-Eintrag |
|
löscht einen Dict-Eintrag |
|
setzt eine Umgebungsvariable |
|
löscht eine Umgebungsvariable |
|
erweitert den Pfad
|
|
wechselt das aktuelle Arbeitsverzeichnis |
|
wechselt den aktuellen Kontext |
Bemerkung
Bei agentischer Software-Entwicklung versuchen wir, Monkeypatching so gut wie möglich zu vermeiden:
- 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.:
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:
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:
@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 |
|
|---|---|---|
Varianten von |
||
ähnlich wie |
||
wird zum Speichern und Abrufen von Werten über mehrere Pytest-Läufe hinweg verwendet. Es erlaubt |
||
nützlich, wenn ihr pytest verwenden möchtet, um Doctests durchzuführen. |
||
wird verwendet, um Zugriff auf Konfigurationswerte, Plugin-Manager und -Hooks zu erhalten. |
||
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. |
||
wird verwendet, um Warnmeldungen zu testen. |
||
wird verwendet, um Informationen über die ausgeführte Testfunktion bereitzustellen. wird meist bei der Parametrisierung von Fixtures verwendet |
||
Wird verwendet, um ein temporäres
Testverzeichnis bereitzustellen, um die
Ausführung und das Testen von pytest-Plugins
zu unterstützen. |
||
ähnlich wie |
||
Ihr könnt die vollständige Liste der Built-in-Fixtures erhalten, indem ihr
pytest --fixtures ausführt.
Siehe auch