Fixture integrate — tmp_path, capsys e caplog
Le fixture fornite da pytest per le parti più delicate dei test: file, output a video e log. Testa il codice che scrive CSV e JSON senza toccare percorsi reali, e verifica ciò che stampa e registra nei log.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco una funzione che scrive un file CSV, e un test per verificarla:
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.pyIl test è passato, e ha lasciato qualcosa dietro di sé. report.csv si trova ora nel tuo progetto, accanto al tuo codice. Se esegui pytest da una cartella diversa, il file finirà invece lì, perché "report.csv" è relativo alla posizione in cui ti trovi in quel momento. Due test che scrivono entrambi su report.csv leggeranno i residui l'uno dell'altro. E se il tuo progetto conteneva già un vero report.csv, il test lo ha appena sovrascritto.
Un test dovrebbe lasciare il mondo esattamente come lo ha trovato. I file sono il caso più evidente, ma l'output stampato e i messaggi di log pongono la stessa questione dall'altro lato: la funzione invia qualcosa verso l'esterno, e il test deve intercettarlo.
Gli ultimi due capitoli ti hanno mostrato come scrivere le fixture. Questo capitolo è dedicato alle fixture che pytest include già per gestire questi aspetti delicati — devi solo richiederle per nome.
Alla fine di questo capitolo sarai in grado di
- Assegnare a ogni test la propria cartella vuota con
tmp_pathe testare funzioni che scrivono CSV o JSON - Condividere una sola cartella temporanea nell'arco di una sessione con
tmp_path_factory - Testare ciò che un programma stampa con
capsys, e sapere quando serve invececapfd - Testare ciò che un programma registra nei log con
caplog, compresi i messaggi al di sotto diWARNING - Usare
request.node.nameper scoprire quale test è in esecuzione - Comprendere gli errori generati da queste fixture quando vengono usate in modo scorretto
Prerequisiti: scope delle fixture e conftest.py.
Prima di scrivere il test
Il codice che interagisce con i file, il terminale o i log produce effetti collaterali: il suo vero risultato è qualcosa che lascia nel mondo circostante, non solo ciò che restituisce. Quindi, prima di scrivere qualsiasi riga di test, annota dove finisce ciascun effetto.
1. Dai un nome a ogni output. Per una funzione che esporta righe di magazzino, ce ne possono essere fino a quattro:
- il valore restituito (il percorso che ha scritto)
- il contenuto del file
- ciò che stampa, suddiviso tra standard output e standard error
- ciò che scrive nei log, e a quale livello
Ognuno di questi rappresenta una promessa separata, e ognuno deve poter essere osservato. Il valore restituito lo ottieni gratis. Per gli altri tre, pytest mette a disposizione una fixture: tmp_path per i file, capsys per il testo stampato, caplog per i log.
2. Assicurati che il codice permetta al test di scegliere dove posizionare i file. Questa è la prima domanda da farsi, perché se la risposta è no, nessuna fixture potrà salvarti. Una funzione che costruisce da sé il proprio percorso — open("/var/reports/stock.csv", "w") — può essere testata solo scrivendo in quella posizione reale. Una funzione che accetta la cartella o il percorso come parametro può essere puntata verso tmp_path. Se il codice è tuo, modificalo per accettare il percorso. (Quando non puoi modificarlo, monkeypatch nel capitolo successivo è la via di fuga).
3. Verifica la tua configurazione. Non c'è nulla da installare — ogni fixture di questo capitolo è inclusa in pytest. Ti serve il solito setup: un ambiente virtuale con pytest installato e il codice importabile dal file di test. Individua il nome del tuo logger (logging.getLogger("export")), perché caplog.set_level può indirizzarsi specificamente a esso.
4. Pianifica i casi. Per export_stock(rows, out_dir), che scrive le righe valide in stock.csv, avvisa in merito alle quantità negative e stampa un riepilogo:
| Caso | Input | Atteso | |---|---|---| | Happy path | [("pen", 12), ("eraser", 30)], tmp_path | il file ha l'intestazione + 2 righe; stampa wrote 2 rows to stock.csv | | Riga non valida | una riga con quantità -1 | riga esclusa; un WARNING che la indica | | Cartella mancante | tmp_path / "exports" / "2026" | cartelle create; file all'interno | | Nessun dato valido | [("bag", -1)] | solo intestazione; nothing exported sullo standard error | | Output pulito | qualsiasi riga valida | nulla sullo standard error |
5. Decidi cosa non testare. Non testare che il modulo csv di Python racchiuda correttamente le virgole tra virgolette, o che json.dumps produca JSON valido — quelle sono promesse di qualcun altro, già testate. Non fare asserzioni sull'intero caplog.text, che include nomi di file e numeri di riga che cambiano a ogni modifica del codice. E non testare l'esatta posizione di tmp_path; è diversa su ogni macchina. Testa le tue decisioni: quali righe vengono mantenute, cosa va dove, cosa dicono i messaggi.
Il resto del capitolo ti fornisce gli strumenti per ogni colonna di questa tabella, e l'esempio completo la implementa.
tmp_path — una nuova cartella per ogni test
Richiedi tmp_path e pytest fornirà al tuo test una directory che fino a un attimo prima non esisteva:
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 disattiva la cattura dell'output integrata in pytest in modo che le chiamate a print raggiungano il terminale. Su Windows il percorso inizia da qualche parte nella cartella Temp del tuo utente e il tipo è WindowsPath).
Tre cose da notare in questo output:
- È un
pathlib.Path, non una stringa. Si concatena con/, si legge con.read_text()e si verifica l'esistenza con.exists(). - Ogni test riceve la propria cartella, che prende il nome dal test stesso.
test_first0etest_second0non si incontrano mai, quindi entrambi possono scrivere un file chiamatoreport.csv. - Ogni esecuzione riceve una nuova cartella di base:
pytest-0, poipytest-1e così via. pytest conserva le cartelle delle ultime tre esecuzioni per permetterti di esaminarle dopo un fallimento, ed elimina da sé quelle più vecchie. Non devi mai occuparti della pulizia.
Testare una funzione che scrive un file
Riprendiamo ora il test del CSV, fatto a regola d'arte — insieme a una coppia di funzioni JSON, dato che salvare impostazioni è l'altra operazione di scrittura su file che quasi ogni programma include:
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.pyQuesta volta non è rimasto alcun residuo. Il pattern è sempre lo stesso: costruisci un percorso all'interno di tmp_path, passalo al codice sotto test, poi rileggi il file e verificalo.
Ci sono due modi per eseguire la verifica, e i test sopra li usano entrambi. test_write_csv controlla il testo esatto — il test più rigoroso, e quello più adatto quando il file dovrà essere letto da un altro programma. test_csv_reads_back e test_settings_round_trip rileggono il file nel modo in cui lo farebbe un lettore reale e confrontano i dati. Nota che DictReader ha restituito "12", una stringa: i CSV non contengono numeri, solo testo. Un test di round-trip è proprio il luogo in cui emergono sorprese di questo tipo.
tmp_path parte vuota. Se il tuo codice si aspetta che una sottocartella esista già, creala nel test con (tmp_path / "exports").mkdir() oppure — preferibilmente — fai in modo che sia il codice a crearla. Vedrai cosa succede quando nessuno lo fa nella sezione "Quando qualcosa va storto".
tmp_path_factory — una sola cartella per l'intera sessione
tmp_path ha scope di funzione: una nuova cartella per ogni test. A volte questo è uno spreco. Supponi che diversi test debbano leggere un file di esempio di grandi dimensioni che richiede tempo per essere creato. Nel capitolo precedente avresti usato scope="session" — ma una fixture di sessione non può usare tmp_path, perché una fixture che dura per l'intera sessione non può dipendere da qualcosa che viene eliminato dopo ogni singolo test.
Per questo scopo esiste tmp_path_factory, che ha a sua volta scope di sessione. Le richiedi le cartelle tramite 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 compare una sola volta per due test. mktemp("data") aggiunge un numero al nome — data0, e data1 se lo richiami di nuovo — così due chiamate non entrano mai in conflitto.
Il compromesso è quello visto nel capitolo precedente: entrambi i test ora condividono lo stesso file. Qui va benissimo perché si limitano a leggerlo. Un test che modifica un file condiviso ha bisogno di una copia propria in tmp_path.
capsys — testare ciò che un programma stampa
Iniziamo con l'errore che quasi tutti fanno almeno una volta. Una funzione stampa un saluto; il test ne controlla il risultato:
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 invia testo; non lo restituisce, quindi greet restituisce None. Guarda però in fondo al report: Captured stdout call — pytest ha già intercettato il testo. Lo fa per ogni test, per mantenere ordinato il terminale. capsys è lo strumento che ti permette di metterci le mani sopra:
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.07sChiama prima la funzione, poi leggi ciò che ha stampato. I veri programmi a riga di comando stampano più di una riga, su due flussi diversi, e restituiscono un codice di uscita. Eccone uno, corredato di test:
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() restituisce tutto ciò che è stato scritto sullo standard output e sullo standard error dall'ultima volta che lo hai richiesto, sotto forma di oggetto con due campi stringa, .out ed .err. È una named tuple, quindi funziona anche out, err = capsys.readouterr().
Due dettagli contano nella pratica:
printaggiunge un carattere di a capo. Il testo catturato è"pen: 12 in stock\n", non"pen: 12 in stock". Per più righe,.splitlines()fornisce una lista pulita da confrontare.- Gli errori vanno su
err. Un messaggio informativo sull'utilizzo appartiene allo standard error, etest_usage_goes_to_stderrverifica sia che sia finito lì, sia che nulla sia stato inviato allo standard output.
"Dall'ultima volta che lo hai richiesto" va inteso alla lettera — la lettura svuota il buffer:
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.07sQuesto è utile: puoi verificare l'output passo dopo passo. Ma è anche il motivo per cui una seconda chiamata a readouterr() a volte restituisce una stringa vuota quando non te lo aspetti.
capfd — quando l'output non passa attraverso Python
capsys sostituisce i flussi sys.stdout e sys.stderr di Python. Tutto ciò che scrive direttamente nell'output del sistema operativo — un processo figlio, un'estensione in C — li aggira. capfd cattura a un livello più profondo, a livello di file descriptor, intercettando anche quell'output:
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.07sStesso metodo readouterr(), stessi campi .out ed .err. Usa capsys come scelta predefinita e passa a capfd solo quando manca dell'output proveniente dall'esterno di Python.
caplog — testare ciò che un programma scrive nei log
I programmi reali usano i log invece di stampare a video. Un avviso nel log è un comportamento come un altro, e come tale merita un 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 ti fornisce lo stesso log in tre forme diverse:
caplog.records— una lista di oggettilogging.LogRecord, uno per ciascun messaggio. Ognuno possiede.levelname("WARNING"),.name(il logger,"importer") e.getMessage(), che sostituisce i placeholder come%rcon il relativo valore.caplog.messages— semplicemente le stringhe dei messaggi finali, sotto forma di lista.caplog.text— l'intero output come una singola stringa formattata. Utile per una rapida verifica conin; troppo poco restrittivo per controlli di precisione.
Prediligi records quando il livello è importante e messages quando conta soltanto il testo del messaggio.
I messaggi al di sotto di WARNING non vengono catturati per impostazione predefinita
parse_quantities registra anche un riepilogo a livello INFO. Aggiungiamo un test per verificarlo:
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.08sNon è stato catturato nulla. La funzione ha effettivamente registrato nel log — ma il logging di Python parte da WARNING, quindi il messaggio INFO è stato scartato prima ancora che caplog potesse vederlo. Abbassa il livello per il test con 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.07sChiama set_level prima del codice che scrive nei log. La seconda forma, con logger="importer", abbassa il livello solo per quello specifico logger, così le librerie troppo prolisse non interferiscono. In entrambi i casi pytest ripristina il livello precedente al termine del test.
request e pytestconfig
Un'altra fixture integrata compare spesso all'interno di altre fixture. request descrive il test che ha richiesto la fixture; request.node.name è il nome di quel test. Questo è comodo per dare ai file nomi leggibili e univoci:
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.07sIl nome di un test parametrizzato include i suoi parametri tra parentesi quadre. E l'ultimo test mostra pytestconfig: l'oggetto di configurazione dell'esecuzione, che contiene la cartella radice del progetto e tutte le opzioni da riga di comando (-q imposta la verbosità a -1). Lo incontrerai di nuovo nei capitoli dedicati alla configurazione e ai plugin.
Un esempio completo
export.py scrive le righe valide di magazzino in un CSV, registra un avviso nei log per ogni riga scartata e stampa un riepilogo:
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 verifica tutti e tre i suoi output — il file, il log e il terminale:
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.pyTre cose meritano attenzione.
Primo: export_stock accetta la cartella come parametro. Questa singola decisione è ciò che la rende testabile: il programma passa una cartella reale, i test passano tmp_path. Una funzione che inserisce "/var/reports/stock.csv" direttamente nel codice può essere testata solo scrivendo su quel percorso reale — che è esattamente ciò che un test non deve mai fare.
Secondo: ogni test richiede solo le fixture che verifica. Due di essi combinano tmp_path con capsys o caplog; le fixture integrate possono essere combinate liberamente tra loro e con le tue.
Terzo: la working directory non ha subito alcuna modifica dopo cinque test che hanno tutti scritto file.
Quando qualcosa va storto
fixture 'tmp_dir' not found Un errore di battitura nel nome del parametro. pytest associa le fixture in base al nome esatto, e il messaggio di errore prosegue stampando available fixtures: con tutte quelle che conosce — tmp_path, tmp_path_factory, capsys, capfd, caplog e le altre. Copia il nome corretto da quell'elenco. (Troverai lì anche tmpdir: è la versione più vecchia che restituisce un oggetto py.path invece di un pathlib.Path. Usa tmp_path nel codice nuovo).
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. Una fixture con scope="session" (o "module") ha richiesto tmp_path. Una fixture a lunga durata non può dipendere da una fixture limitata al singolo test. Usa invece 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 esiste, ma la cartella exports al suo interno non esiste — nessuno crea le cartelle al posto tuo. Chiama prima .mkdir(parents=True, exist_ok=True) sul percorso genitore, preferibilmente all'interno del codice sotto test, come fa export_stock.
Un file compare in /tmp/pytest-of-you/pytest-5/test_str0report.csv Nessun errore evidente — il percorso è stato costruito usando stringhe: str(tmp_path) + "report.csv". Il separatore è andato perso, quindi il file è finito accanto alla cartella del test invece che al suo interno. Esegui l'unione usando / direttamente sull'oggetto Path: tmp_path / "report.csv".
AssertionError: assert [] == ['parsed 1 of 1 lines'] con caplog Il messaggio è stato registrato a un livello inferiore a WARNING e non è mai stato catturato. Chiama caplog.set_level(logging.INFO) — o logging.DEBUG — prima dell'esecuzione del codice.
capsys.readouterr().out è "" quando ti aspettavi del testo L'output è già stato consumato da una precedente chiamata a readouterr() nello stesso test, oppure non è passato attraverso il sys.stdout di Python — ad esempio una chiamata a un sottoprocesso o a os.system. Nel secondo caso, passa a capfd.
cannot use capfd and capsys at the same time Un test ha richiesto entrambe. Entrambe catturano gli stessi flussi a livelli differenti; scegline una sola.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Dopo l'ultima riga, cosa c'è esattamente 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 scrive nei log, eppure il test fallisce con assert [] == ['restocked pen']. Perché?
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"]- AIl `%s` non viene mai sostituito, quindi il messaggio è `'restocked %s'`
- B`caplog.messages` include il nome del livello, quindi confronta con `'INFO restocked pen'`
- C`INFO` è al di sotto del livello predefinito `WARNING`, quindi non è stato catturato nulla; chiama prima `caplog.set_level(logging.INFO)`
- DIl logger si chiama `"shop"`; `caplog` ascolta solo il root logger
Una fixture con scope="session" deve creare una sola volta un file di esempio di grandi dimensioni. Dove deve andare il file?
- ANella cartella restituita da `tmp_path_factory.mktemp("data")`
- BIn una cartella `data/` all'interno del progetto, così sopravvive fino all'esecuzione successiva
- CIn `tmp_path` — basta richiederla nei parametri della fixture
- DIn `tmpdir` — è la versione di sessione di `tmp_path`
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Scrivi un modulo notes.py con una funzione export_notes(notes, out_dir). La funzione accetta una lista di dizionari come {"title": "Buy pens", "done": False} e:
- Crea
out_dirse non esiste, e scrive le note inout_dir / "notes.json" - Registra un
WARNINGnel log per qualsiasi nota il cui titolo sia vuoto, e la esclude dal file - Registra un messaggio
INFO"exported N notes" - Stampa
saved notes.jsonal termine
Poi scrivi test_notes.py che verifichi, usando esclusivamente fixture integrate:
- Il file JSON riletto contiene esattamente le note che erano valide
- Una sottocartella mancante come
tmp_path / "a" / "b"viene creata - L'avviso viene registrato con
levelname == "WARNING", e viene catturato anche il messaggioINFO(cosa devi chiamare prima?) - Viene stampato esattamente
saved notes.json\n, e nulla viene inviato allo standard error
Esegui pytest -q, poi esegui ls (o dir su Windows). Se compare un file notes.json nella cartella del tuo progetto, uno dei tuoi test — o la tua funzione — sta scrivendo su un percorso reale. Individuare questo comportamento e correggerlo è l'abitudine al centro di questo capitolo.
Soluzione
Innanzitutto il piano, sotto forma di tabella come in "Prima di scrivere il test":
| Caso | Input | Atteso | |---|---|---| | Note valide mantenute | tre note, una con titolo " " | il file contiene le altre due, invariate | | Cartella mancante | tmp_path / "a" / "b" | entrambe le cartelle create, file all'interno | | Titolo vuoto | le stesse tre note | un WARNING, poi INFO exported 2 notes | | Output | le stesse tre note | saved notes.json\n su stdout, nulla su stderr | | Nulla da esportare | [] | il file contiene [] |
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.pyIl motivo di ciascuna decisione:
out_dirè un parametro. È ciò che permette a ogni test di passaretmp_path; il comandolsfinale mostra che non c'è alcunnotes.jsonnel progetto.- Il test del JSON rilegge il file con
json.loadsinvece di confrontare il testo. La promessa è "queste note, come dati"; l'indentazione esatta riguardajson.dumps, non noi. Il confronto conNOTES[0]eNOTES[2]dimostra inoltre che le note valide sono state scritte invariate. - Il titolo
" "è composto da spazi, non da"". Un titolo composto solo da spazi è il caso limite — appare non vuoto aif not note["title"]e solo.strip()riesce a intercettarlo. Il test fissa questa decisione in modo preciso. caplog.set_levelviene chiamato per primo e ha come target"notes". Senza di esso la rigaINFOnon viene mai catturata. Il confronto tra coppie(levelname, message)verifica il livello, il testo e l'ordine in un unico assert — senza toccarecaplog.text, i cui nomi di file e numeri di riga romperebbero il test a ogni modifica del codice.err == ""viene verificato intenzionalmente con un assert. "Nulla è andato storto" fa parte del contratto, e unaprintdi debug dimenticata sullo standard error altrimenti passerebbe inosservata.- La lista vuota riceve un test dedicato. È il caso limite che chi legge il file incontrerà per primo su un nuovo account, e scrivere
[]invece di non scrivere assolutamente nulla è una decisione che vale la pena mantenere.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz