Ingebouwde fixtures — tmp_path, capsys en caplog
De fixtures die pytest meelevert voor de lastige kanten van testen: bestanden, geprinte uitvoer en logs. Test code die CSV en JSON schrijft zonder echte paden aan te raken, en controleer wat er geprint en gelogd wordt.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een functie die een CSV-bestand schrijft, en een test ervoor:
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.pyDe test is geslaagd, en heeft iets achtergelaten. report.csv staat nu in je project, naast je code. Voer pytest uit vanuit een andere map en het bestand belandt daar, omdat "report.csv" relatief is aan de plek waar je toevallig stond. Twee tests die allebei report.csv schrijven, lezen elkaars overblijfselen. En als je project al een echte report.csv had, heeft de test die zojuist overschreven.
Een test moet de wereld precies zo achterlaten als hij deze aantrof. Bestanden zijn het voor de hand liggende voorbeeld, maar geprinte uitvoer en logberichten roepen vanaf de andere kant dezelfde vraag op: de functie stuurt iets naar buiten, en de test moet dat opvangen.
De afgelopen twee hoofdstukken lieten zien hoe je fixtures schrijft. Dit hoofdstuk gaat over de fixtures die pytest al meelevert voor deze lastige kanten — je hoeft ze alleen maar bij naam aan te vragen.
Aan het einde van dit hoofdstuk kun je
- Elke test een eigen lege map geven met
tmp_path, en functies testen die CSV of JSON schrijven - Eén tijdelijke map delen over een hele sessie met
tmp_path_factory - Testen wat een programma print met
capsys, en weten wanneer in plaats daarvancapfdnodig is - Testen wat een programma logt met
caplog, inclusief berichten onderWARNING request.node.namegebruiken om te achterhalen welke test er draait- De foutmeldingen begrijpen die deze fixtures geven wanneer ze verkeerd worden gebruikt
Voorkennis: fixture-scope en conftest.py.
Voordat je de test schrijft
Code die bestanden, de terminal of het log aanraakt, heeft bijwerkingen: het echte resultaat is iets wat die achterlaat in de wereld, niet alleen wat die retourneert. Schrijf daarom vóór elke regel testcode op waar elk effect naartoe gaat.
1. Benoem elke uitvoer. Voor een functie die voorraadrijen exporteert, zijn er maximaal vier:
- de returnwaarde (het pad dat is geschreven)
- de inhoud van het bestand
- wat de functie print, opgesplitst in standard output en standard error
- wat de functie logt, en op welk niveau
Elk daarvan is een afzonderlijke belofte, en elk moet kunnen worden waargenomen. De returnwaarde krijg je gratis. Voor de andere drie heeft pytest een fixture: tmp_path voor bestanden, capsys voor geprinte tekst, caplog voor logs.
2. Zorg dat de code een test laat kiezen waar bestanden naartoe gaan. Dit is de eerste vraag die je moet stellen, want als het antwoord 'nee' is, kan geen enkele fixture je redden. Een functie die haar eigen pad samenstelt — open("/var/reports/stock.csv", "w") — kan alleen worden getest door naar die echte plek te schrijven. Een functie die de map of het pad als parameter accepteert, kan naar tmp_path worden verwezen. Als jij eigenaar bent van de code, pas deze dan aan zodat het pad wordt geaccepteerd. (Kun je de code niet aanpassen, dan is monkeypatch uit het volgende hoofdstuk de uitweg.)
3. Controleer je setup. Er valt niets te installeren — elke fixture in dit hoofdstuk wordt standaard meegeleverd met pytest. Je hebt het gebruikelijke nodig: een virtuele omgeving met pytest erin, en de code importeerbaar vanuit het testbestand. Zorg dat je de naam van je logger kent (logging.getLogger("export")), omdat caplog.set_level zich daarop kan richten.
4. Plan de gevallen. Voor export_stock(rows, out_dir), die geldige rijen naar stock.csv schrijft, waarschuwt voor negatieve aantallen en een samenvatting print:
| Geval | Invoer | Verwacht | |---|---|---| | Happy path | [("pen", 12), ("eraser", 30)], tmp_path | bestand heeft header + 2 rijen; print wrote 2 rows to stock.csv | | Ongeldige rij | één rij met aantal -1 | rij weggelaten; één WARNING die de rij noemt | | Ontbrekende map | tmp_path / "exports" / "2026" | mappen aangemaakt; bestand erin | | Niets geldig | [("bag", -1)] | alleen header; nothing exported op standard error | | Schone uitvoer | willekeurige geldige rijen | niets op standard error |
5. Bepaal wat je niet test. Test niet of Python's csv-module komma's correct tussen aanhalingstekens zet, of dat json.dumps geldige JSON produceert — dat zijn andermans beloften, die al zijn getest. Assert niet op de volledige caplog.text, die bestandsnamen en regelnummers bevat die veranderen zodra je de code bewerkt. En test niet de exacte locatie van tmp_path; die verschilt op elke machine. Test jouw beslissingen: welke rijen worden bewaard, wat waar terechtkomt en wat de berichten zeggen.
De rest van het hoofdstuk geeft je de tools voor elke kolom uit die tabel, en het complete voorbeeld implementeert deze.
tmp_path — een nieuwe map voor elke test
Vraag om tmp_path en pytest overhandigt je test een map die een moment geleden nog niet bestond:
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 schakelt pytest's eigen afvangen van uitvoer uit, zodat de print-aanroepen de terminal bereiken. Op Windows begint het pad ergens onder de Temp-map van je gebruiker en is het type WindowsPath.)
Drie dingen om te zien in die uitvoer:
- Het is een
pathlib.Path, geen string. Je voegt paden samen met/, leest met.read_text()en controleert met.exists(). - Elke test krijgt zijn eigen map, vernoemd naar de test.
test_first0entest_second0komen elkaar nooit tegen, dus beide kunnen een bestand met de naamreport.csvschrijven. - Elke run krijgt een nieuwe basismap:
pytest-0, daarnapytest-1, enzovoort. pytest bewaart de mappen van de laatste drie runs zodat je na een mislukte test kunt inspecteren wat erin zit, en verwijdert oudere mappen zelf. Je hoeft zelf nooit op te ruimen.
Een functie testen die een bestand schrijft
Nu dezelfde CSV-test opnieuw, maar dan goed aangepakt — plus een paar JSON-functies, aangezien het opslaan van instellingen de andere bestandstaak is die bijna elk programma heeft:
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.pyDeze keer is er niets achtergebleven. Het patroon is altijd hetzelfde: stel een pad samen binnen tmp_path, geef het aan de code die je test, lees het bestand vervolgens weer uit en controleer het.
Er zijn twee manieren om te controleren, en de bovenstaande tests gebruiken beide. test_write_csv controleert de exacte tekst — de strengste test, en de juiste keuze wanneer een ander programma het bestand zal lezen. test_csv_reads_back en test_settings_round_trip lezen het bestand weer uit zoals een echte lezer dat zou doen en vergelijken de gegevens. Merk op dat DictReader "12" teruggaf, een string: CSV kent geen getallen, alleen tekst. Een round-trip test is de plek waar dat soort verrassingen aan het licht komt.
tmp_path begint leeg. Als je code verwacht dat een submap bestaat, maak deze dan aan in de test met (tmp_path / "exports").mkdir() of — beter nog — laat de code deze zelf aanmaken. Je zult zien wat er gebeurt als niemand dat doet in "Wanneer het misgaat".
tmp_path_factory — één map voor de hele sessie
tmp_path heeft function-scope: een nieuwe map voor elke test. Soms is dat verspilling. Stel dat meerdere tests een groot voorbeeldbestand lezen waarvan het opbouwen even duurt. In het vorige hoofdstuk zou je naar scope="session" grijpen — maar een session-fixture kan tmp_path niet gebruiken, omdat een fixture die de hele sessie blijft bestaan niet kan afhangen van iets dat na elke test wordt weggegooid.
Daarvoor is er tmp_path_factory, dat zelf session-scope heeft. Je vraagt er mappen aan met mktemp:
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 verschijnt één keer, voor twee tests. mktemp("data") voegt een nummer toe aan de naam — data0, en data1 als je het nog eens aanroept — waardoor twee aanroepen nooit botsen.
De afweging is die uit het vorige hoofdstuk: beide tests delen nu één bestand. Dat is hier prima, omdat ze het alleen lezen. Een test die een gedeeld bestand wijzigt, heeft een eigen kopie nodig in tmp_path.
capsys — testen wat een programma print
Begin met de fout die bijna iedereen een keer maakt. Een functie print een begroeting; de test controleert het resultaat ervan:
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 stuurt tekst; het retourneert deze niet, dus greet retourneert None. Kijk echter onderaan het rapport: Captured stdout call — pytest heeft de tekst al opgevangen. Dat gebeurt bij elke test, om de terminal netjes te houden. Met capsys krijg je die uitvoer in handen:
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.07sRoep eerst de functie aan en lees daarna uit wat deze heeft geprint. Echte command-line-programma's printen meer dan één regel, naar twee streams, en retourneren een exitcode. Hier is zo'n programma, met bijbehorende 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() retourneert alles wat naar standard output en standard error is geschreven sinds de vorige keer dat je erom vroeg, als een object met twee string-velden, .out en .err. Het is een named tuple, dus out, err = capsys.readouterr() werkt ook.
Twee details zijn belangrijk in de praktijk:
printvoegt een newline toe. De opgevangen tekst is"pen: 12 in stock\n", niet"pen: 12 in stock". Voor meerdere regels levert.splitlines()een overzichtelijke lijst op om te vergelijken.- Fouten gaan naar
err. Een usage-melding hoort thuis op standard error, entest_usage_goes_to_stderrcontroleert zowel dat het daar terechtkwam als dat er niets naar standard output ging.
"Sinds de vorige keer dat je erom vroeg" is letterlijk bedoeld — het uitlezen maakt de buffer leeg:
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.07sDat is handig: je kunt uitvoer stap voor stap controleren. Het is ook de reden dat een tweede readouterr() soms leeg terugkomt wanneer je dat niet verwachtte.
capfd — wanneer de uitvoer niet via Python loopt
capsys vervangt Python's sys.stdout en sys.stderr. Alles wat rechtstreeks naar de uitvoer van het besturingssysteem schrijft — een child-proces, een C-extensie — omzeilt dat. capfd vangt één niveau lager op, bij de file descriptor, en vangt dat ook op:
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.07sZelfde readouterr(), zelfde .out en .err. Gebruik standaard capsys en stap alleen over op capfd wanneer uitvoer van buiten Python ontbreekt.
caplog — testen wat een programma logt
Echte programma's loggen in plaats van te printen. Een waarschuwing in het log is gedrag net als elk ander, dus verdient het een 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 geeft je hetzelfde log op drie manieren:
caplog.records— een lijst vanlogging.LogRecord-objecten, één per bericht. Elk heeft.levelname("WARNING"),.name(de logger,"importer"), en.getMessage(), waarin de%rwordt ingevuld met de bijbehorende waarde.caplog.messages— alleen de uiteindelijke berichtstrings, als een lijst.caplog.text— alles als één geformatteerde string. Handig voor een snellein-check; te los voor iets exacts.
Kies bij voorkeur records wanneer het niveau van belang is en messages wanneer alleen de bewoording telt.
Berichten onder WARNING worden standaard niet opgevangen
parse_quantities logt ook een INFO-samenvatting. Voeg daar een test voor toe:
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.08sEr werd niets opgevangen. De functie heeft wel gelogd — maar Python's logging begint bij WARNING, waardoor het INFO-bericht werd genegeerd voordat caplog het ooit kon zien. Verlaag het niveau voor de test met caplog.set_level:
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.07sRoep set_level aan vóór de code die logt. De tweede vorm, met logger="importer", verlaagt het niveau alleen voor die ene logger, zodat spraakzame libraries stil blijven. In beide gevallen herstelt pytest het oude niveau wanneer de test is afgelopen.
request en pytestconfig
Nog een built-in fixture die opduikt binnen andere fixtures. request beschrijft de test die om de fixture vroeg; request.node.name is de naam van die test. Dat is handig om bestanden leesbare, unieke namen te geven:
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.07sDe naam van een geparametriseerde test bevat zijn parameters tussen vierkante haken. En de laatste test laat pytestconfig zien: het configuratie-object van de run, met de hoofdmap van het project en elke command-line-optie (-q stelt de verbosity in op -1). Je zult deze opnieuw tegenkomen in de hoofdstukken over configuratie en plugins.
Een compleet voorbeeld
export.py schrijft geldige voorraadrijen naar een CSV, logt een waarschuwing voor elke rij die het overslaat en print een samenvatting:
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 controleert alle drie de soorten uitvoer — het bestand, het log en de 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.pyDrie dingen zijn het opmerken waard.
Ten eerste accepteert export_stock de map als een parameter. Die ene beslissing maakt het testbaar: het programma geeft een echte map door, de tests geven tmp_path door. Een functie die "/var/reports/stock.csv" vanbinnen hardcodeert, kan alleen worden getest door naar dat echte pad te schrijven — wat precies is wat een test nooit mag doen.
Ten tweede vraagt elke test alleen om de fixtures die hij controleert. Twee daarvan combineren tmp_path met capsys of caplog; ingebouwde fixtures laten zich moeiteloos combineren met elkaar en met je eigen fixtures.
Ten derde is de werkmap ongewijzigd gebleven na vijf tests die allemaal bestanden hebben geschreven.
Wanneer het misgaat
fixture 'tmp_dir' not found Een typefout in de parameternaam. pytest koppelt fixtures op exacte naam, en de error print vervolgens available fixtures: met alle fixtures die bekend zijn — tmp_path, tmp_path_factory, capsys, capfd, caplog en de rest. Neem de juiste naam over uit die lijst. (Je ziet daar ook tmpdir staan; dat is de oudere versie die een py.path-object retourneert in plaats van een pathlib.Path. Gebruik tmp_path in nieuwe code.)
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. Een fixture met scope="session" (of "module") heeft om tmp_path gevraagd. Een fixture die langer blijft bestaan kan niet afhangen van een fixture die per test bestaat. Gebruik in plaats daarvan 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 bestaat, maar de map exports daarbinnen niet — niets maakt automatisch mappen voor je aan. Roep eerst .mkdir(parents=True, exist_ok=True) aan op de bovenliggende map, bij voorkeur in de code die je test, zoals export_stock doet.
Er verschijnt een bestand op /tmp/pytest-of-you/pytest-5/test_str0report.csv Helemaal geen error — het pad werd samengesteld met strings: str(tmp_path) + "report.csv". Het scheidingsteken ontbrak, waardoor het bestand naast de testmap belandde in plaats van erin. Voeg paden samen met / op het Path-object zelf: tmp_path / "report.csv".
AssertionError: assert [] == ['parsed 1 of 1 lines'] bij caplog Het bericht werd gelogd onder WARNING en werd nooit opgevangen. Roep caplog.set_level(logging.INFO) — of logging.DEBUG — aan vóórdat de code wordt uitgevoerd.
capsys.readouterr().out is "" terwijl je tekst verwachtte Ofwel de uitvoer is al verbruikt door een eerdere readouterr() in dezelfde test, of de uitvoer liep niet via Python's sys.stdout — een subproces of een os.system-aanroep. Stap in het tweede geval over op capfd.
cannot use capfd and capsys at the same time Een test vroeg om beide. Ze vangen dezelfde streams op verschillende niveaus af; kies een van beide.
Stap 4 van 6 — Voorspellen
Test je begrip
Wat zit er na de laatste regel precies 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 logt wel, maar toch faalt de test met assert [] == ['restocked pen']. Waarom?
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"]- ADe `%s` wordt nooit ingevuld, dus het bericht is `'restocked %s'`
- B`caplog.messages` bevat de niveaunaam, dus vergelijk met `'INFO restocked pen'`
- C`INFO` ligt onder het standaardniveau `WARNING`, dus er is niets opgevangen; roep eerst `caplog.set_level(logging.INFO)` aan
- DDe logger heet `"shop"`; `caplog` luistert alleen naar de root-logger
Een fixture met scope="session" wil één keer een groot voorbeeldbestand aanmaken. Waar moet het bestand komen te staan?
- AIn de map die wordt geretourneerd door `tmp_path_factory.mktemp("data")`
- BIn een map `data/` binnen het project, zodat het tot de volgende run bewaard blijft
- CIn `tmp_path` — vraag deze gewoon aan in de parameters van de fixture
- DIn `tmpdir` — dit is de session-versie van `tmp_path`
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf een module notes.py met een functie export_notes(notes, out_dir). Deze accepteert een lijst van dictionaries zoals {"title": "Buy pens", "done": False} en:
- Maakt
out_diraan als deze nog niet bestaat, en schrijft de notities naarout_dir / "notes.json" - Logt een
WARNINGvoor elke notitie waarvan de titel leeg is, en laat deze weg uit het bestand - Logt een
INFO-bericht"exported N notes" - Print
saved notes.jsonwanneer die klaar is
Schrijf vervolgens test_notes.py die met uitsluitend ingebouwde fixtures controleert:
- Het JSON-bestand wordt weer uitgelezen als exact de notities die geldig waren
- Een ontbrekende submap zoals
tmp_path / "a" / "b"wordt aangemaakt - De waarschuwing wordt gelogd met
levelname == "WARNING", en hetINFO-bericht wordt ook opgevangen (wat moet je eerst aanroepen?) - Precies
saved notes.json\nwordt geprint, en er gaat niets naar standard error
Voer pytest -q uit, en voer daarna ls uit (of dir op Windows). Als er een notes.json in je projectmap verschijnt, schrijft een van je tests — of je functie — naar een echt pad. Dat ontdekken en oplossen is de gewoonte waar dit hoofdstuk om draait.
Oplossing
Eerst het plan, in de vorm van de tabel uit "Voordat je de test schrijft":
| Geval | Invoer | Verwacht | |---|---|---| | Geldige notities behouden | drie notities, één met titel " " | bestand bevat de andere twee, ongewijzigd | | Ontbrekende map | tmp_path / "a" / "b" | beide mappen aangemaakt, bestand erin | | Lege titel | dezelfde drie notities | één WARNING, daarna INFO exported 2 notes | | Uitvoer | dezelfde drie notities | saved notes.json\n op stdout, niets op stderr | | Niets te exporteren | [] | bestand bevat [] |
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.pyWaarom elke beslissing is genomen:
out_diris een parameter. Dat zorgt ervoor dat elke testtmp_pathkan doorgeven; het afsluitendelstoont geennotes.jsonin het project.- De JSON-test leest het bestand weer uit met
json.loadsin plaats van tekst te vergelijken. De belofte is "deze notities, als data"; de exacte inspringing is de zaak vanjson.dumps, niet die van ons. Vergelijken metNOTES[0]enNOTES[2]bewijst bovendien dat de geldige notities ongewijzigd zijn weggeschreven. - De titel
" "bestaat uit spaties, niet"". Een titel met alleen spaties is het grensgeval — voorif not note["title"]lijkt deze niet-leeg en alleen.strip()vangt dit op. De test legt die beslissing vast. caplog.set_levelkomt eerst, en richt zich op"notes". Zonder dit wordt deINFO-regel nooit opgevangen. Het vergelijken van paren(levelname, message)controleert het niveau, de bewoording en de volgorde in één assert — zondercaplog.textaan te raken, waarvan de bestandsnamen en regelnummers de test bij elke bewerking zouden breken.err == ""wordt bewust geassert. "Er ging niets mis" maakt deel uit van het contract, en een verdwaalde debug-printnaar standard error zou er anders tussendoor glippen.- De lege lijst krijgt een eigen test. Dit is het randgeval waar een lezer van het bestand bij een nieuw account het eerst tegenaan loopt, en
[]wegschrijven in plaats van helemaal niets is een beslissing die het waard is om te behouden.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken