Eingebaute Fixtures — tmp_path, capsys und caplog
Die Fixtures, die pytest für die kniffligen Teile des Testens mitbringt: Dateien, Konsolenausgaben und Logs. Teste Code, der CSV und JSON schreibt, ohne echte Pfade zu berühren, und überprüfe seine Ausgaben und Logs.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist eine Funktion, die eine CSV-Datei schreibt, und ein Test dazu:
import csv
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def test_write_csv():
write_csv("report.csv", [("pen", 12), ("bag", 2)])
with open("report.csv") as f:
assert f.read().splitlines()[1] == "pen,12"$ pytest -q
. [100%]
1 passed in 0.08s
$ ls
report.csv test_report.pyDer Test hat bestanden — und er hat etwas hinterlassen. report.csv liegt jetzt in deinem Projekt, direkt neben deinem Code. Führst du pytest aus einem anderen Ordner aus, landet die Datei stattdessen dort, denn "report.csv" ist relativ zu dem Verzeichnis, in dem du dich gerade befindest. Zwei Tests, die beide report.csv schreiben, lesen die Überbleibsel des jeweils anderen. Und falls dein Projekt bereits eine echte report.csv hatte, hat der Test sie soeben überschrieben.
Ein Test sollte die Welt genau so hinterlassen, wie er sie vorgefunden hat. Dateien sind der offensichtlichste Fall, aber Bildschirmausgaben und Log-Nachrichten werfen dieselbe Frage von der anderen Seite auf: Die Funktion gibt etwas nach außen, und der Test muss es abfangen.
Die letzten beiden Kapitel haben dir gezeigt, wie man Fixtures schreibt. In diesem Kapitel geht es um die Fixtures, die pytest für diese kniffligen Teile bereits mitbringt — du musst sie nur beim Namen nennen.
Am Ende dieses Kapitels kannst du
- Jedem Test mit
tmp_patheinen eigenen leeren Ordner geben und Funktionen testen, die CSV oder JSON schreiben - Einen temporären Ordner über eine gesamte Session hinweg mit
tmp_path_factoryteilen - Mit
capsystesten, was ein Programm ausgibt, und wissen, wann stattdessencapfdbenötigt wird - Mit
caplogtesten, was ein Programm loggt, einschließlich Nachrichten unterhalb vonWARNING - Mit
request.node.nameherausfinden, welcher Test gerade läuft - Die Fehlermeldungen verstehen, die diese Fixtures bei falscher Verwendung erzeugen
Voraussetzungen: Fixture-Scope und conftest.py.
Bevor du den Test schreibst
Code, der Dateien, das Terminal oder das Log anfasst, hat Seiteneffekte: Sein eigentliches Ergebnis ist etwas, das er in der Welt hinterlässt, nicht nur das, was er zurückgibt. Notiere dir daher vor jeder Zeile Testcode, wohin welcher Effekt fließt.
1. Benenne jede Ausgabe. Für eine Funktion, die Lagerbestandszeilen exportiert, gibt es bis zu vier:
- den Rückgabewert (den geschriebenen Pfad)
- den Inhalt der Datei
- was sie ausgibt, aufgeteilt in Standardausgabe und Standardfehlerausgabe
- was sie loggt, und auf welchem Level
Jede davon ist ein eigenes Versprechen, und jede muss beobachtet werden können. Den Rückgabewert bekommst du umsonst. Für die anderen drei hat pytest jeweils eine Fixture: tmp_path für Dateien, capsys für ausgegebenen Text, caplog für Logs.
2. Stelle sicher, dass der Code dem Test die Wahl lässt, wohin Dateien geschrieben werden. Das ist die erste Frage, die du stellen solltest — denn wenn die Antwort „Nein“ lautet, kann dich keine Fixture retten. Eine Funktion, die ihren Pfad selbst baut — open("/var/reports/stock.csv", "w") —, lässt sich nur testen, indem man an genau diesen echten Ort schreibt. Eine Funktion, die den Ordner oder Pfad als Parameter entgegennimmt, kann auf tmp_path verwiesen werden. Wenn du den Code besitzt, ändere ihn so, dass er den Pfad entgegennimmt. (Wenn du ihn nicht ändern kannst, ist monkeypatch aus dem nächsten Kapitel der Rettungsanker.)
3. Überprüfe dein Setup. Es gibt nichts zu installieren — jede Fixture in diesem Kapitel ist bereits in pytest enthalten. Du brauchst das Übliche: eine virtuelle Umgebung mit pytest und Code, der aus der Testdatei importierbar ist. Kenne den Namen deines Loggers (logging.getLogger("export")), denn caplog.set_level kann gezielt darauf angewendet werden.
4. Plane die Testfälle. Für export_stock(rows, out_dir), das gültige Zeilen in stock.csv schreibt, vor negativen Mengen warnt und eine Zusammenfassung ausgibt:
| Fall | Eingabe | Erwartet | |---|---|---| | Normalfall | [("pen", 12), ("eraser", 30)], tmp_path | Datei enthält Header + 2 Zeilen; gibt wrote 2 rows to stock.csv aus | | Ungültige Zeile | eine Zeile mit Menge -1 | Zeile ausgelassen; ein WARNING, das sie nennt | | Fehlender Ordner | tmp_path / "exports" / "2026" | Ordner erstellt; Datei darin | | Nichts Gültiges | [("bag", -1)] | nur Header; nothing exported auf der Standardfehlerausgabe | | Saubere Ausgabe | beliebige gültige Zeilen | nichts auf der Standardfehlerausgabe |
5. Entscheide, was du nicht testest. Teste nicht, ob Pythons csv-Modul Kommas korrekt maskiert oder ob json.dumps gültiges JSON erzeugt — das sind die Versprechen anderer, die bereits getestet wurden. Vergleiche nicht das vollständige caplog.text, das Dateinamen und Zeilennummern enthält, die sich bei jeder Codeänderung verschieben. Und teste nicht den genauen Speicherort von tmp_path; der ist auf jedem Rechner anders. Teste deine Entscheidungen: welche Zeilen behalten werden, was wohin fließt und was in den Meldungen steht.
Der Rest des Kapitels gibt dir das Werkzeug für jede Spalte dieser Tabelle an die Hand, und das vollständige Beispiel setzt sie um.
tmp_path — ein frischer Ordner für jeden Test
Fordere tmp_path an, und pytest übergibt deinem Test ein Verzeichnis, das vor einem Augenblick noch gar nicht existierte:
def test_first(tmp_path):
print(tmp_path)
print(type(tmp_path))
def test_second(tmp_path):
print(tmp_path)$ pytest -q -s
/tmp/pytest-of-you/pytest-0/test_first0
<class 'pathlib.PosixPath'>
./tmp/pytest-of-you/pytest-0/test_second0
.
2 passed in 0.07s(-s schaltet pytests eigenes Abfangen von Ausgaben ab, damit die print-Aufrufe das Terminal erreichen. Unter Windows beginnt der Pfad irgendwo im Temp-Ordner deines Benutzers und der Typ lautet WindowsPath.)
Drei Dinge lassen sich aus dieser Ausgabe ablesen:
- Es ist ein
pathlib.Path, kein String. Du fügst Pfade mit/zusammen, liest mit.read_text()und überprüfst mit.exists(). - Jeder Test bekommt seinen eigenen Ordner, benannt nach dem Test.
test_first0undtest_second0begegnen sich nie, sodass beide eine Datei namensreport.csvschreiben können. - Jeder Durchlauf bekommt einen neuen Basisordner:
pytest-0, dannpytest-1und so weiter. pytest behält die Ordner der letzten drei Durchläufe, damit du nach einem Fehlschlag hineinschauen kannst, und löscht ältere automatisch. Du musst nie aufräumen.
Eine Funktion testen, die eine Datei schreibt
Jetzt noch einmal der CSV-Test, diesmal richtig gemacht — plus zwei JSON-Funktionen, da das Speichern von Einstellungen die andere dateischreibende Aufgabe ist, die fast jedes Programm hat:
import csv
import json
def write_csv(path, rows):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
writer.writerows(rows)
def save_settings(path, settings):
path.write_text(json.dumps(settings, indent=2))
def load_settings(path):
return json.loads(path.read_text())
def test_write_csv(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12), ("bag", 2)])
assert target.exists()
assert target.read_text().splitlines() == [
"product,quantity",
"pen,12",
"bag,2",
]
def test_csv_reads_back(tmp_path):
target = tmp_path / "report.csv"
write_csv(target, [("pen", 12)])
with target.open(newline="") as f:
rows = list(csv.DictReader(f))
assert rows == [{"product": "pen", "quantity": "12"}]
def test_settings_round_trip(tmp_path):
target = tmp_path / "settings.json"
settings = {"currency": "BDT", "tax": 0.15}
save_settings(target, settings)
assert load_settings(target) == settings
assert '"currency": "BDT"' in target.read_text()$ pytest -q
... [100%]
3 passed in 0.07s
$ ls
test_files.pyDiesmal bleibt nichts zurück. Das Muster ist immer dasselbe: einen Pfad innerhalb von tmp_path bauen, ihn an den zu testenden Code übergeben, dann die Datei wieder einlesen und prüfen.
Es gibt zwei Möglichkeiten der Prüfung, und die obigen Tests nutzen beide. test_write_csv prüft den exakten Text — der strengste Test und der richtige, wenn ein anderes Programm die Datei lesen wird. test_csv_reads_back und test_settings_round_trip lesen die Datei so ein, wie es ein echter Leser tun würde, und vergleichen die Daten. Beachte, dass DictReader "12" als String zurückgegeben hat: CSV kennt keine Zahlen, nur Text. Bei einem Round-Trip-Test fällt genau diese Art von Überraschung auf.
tmp_path beginnt leer. Wenn dein Code erwartet, dass ein Unterordner existiert, erstelle ihn entweder im Test mit (tmp_path / "exports").mkdir() oder — noch besser — lass den Code ihn selbst erstellen. Was passiert, wenn niemand daran denkt, siehst du unter „Wenn etwas schiefgeht“.
tmp_path_factory — ein Ordner für die gesamte Session
tmp_path hat Function Scope: ein neuer Ordner für jeden Test. Manchmal ist das verschwenderisch. Nimm an, mehrere Tests lesen eine große Beispieldatei, deren Erstellung einige Zeit dauert. Im letzten Kapitel hättest du zu scope="session" gegriffen — aber eine Session-Fixture kann tmp_path nicht nutzen, weil eine langlebige Fixture nicht von etwas abhängen darf, das nach jedem Test verworfen wird.
Dafür gibt es tmp_path_factory, das selbst Session Scope hat. Du forderst Ordner mit mktemp an:
import pytest
@pytest.fixture(scope="session")
def big_catalogue(tmp_path_factory):
folder = tmp_path_factory.mktemp("data")
target = folder / "catalogue.csv"
lines = ["product,quantity"]
lines += [f"item{i},{i}" for i in range(10_000)]
target.write_text("\n".join(lines))
print("built", target)
return target
def test_has_header(big_catalogue):
assert big_catalogue.read_text().startswith("product,quantity")
def test_has_every_row(big_catalogue):
assert len(big_catalogue.read_text().splitlines()) == 10_001$ pytest -q -s
built /tmp/pytest-of-you/pytest-6/data0/catalogue.csv
..
2 passed in 0.07sbuilt erscheint nur einmal für zwei Tests. mktemp("data") hängt eine Zahl an den Namen an — data0, und data1, wenn du es erneut aufrufst —, sodass zwei Aufrufe niemals kollidieren.
Der Kompromiss ist derselbe wie im letzten Kapitel: Beide Tests teilen sich nun eine Datei. Das ist hier in Ordnung, weil sie sie nur lesen. Ein Test, der eine geteilte Datei verändert, braucht seine eigene Kopie in tmp_path.
capsys — testen, was ein Programm ausgibt
Beginnen wir mit dem Fehler, den fast jeder einmal macht. Eine Funktion gibt eine Begrüßung aus; der Test prüft ihr Ergebnis:
def greet(name):
print(f"hello, {name}")
def test_greet():
assert greet("Rina") == "hello, Rina"$ pytest -q
F [100%]
=================================== FAILURES ===================================
__________________________________ test_greet __________________________________
def test_greet():
> assert greet("Rina") == "hello, Rina"
E AssertionError: assert None == 'hello, Rina'
E + where None = greet('Rina')
test_wrong.py:6: AssertionError
----------------------------- Captured stdout call -----------------------------
hello, Rina
=========================== short test summary info ============================
FAILED test_wrong.py::test_greet - AssertionError: assert None == 'hello, Rina'
1 failed in 0.09sprint sendet Text; es gibt ihn nicht per Return zurück, daher gibt greet None zurück. Schau dir jedoch das Ende des Berichts an: Captured stdout call — pytest hat den Text bereits abgefangen. Das geschieht bei jedem Test, um das Terminal aufgeräumt zu halten. Mit capsys greifst du darauf zu:
def greet(name):
print(f"hello, {name}")
def test_greet(capsys):
greet("Rina")
assert capsys.readouterr().out == "hello, Rina\n"$ pytest -q
. [100%]
1 passed in 0.07sRufe zuerst die Funktion auf und lies danach aus, was sie ausgegeben hat. Echte Kommandozeilenprogramme geben mehr als eine Zeile auf zwei Streams aus und geben einen Exit-Code zurück. Hier ist ein solches Programm samt Tests:
import sys
def main(argv):
if not argv:
print("usage: stock ITEM QUANTITY", file=sys.stderr)
return 2
item, quantity = argv[0], int(argv[1])
print(f"{item}: {quantity} in stock")
if quantity < 5:
print("low stock")
return 0
def test_prints_the_stock(capsys):
code = main(["pen", "12"])
captured = capsys.readouterr()
assert code == 0
assert captured.out == "pen: 12 in stock\n"
assert captured.err == ""
def test_warns_when_low(capsys):
main(["bag", "2"])
out = capsys.readouterr().out
assert out.splitlines() == ["bag: 2 in stock", "low stock"]
def test_usage_goes_to_stderr(capsys):
code = main([])
captured = capsys.readouterr()
assert code == 2
assert captured.out == ""
assert "usage:" in captured.err$ pytest -q
... [100%]
3 passed in 0.08scapsys.readouterr() gibt alles zurück, was seit der letzten Abfrage auf die Standardausgabe und Standardfehlerausgabe geschrieben wurde — als Objekt mit zwei String-Feldern, .out und .err. Es ist ein Named Tuple, daher funktioniert auch out, err = capsys.readouterr().
Zwei Details sind in der Praxis wichtig:
printhängt einen Zeilenumbruch an. Der abgefangene Text lautet"pen: 12 in stock\n", nicht"pen: 12 in stock". Bei mehreren Zeilen liefert.splitlines()eine saubere Liste zum Vergleichen.- Fehler gehören nach
err. Ein Benutzungshinweis gehört auf die Standardfehlerausgabe, undtest_usage_goes_to_stderrprüft sowohl, dass er dort gelandet ist, als auch, dass nichts auf die Standardausgabe gelangt ist.
„Seit der letzten Abfrage“ ist wörtlich gemeint — das Auslesen leert den Puffer:
def test_read_twice(capsys):
print("first")
one = capsys.readouterr()
print("second")
two = capsys.readouterr()
assert one.out == "first\n"
assert two.out == "second\n"$ pytest -q
. [100%]
1 passed in 0.07sDas ist nützlich: Du kannst Ausgaben Schritt für Schritt überprüfen. Es ist aber auch der Grund, warum ein zweites readouterr() manchmal unerwartet leer zurückkommt.
capfd — wenn die Ausgabe nicht über Python läuft
capsys ersetzt Pythons sys.stdout und sys.stderr. Alles, was direkt in die Ausgabe des Betriebssystems schreibt — ein Kindprozess, eine C-Erweiterung —, umgeht das. capfd fängt Ausgaben eine Ebene tiefer auf File-Descriptor-Ebene ab und erwischt auch diese:
import os
def test_with_capsys(capsys):
os.system("echo from the shell")
assert capsys.readouterr().out == ""
def test_with_capfd(capfd):
os.system("echo from the shell")
assert capfd.readouterr().out == "from the shell\n"$ pytest -q
.. [100%]
2 passed in 0.07sDasselbe readouterr(), dieselben Felder .out und .err. Verwende standardmäßig capsys und wechsle nur dann zu capfd, wenn Ausgaben von außerhalb von Python fehlen.
caplog — testen, was ein Programm loggt
Echte Programme loggen, statt auf der Konsole auszugeben. Eine Warnung im Log ist ein Verhalten wie jedes andere und verdient daher einen Test:
import logging
logger = logging.getLogger("importer")
def parse_quantities(lines):
result = {}
for line in lines:
item, _, raw = line.partition(",")
if not raw.strip().isdigit():
logger.warning("skipped %r: bad quantity", item)
continue
result[item] = int(raw)
logger.info("parsed %d of %d lines", len(result), len(lines))
return result
def test_bad_row_is_logged(caplog):
result = parse_quantities(["pen,12", "bag,two"])
assert result == {"pen": 12}
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.levelname == "WARNING"
assert record.getMessage() == "skipped 'bag': bad quantity"
def test_text_is_one_string(caplog):
parse_quantities(["bag,two"])
print(repr(caplog.text))
assert "bad quantity" in caplog.text$ pytest -q -s
."WARNING importer:test_importer.py:11 skipped 'bag': bad quantity\n"
.
2 passed in 0.07scaplog stellt dir dasselbe Log auf drei Arten zur Verfügung:
caplog.records— eine Liste vonlogging.LogRecord-Objekten, eines pro Nachricht. Jedes besitzt.levelname("WARNING"),.name(den Logger,"importer") und.getMessage(), das das%rmit seinem Wert befüllt.caplog.messages— nur die fertigen Nachrichten-Strings als Liste.caplog.text— alles als ein einziger formatierter String. Gut für eine schnellein-Prüfung; zu ungenau für alles Exakte.
Bevorzuge records, wenn das Level eine Rolle spielt, und messages, wenn es nur auf den Wortlaut ankommt.
Nachrichten unterhalb von WARNING werden standardmäßig nicht erfasst
parse_quantities loggt auch eine INFO-Zusammenfassung. Fügen wir einen Test dafür hinzu:
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]$ pytest -q -k summary
F [100%]
=================================== FAILURES ===================================
____________________________ test_summary_is_logged ____________________________
caplog = <_pytest.logging.LogCaptureFixture object at 0x70495b4f43e0>
def test_summary_is_logged(caplog):
parse_quantities(["pen,12"])
> assert caplog.messages == ["parsed 1 of 1 lines"]
E AssertionError: assert [] == ['parsed 1 of 1 lines']
E
E Right contains one more item: 'parsed 1 of 1 lines'
E Use -v to get more diff
test_importer.py:38: AssertionError
=========================== short test summary info ============================
FAILED test_importer.py::test_summary_is_logged - AssertionError: assert [] =...
1 failed, 2 deselected in 0.08sEs wurde nichts erfasst. Die Funktion hat zwar geloggt — aber Pythons Logging beginnt standardmäßig bei WARNING, sodass die INFO-Nachricht verworfen wurde, bevor caplog sie überhaupt zu Gesicht bekam. Setze das Level für den Test mit caplog.set_level herab:
import logging
def test_summary_is_logged(caplog):
caplog.set_level(logging.INFO)
parse_quantities(["pen,12"])
assert caplog.messages == ["parsed 1 of 1 lines"]
def test_only_the_importer_logger(caplog):
caplog.set_level(logging.INFO, logger="importer")
parse_quantities(["pen,12", "bag,two"])
assert [r.levelname for r in caplog.records] == ["WARNING", "INFO"]$ pytest -q
.. [100%]
2 passed in 0.07sRufe set_level vor dem Code auf, der loggt. Die zweite Variante mit logger="importer" setzt das Level nur für diesen einen Logger herab, sodass geschwätzige Bibliotheken ruhig bleiben. In beiden Fällen stellt pytest das alte Level wieder her, sobald der Test beendet ist.
request und pytestconfig
Eine weitere eingebaute Fixture taucht häufig im Inneren anderer Fixtures auf. request beschreibt den Test, der die Fixture angefordert hat; request.node.name ist der Name dieses Tests. Das ist praktisch, um Dateien lesbare, eindeutige Namen zu geben:
import pytest
@pytest.fixture
def export_file(tmp_path, request):
return tmp_path / f"{request.node.name}.csv"
def test_pens(export_file):
print(export_file.name)
@pytest.mark.parametrize("quantity", [1, 50])
def test_bulk(export_file, quantity):
print(export_file.name)
def test_config(pytestconfig):
print(pytestconfig.rootpath.name, pytestconfig.getoption("verbose"))$ pytest -q -s
test_pens.csv
.test_bulk[1].csv
.test_bulk[50].csv
.shop -1
.
4 passed in 0.07sDer Name eines parametrisierten Tests enthält seine Parameter in eckigen Klammern. Und der letzte Test zeigt pytestconfig: das Konfigurationsobjekt des Durchlaufs mit dem Root-Ordner des Projekts und jeder Kommandozeilenoption (-q setzt die Verbosity auf -1). Du wirst ihr in den Kapiteln über Konfiguration und Plugins wieder begegnen.
Ein vollständiges Beispiel
export.py schreibt gültige Lagerbestandszeilen in eine CSV-Datei, loggt eine Warnung für jede übersprungene Zeile und gibt eine Zusammenfassung aus:
import csv
import logging
import sys
logger = logging.getLogger("export")
def export_stock(rows, out_dir):
"""Write the valid rows to out_dir/stock.csv and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / "stock.csv"
written = 0
with target.open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product", "quantity"])
for product, quantity in rows:
if quantity < 0:
logger.warning("skipped %s: negative quantity %d", product, quantity)
continue
writer.writerow([product, quantity])
written += 1
print(f"wrote {written} rows to {target.name}")
if written == 0:
print("nothing exported", file=sys.stderr)
return targettest_export.py prüft alle drei Ausgaben — die Datei, das Log und das Terminal:
import csv
from export import export_stock
ROWS = [("pen", 12), ("bag", -1), ("eraser", 30)]
def read_rows(path):
with path.open(newline="") as f:
return list(csv.reader(f))
def test_writes_only_valid_rows(tmp_path):
target = export_stock(ROWS, tmp_path)
assert target == tmp_path / "stock.csv"
assert read_rows(target) == [
["product", "quantity"],
["pen", "12"],
["eraser", "30"],
]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "exports" / "2026"
target = export_stock(ROWS, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_skipped_row_is_logged(tmp_path, caplog):
export_stock(ROWS, tmp_path)
assert [r.levelname for r in caplog.records] == ["WARNING"]
assert caplog.messages == ["skipped bag: negative quantity -1"]
def test_prints_a_summary(tmp_path, capsys):
export_stock(ROWS, tmp_path)
captured = capsys.readouterr()
assert captured.out == "wrote 2 rows to stock.csv\n"
assert captured.err == ""
def test_empty_export_warns_on_stderr(tmp_path, capsys):
export_stock([("bag", -1)], tmp_path)
out, err = capsys.readouterr()
assert out == "wrote 0 rows to stock.csv\n"
assert err == "nothing exported\n"$ pytest -v
collecting ... collected 5 items
test_export.py::test_writes_only_valid_rows PASSED [ 20%]
test_export.py::test_creates_missing_folders PASSED [ 40%]
test_export.py::test_skipped_row_is_logged PASSED [ 60%]
test_export.py::test_prints_a_summary PASSED [ 80%]
test_export.py::test_empty_export_warns_on_stderr PASSED [100%]
============================== 5 passed in 0.09s ===============================
$ ls
export.py test_export.pyDrei Dinge sind bemerkenswert.
Erstens nimmt export_stock den Ordner als Parameter entgegen. Genau diese eine Entscheidung macht die Funktion testbar: Das Programm übergibt einen echten Ordner, die Tests übergeben tmp_path. Eine Funktion, die "/var/reports/stock.csv" fest in sich verdrahtet, lässt sich nur testen, indem man in diesen echten Pfad schreibt — und genau das darf ein Test niemals tun.
Zweitens fordert jeder Test nur die Fixtures an, die er tatsächlich prüft. Zwei davon kombinieren tmp_path mit capsys oder caplog; eingebaute Fixtures lassen sich beliebig miteinander und mit deinen eigenen kombinieren.
Drittens ist das Arbeitsverzeichnis nach fünf Tests, die alle Dateien geschrieben haben, völlig unverändert.
Wenn etwas schiefgeht
fixture 'tmp_dir' not found Ein Tippfehler im Parameternamen. pytest ordnet Fixtures nach dem exakten Namen zu, und die Fehlermeldung listet unter available fixtures: jede Fixture auf, die pytest kennt — tmp_path, tmp_path_factory, capsys, capfd, caplog und die restlichen. Kopiere den richtigen Namen aus dieser Liste. (Dort wirst du auch tmpdir finden; das ist die ältere Version, die ein py.path-Objekt statt eines pathlib.Path zurückgibt. Verwende in neuem Code tmp_path.)
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. Eine Fixture mit scope="session" (oder "module") hat tmp_path angefordert. Eine langlebige Fixture darf nicht von einer Fixture pro Test abhängen. Verwende stattdessen tmp_path_factory.mktemp("name").
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pytest-of-you/pytest-3/test_nested0/exports/report.csv' tmp_path existiert, aber der darin liegende Ordner exports nicht — niemand erstellt Ordner automatisch für dich. Rufe zuerst .mkdir(parents=True, exist_ok=True) auf dem übergeordneten Ordner auf, am besten direkt im zu testenden Code, so wie es export_stock tut.
Eine Datei erscheint unter /tmp/pytest-of-you/pytest-5/test_str0report.csv Überhaupt kein Fehler — der Pfad wurde mit Strings zusammengesetzt: str(tmp_path) + "report.csv". Das Trennzeichen ging verloren, sodass die Datei neben dem Ordner des Tests landete statt darin. Füge Pfade mit / direkt auf dem Path-Objekt zusammen: tmp_path / "report.csv".
AssertionError: assert [] == ['parsed 1 of 1 lines'] mit caplog Die Nachricht wurde unterhalb von WARNING geloggt und nie erfasst. Rufe caplog.set_level(logging.INFO) — oder logging.DEBUG — auf, bevor der Code ausgeführt wird.
capsys.readouterr().out ist "", obwohl du Text erwartet hast Entweder wurde die Ausgabe bereits durch ein früheres readouterr() im selben Test verbraucht oder sie lief nicht über Pythons sys.stdout — beispielsweise bei einem Subprozess oder einem Aufruf von os.system. Wechsle für den zweiten Fall zu capfd.
cannot use capfd and capsys at the same time Ein Test hat beide angefordert. Sie fangen dieselben Datenströme auf unterschiedlichen Ebenen ab; wähle eine davon aus.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Was genau befindet sich nach der letzten Zeile in captured.out?
def test_twice(capsys):
print("first")
capsys.readouterr()
print("second")
print("third")
captured = capsys.readouterr()- A'second\nthird\n'
- B'third\n'
- C'first\nsecond\nthird\n'
- D''
restock loggt zwar, aber der Test schlägt mit assert [] == ['restocked pen'] fehl. Warum?
import logging
logger = logging.getLogger("shop")
def restock(item):
logger.info("restocked %s", item)
def test_restock_is_logged(caplog):
restock("pen")
assert caplog.messages == ["restocked pen"]- ADas `%s` wird nie eingesetzt, die Nachricht lautet also `'restocked %s'`
- B`caplog.messages` enthält den Level-Namen, vergleiche also mit `'INFO restocked pen'`
- C`INFO` liegt unter dem Standard-Level `WARNING`, daher wurde nichts erfasst; rufe zuerst `caplog.set_level(logging.INFO)` auf
- DDer Logger heißt `"shop"`; `caplog` lauscht nur auf dem Root-Logger
Eine Fixture mit scope="session" möchte einmalig eine große Beispieldatei erstellen. Wo sollte die Datei abgelegt werden?
- AIn dem Ordner, den `tmp_path_factory.mktemp("data")` zurückgibt
- BIn einem `data/`-Ordner im Projekt, damit sie den nächsten Durchlauf überlebt
- CIn `tmp_path` — fordere es einfach in den Parametern der Fixture an
- DIn `tmpdir` — es ist die Session-Version von `tmp_path`
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Schreibe ein Modul notes.py mit einer Funktion export_notes(notes, out_dir). Sie nimmt eine Liste von Dictionaries wie {"title": "Buy pens", "done": False} entgegen und:
- Erstellt
out_dir, falls es noch nicht existiert, und schreibt die Notizen nachout_dir / "notes.json" - Loggt ein
WARNINGfür jede Notiz, deren Titel leer ist, und lässt sie in der Datei aus - Loggt eine
INFO-Nachricht"exported N notes" - Gibt
saved notes.jsonaus, wenn sie fertig ist
Schreibe dann test_notes.py, das ausschließlich mit eingebauten Fixtures Folgendes prüft:
- Die JSON-Datei lässt sich wieder einlesen und enthält exakt die Notizen, die gültig waren
- Ein fehlender Unterordner wie
tmp_path / "a" / "b"wird erstellt - Die Warnung wird mit
levelname == "WARNING"geloggt und dieINFO-Nachricht wird ebenfalls erfasst (was musst du zuerst aufrufen?) - Es wird exakt
saved notes.json\nausgegeben und nichts gelangt auf die Standardfehlerausgabe
Führe pytest -q aus und führe danach ls aus (oder dir unter Windows). Falls irgendeine notes.json in deinem Projektordner auftaucht, schreibt einer deiner Tests — oder deine Funktion — in einen echten Pfad. Das zu erkennen und zu beheben ist die Gewohnheit, um die es in diesem Kapitel geht.
Lösung
Zuerst der Plan, in Form der Tabelle aus „Bevor du den Test schreibst“:
| Fall | Eingabe | Erwartet | |---|---|---| | Gültige Notizen behalten | drei Notizen, eine mit Titel " " | Datei enthält die anderen beiden, unverändert | | Fehlender Ordner | tmp_path / "a" / "b" | beide Ordner erstellt, Datei darin | | Leerer Titel | dieselben drei Notizen | ein WARNING, dann INFO exported 2 notes | | Ausgabe | dieselben drei Notizen | saved notes.json\n auf stdout, nichts auf stderr | | Nichts zu exportieren | [] | Datei enthält [] |
notes.py:
import json
import logging
logger = logging.getLogger("notes")
def export_notes(notes, out_dir):
"""Write notes with a title to out_dir/notes.json and return its path."""
out_dir.mkdir(parents=True, exist_ok=True)
valid = []
for note in notes:
if not note["title"].strip():
logger.warning("skipped a note with an empty title")
continue
valid.append(note)
target = out_dir / "notes.json"
target.write_text(json.dumps(valid, indent=2))
logger.info("exported %d notes", len(valid))
print("saved notes.json")
return targettest_notes.py:
import json
import logging
from notes import export_notes
NOTES = [
{"title": "Buy pens", "done": False},
{"title": " ", "done": True},
{"title": "Call the printer", "done": True},
]
def test_file_holds_only_valid_notes(tmp_path):
target = export_notes(NOTES, tmp_path)
assert target == tmp_path / "notes.json"
assert json.loads(target.read_text()) == [NOTES[0], NOTES[2]]
def test_creates_missing_folders(tmp_path):
out_dir = tmp_path / "a" / "b"
target = export_notes(NOTES, out_dir)
assert target.parent == out_dir
assert target.exists()
def test_empty_title_is_warned_about(tmp_path, caplog):
caplog.set_level(logging.INFO, logger="notes")
export_notes(NOTES, tmp_path)
assert [(r.levelname, r.getMessage()) for r in caplog.records] == [
("WARNING", "skipped a note with an empty title"),
("INFO", "exported 2 notes"),
]
def test_prints_one_line_and_no_errors(tmp_path, capsys):
export_notes(NOTES, tmp_path)
out, err = capsys.readouterr()
assert out == "saved notes.json\n"
assert err == ""
def test_no_notes_still_writes_an_empty_list(tmp_path):
target = export_notes([], tmp_path)
assert json.loads(target.read_text()) == []$ pytest -q
..... [100%]
5 passed in 0.10s
$ ls
notes.py test_notes.pyDie Gründe für jede Entscheidung:
out_dirist ein Parameter. Genau das erlaubt es jedem Test,tmp_pathzu übergeben; das abschließendelszeigt keinenotes.jsonim Projekt.- Der JSON-Test liest die Datei mit
json.loadswieder ein, anstatt Text zu vergleichen. Das Versprechen lautet „diese Notizen als Daten“; die genaue Einrückung ist Sache vonjson.dumps, nicht unsere. Der Vergleich mitNOTES[0]undNOTES[2]beweist außerdem, dass die gültigen Notizen unverändert geschrieben wurden. - Der Titel
" "besteht aus Leerzeichen, nicht"". Ein Titel aus reinen Leerzeichen ist der Grenzfall — fürif not note["title"]wirkt er nicht leer und erst.strip()fängt ihn ab. Der Test hält diese Entscheidung fest. caplog.set_levelkommt zuerst und zielt auf"notes". Ohne diesen Aufruf wird dieINFO-Zeile niemals erfasst. Der Vergleich von(levelname, message)-Paaren prüft das Level, den Wortlaut und die Reihenfolge in einem einzigen Assert — ohnecaplog.textanzufassen, dessen Dateinamen und Zeilennummern den Test bei jeder Bearbeitung brechen lassen würden.err == ""wird ganz bewusst geprüft. „Es ist nichts schiefgegangen“ ist Teil der Schnittstellenvereinbarung, und ein versehentliches Debug-printauf die Standardfehlerausgabe würde sonst unbemerkt durchrutschen.- Die leere Liste bekommt ihren eigenen Test. Das ist der Randfall, auf den ein Leser der Datei bei einem neuen Konto als Erstes stoßen wird, und
[]statt gar nichts zu schreiben, ist eine bewusste Entscheidung, die beibehalten werden sollte.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen