Fixtures intégrées — tmp_path, capsys et caplog
Les fixtures fournies par pytest pour les aspects délicats des tests : fichiers, sorties affichées et logs. Testez du code écrivant du CSV et du JSON sans toucher à de vrais chemins, et vérifiez ce qu'il affiche et consigne dans les logs.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici une fonction qui écrit un fichier CSV, ainsi qu'un test pour celle-ci :
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.pyLe test a réussi, et il a laissé une trace derrière lui. report.csv se trouve désormais dans votre projet, à côté de votre code. Exécutez pytest depuis un autre dossier et le fichier atterrira là-bas à la place, car "report.csv" est relatif à l'endroit où vous vous trouviez. Deux tests écrivant tous deux report.csv liront les restes l'un de l'autre. Et si votre projet contenait déjà un vrai fichier report.csv, le test vient de l'écraser.
Un test devrait laisser le monde exactement dans l'état où il l'a trouvé. Les fichiers en sont l'exemple le plus évident, mais la sortie affichée et les messages de log posent la même question sous un autre angle : la fonction envoie quelque chose vers l'extérieur, et le test doit l'intercepter.
Les deux derniers chapitres vous ont montré comment écrire des fixtures. Celui-ci est consacré aux fixtures que pytest fournit déjà pour ces aspects délicats — il vous suffit de les demander par leur nom.
À la fin de ce chapitre, vous saurez
- Donner à chaque test son propre dossier vide avec
tmp_path, et tester des fonctions qui écrivent du CSV ou du JSON - Partager un dossier temporaire sur toute une session avec
tmp_path_factory - Tester ce qu'un programme affiche avec
capsys, et savoir quandcapfdest nécessaire à la place - Tester ce qu'un programme consigne dans les logs avec
caplog, y compris les messages sous le niveauWARNING - Utiliser
request.node.namepour savoir quel test est en cours d'exécution - Comprendre les erreurs que ces fixtures produisent lorsqu'elles sont mal utilisées
Prérequis : portée des fixtures et conftest.py.
Avant d'écrire le test
Le code qui touche aux fichiers, au terminal ou aux logs a des effets de bord : son résultat réel est quelque chose qu'il laisse dans le monde, et pas seulement ce qu'il renvoie. Ainsi, avant d'écrire la moindre ligne de code de test, notez où va chaque effet.
1. Nommez chaque sortie. Pour une fonction qui exporte des lignes d'inventaire, il y en a jusqu'à quatre :
- la valeur de retour (le chemin qu'elle a écrit)
- le contenu du fichier
- ce qu'elle affiche, réparti entre sortie standard et erreur standard
- ce qu'elle consigne dans les logs, et à quel niveau
Chacune est une promesse distincte, et chacune nécessite un moyen d'être observée. La valeur de retour, vous l'obtenez sans effort. Pour les trois autres, pytest dispose d'une fixture : tmp_path pour les fichiers, capsys pour le texte affiché, caplog pour les logs.
2. Assurez-vous que le code permette à un test de choisir où vont les fichiers. C'est la première question à poser, car si la réponse est non, aucune fixture ne pourra vous sauver. Une fonction qui construit son propre chemin — open("/var/reports/stock.csv", "w") — ne peut être testée qu'en écrivant dans cet emplacement réel. Une fonction qui prend le dossier ou le chemin en paramètre peut être dirigée vers tmp_path. Si vous êtes propriétaire du code, modifiez-le pour qu'il prenne le chemin. (Quand vous ne pouvez pas le modifier, monkeypatch, présenté au chapitre suivant, constitue la porte de sortie.)
3. Vérifiez votre environnement. Rien à installer — chaque fixture de ce chapitre est fournie avec pytest. Vous avez besoin de la configuration habituelle : un environnement virtuel contenant pytest, et le code importable depuis le fichier de test. Retenez le nom de votre logger (logging.getLogger("export")), car caplog.set_level peut le cibler.
4. Planifiez les cas. Pour export_stock(rows, out_dir), qui écrit les lignes valides dans stock.csv, avertit pour les quantités négatives et affiche un résumé :
| Cas | Entrée | Résultat attendu | |---|---|---| | Cas nominal | [("pen", 12), ("eraser", 30)], tmp_path | le fichier contient l'en-tête + 2 lignes ; affiche wrote 2 rows to stock.csv | | Ligne invalide | une ligne avec une quantité de -1 | ligne ignorée ; un WARNING la nommant | | Dossier manquant | tmp_path / "exports" / "2026" | dossiers créés ; fichier à l'intérieur | | Rien de valide | [("bag", -1)] | en-tête uniquement ; nothing exported sur l'erreur standard | | Sortie propre | n'importe quelles lignes valides | rien sur l'erreur standard |
5. Décidez de ce qu'il ne faut pas tester. Ne testez pas que le module csv de Python échappe correctement les virgules, ni que json.dumps produit du JSON valide — ce sont les promesses de quelqu'un d'autre, déjà testées. Ne faites pas d'assertion sur la totalité de caplog.text, qui inclut des noms de fichiers et des numéros de lignes qui changent dès que vous modifiez le code. Et ne testez pas l'emplacement exact de tmp_path ; il est différent sur chaque machine. Testez vos décisions : quelles lignes sont conservées, qu'est-ce qui va où, que disent les messages.
Le reste du chapitre vous donne les outils pour chaque colonne de ce tableau, et l'exemple complet le met en œuvre.
tmp_path — un dossier tout neuf pour chaque test
Demandez tmp_path et pytest remet à votre test un répertoire qui n'existait pas un instant plus tôt :
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 désactive la capture de sortie propre à pytest afin que les appels à print atteignent le terminal. Sous Windows, le chemin commence quelque part dans le dossier Temp de votre utilisateur et le type est WindowsPath.)
Trois éléments sont à observer dans cette sortie :
- C'est un
pathlib.Path, pas une chaîne. Vous combinez avec/, lisez avec.read_text()et vérifiez avec.exists(). - Chaque test reçoit son propre dossier, nommé d'après le test.
test_first0ettest_second0ne se rencontrent jamais, tous deux peuvent donc écrire un fichier nomméreport.csv. - Chaque exécution reçoit un nouveau dossier de base :
pytest-0, puispytest-1, et ainsi de suite. pytest conserve les dossiers des trois dernières exécutions afin que vous puissiez regarder à l'intérieur après un échec, et supprime lui-même les plus anciens. Vous n'avez jamais rien à nettoyer.
Tester une fonction qui écrit un fichier
Reprenons à présent le test CSV, réalisé correctement — accompagné d'une paire de fonctions JSON, la sauvegarde des paramètres étant l'autre tâche d'écriture de fichiers que l'on retrouve dans presque tous les programmes :
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.pyRien n'a été laissé derrière cette fois-ci. Le schéma est toujours le même : construire un chemin dans tmp_path, le transmettre au code testé, puis relire le fichier et le vérifier.
Il existe deux manières d'effectuer la vérification, et les tests ci-dessus emploient les deux. test_write_csv vérifie le texte exact — le test le plus strict, et le plus adapté lorsqu'un autre programme doit lire le fichier. test_csv_reads_back et test_settings_round_trip relisent le fichier de la même manière qu'un vrai lecteur le ferait et comparent les données. Remarquez que DictReader a renvoyé "12", une chaîne : le CSV ne contient aucun nombre, uniquement du texte. C'est dans un test d'aller-retour (round-trip) que ce genre de surprise apparaît.
tmp_path démarre vide. Si votre code s'attend à ce qu'un sous-dossier existe, vous devez soit le créer dans le test avec (tmp_path / "exports").mkdir(), soit — et c'est encore mieux — faire en sorte que le code le crée lui-même. Vous verrez ce qui arrive quand personne ne le fait dans « En cas de problème ».
tmp_path_factory — un dossier pour toute la session
tmp_path a une portée de fonction : un nouveau dossier pour chaque test. C'est parfois du gaspillage. Supposons que plusieurs tests lisent un gros fichier d'exemple dont la création prend du temps. Au chapitre précédent, vous auriez utilisé scope="session" — or une fixture de session ne peut pas utiliser tmp_path, car une fixture qui vit durant toute la session ne peut pas dépendre d'un élément détruit après chaque test.
C'est là qu'intervient tmp_path_factory, qui a elle-même une portée de session. Vous lui demandez des dossiers au moyen de 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 n'apparaît qu'une seule fois, pour deux tests. mktemp("data") ajoute un numéro au nom — data0, puis data1 si vous l'appelez à nouveau — ainsi deux appels n'entrent jamais en collision.
Le compromis est le même qu'au chapitre précédent : les deux tests partagent désormais un seul fichier. Cela ne pose aucun problème ici, puisqu'ils se contentent de le lire. Un test qui modifie un fichier partagé a besoin de sa propre copie dans tmp_path.
capsys — tester ce qu'un programme affiche
Commençons par l'erreur que presque tout le monde commet au moins une fois. Une fonction affiche un message de salutation ; le test vérifie son résultat :
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 envoie du texte ; il ne le renvoie pas, donc greet renvoie None. Regardez toutefois le bas du rapport : Captured stdout call — pytest a déjà intercepté le texte. Il fait cela pour chaque test, afin de garder le terminal propre. capsys est le moyen qui vous permet de mettre la main dessus :
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.07sAppelez d'abord la fonction, puis lisez ce qu'elle a affiché. Les vrais programmes en ligne de commande affichent plus d'une ligne, sur deux flux distincts, et renvoient un code de sortie. En voici un, accompagné de ses 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() renvoie tout ce qui a été écrit sur la sortie standard et l'erreur standard depuis la dernière fois que vous l'avez demandé, sous la forme d'un objet doté de deux champs de type chaîne, .out et .err. Il s'agit d'un tuple nommé (named tuple), donc out, err = capsys.readouterr() fonctionne également.
Deux détails ont leur importance en pratique :
printajoute un saut de ligne. Le texte capturé est"pen: 12 in stock\n", et non"pen: 12 in stock". Pour plusieurs lignes,.splitlines()donne une liste propre à comparer.- Les erreurs vont dans
err. Un message d'utilisation (usage) a sa place sur l'erreur standard, ettest_usage_goes_to_stderrvérifie à la fois qu'il y est bien allé et que rien n'a été envoyé sur la sortie standard.
« Depuis la dernière fois que vous l'avez demandé » est à prendre au sens littéral — la lecture vide le tampon :
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.07sC'est très utile : vous pouvez vérifier la sortie étape par étape. C'est aussi la raison pour laquelle un second appel à readouterr() revient parfois vide alors que vous ne vous y attendiez pas.
capfd — quand la sortie ne passe pas par Python
capsys remplace sys.stdout et sys.stderr de Python. Tout ce qui écrit directement dans la sortie du système d'exploitation — un processus fils, une extension C — le contourne. capfd effectue sa capture un niveau plus bas, au niveau du descripteur de fichier, et intercepte cela également :
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.07sMême méthode readouterr(), mêmes attributs .out et .err. Utilisez capsys par défaut et ne passez à capfd que lorsqu'une sortie issue de l'extérieur de Python manque à l'appel.
caplog — tester ce qu'un programme consigne dans les logs
Les vrais programmes utilisent les logs plutôt que d'afficher du texte. Un avertissement dans les logs constitue un comportement comme un autre, et mérite donc 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 met à votre disposition les mêmes logs de trois manières :
caplog.records— une liste d'objetslogging.LogRecord, un par message. Chacun possède.levelname("WARNING"),.name(le logger,"importer") et.getMessage(), qui remplace le%rpar sa valeur.caplog.messages— simplement les chaînes des messages finaux, sous forme de liste.caplog.text— l'ensemble sous la forme d'une unique chaîne formatée. Pratique pour un test rapide avecin; trop imprécis pour une vérification exacte.
Privilégiez records quand le niveau importe, et messages quand seule la formulation compte.
Les messages sous WARNING ne sont pas capturés par défaut
parse_quantities consigne également un résumé au niveau INFO. Ajoutons un test pour le vérifier :
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.08sRien n'a été capturé. La fonction a bien consigné son message — mais le logging de Python commence à WARNING, le message INFO a donc été ignoré avant même que caplog ne puisse le voir. Abaissez le niveau pour le test avec 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.07sAppelez set_level avant le code qui consigne les logs. La seconde forme, avec logger="importer", n'abaisse le niveau que pour ce logger spécifique, évitant ainsi que des bibliothèques trop bavardes ne polluent la sortie. Dans les deux cas, pytest rétablit l'ancien niveau à la fin du test.
request et pytestconfig
Une autre fixture intégrée apparaît souvent à l'intérieur d'autres fixtures. request décrit le test qui a demandé la fixture ; request.node.name correspond au nom de ce test. C'est bien pratique pour donner aux fichiers des noms lisibles et uniques :
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.07sLe nom d'un test paramétré inclut ses paramètres entre crochets. Et le dernier test montre pytestconfig : l'objet de configuration de l'exécution, contenant le dossier racine du projet ainsi que chaque option de la ligne de commande (-q fixe la verbosité à -1). Vous le retrouverez dans les chapitres consacrés à la configuration et aux plugins.
Un exemple complet
export.py écrit les lignes d'inventaire valides dans un fichier CSV, consigne un avertissement dans les logs pour chaque ligne ignorée et affiche un résumé :
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 vérifie l'ensemble de ses trois sorties — le fichier, les logs et le 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.pyTrois points méritent d'être soulignés.
Premièrement, export_stock prend le dossier comme paramètre. C'est cette décision unique qui la rend testable : le programme lui passe un vrai dossier, les tests lui passent tmp_path. Une fonction qui code en dur "/var/reports/stock.csv" en son sein ne peut être testée qu'en écrivant dans ce vrai chemin — ce qu'un test ne doit précisément jamais faire.
Deuxièmement, chaque test ne demande que les fixtures qu'il vérifie. Deux d'entre eux combinent tmp_path avec capsys ou caplog ; les fixtures intégrées se mélangent sans problème entre elles ainsi qu'avec les vôtres.
Troisièmement, le répertoire de travail reste inchangé après cinq tests qui ont pourtant tous écrit des fichiers.
En cas de problème
fixture 'tmp_dir' not found Une faute de frappe dans le nom du paramètre. pytest associe les fixtures par leur nom exact, et l'erreur continue en affichant available fixtures: avec toutes celles qu'il connaît — tmp_path, tmp_path_factory, capsys, capfd, caplog et les autres. Copiez le nom correct depuis cette liste. (Vous y verrez également tmpdir ; il s'agit de l'ancienne version qui renvoie un objet py.path au lieu d'un pathlib.Path. Utilisez tmp_path dans le nouveau code.)
ScopeMismatch: You tried to access the function scoped fixture tmp_path with a session scoped request object. Une fixture avec scope="session" (ou "module") a demandé tmp_path. Une fixture de longue durée ne peut pas dépendre d'une fixture propre à chaque test. Utilisez plutôt 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 existe, mais le dossier exports à l'intérieur n'existe pas — personne ne crée de dossiers à votre place. Appelez d'abord .mkdir(parents=True, exist_ok=True) sur le dossier parent, idéalement au sein du code testé, comme le fait export_stock.
Un fichier apparaît à /tmp/pytest-of-you/pytest-5/test_str0report.csv Aucune erreur du tout — le chemin a été construit avec des chaînes de caractères : str(tmp_path) + "report.csv". Le séparateur est passé à la trappe, le fichier a donc atterri à côté du dossier du test au lieu d'être à l'intérieur. Concaténez avec / sur le Path lui-même : tmp_path / "report.csv".
AssertionError: assert [] == ['parsed 1 of 1 lines'] avec caplog Le message a été consigné sous le niveau WARNING et n'a jamais été capturé. Appelez caplog.set_level(logging.INFO) — ou logging.DEBUG — avant l'exécution du code.
capsys.readouterr().out vaut "" alors que vous attendiez du texte Soit la sortie a déjà été consommée par un appel antérieur à readouterr() dans le même test, soit elle n'est pas passée par le sys.stdout de Python — par exemple un sous-processus ou un appel à os.system. Dans ce second cas, passez à capfd.
cannot use capfd and capsys at the same time Un test a demandé les deux. Elles capturent les mêmes flux à des niveaux différents ; choisissez-en une seule.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Après la dernière ligne, que contient exactement 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 écrit bien dans les logs, pourtant le test échoue avec assert [] == ['restocked pen']. Pourquoi ?
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"]- ALe `%s` n'est jamais remplacé, le message est donc `'restocked %s'`
- B`caplog.messages` inclut le nom du niveau, il faut donc comparer avec `'INFO restocked pen'`
- C`INFO` est sous le niveau par défaut `WARNING`, rien n'a donc été capturé ; appelez d'abord `caplog.set_level(logging.INFO)`
- DLe logger se nomme `"shop"` ; `caplog` n'écoute que le logger racine
Une fixture avec scope="session" souhaite créer un gros fichier d'exemple une seule fois. Où ce fichier doit-il être placé ?
- ADans le dossier renvoyé par `tmp_path_factory.mktemp("data")`
- BDans un dossier `data/` au sein du projet, pour qu'il survive jusqu'à la prochaine exécution
- CDans `tmp_path` — demandez-la simplement dans les paramètres de la fixture
- DDans `tmpdir` — c'est la version session de `tmp_path`
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Écrivez un module notes.py avec une fonction export_notes(notes, out_dir). Elle prend une liste de dictionnaires tels que {"title": "Buy pens", "done": False} et :
- Crée
out_dirs'il n'existe pas, et écrit les notes dansout_dir / "notes.json" - Consigne un
WARNINGdans les logs pour toute note dont le titre est vide, et l'exclut du fichier - Consigne un message
INFO"exported N notes"dans les logs - Affiche
saved notes.jsonlorsqu'elle se termine
Écrivez ensuite test_notes.py qui vérifie, en utilisant uniquement des fixtures intégrées :
- Le fichier JSON relu correspond exactement aux notes qui étaient valides
- Un sous-dossier manquant tel que
tmp_path / "a" / "b"est bien créé - L'avertissement est consigné avec
levelname == "WARNING", et le messageINFOest également capturé (que devez-vous appeler en premier ?) - Exactement
saved notes.json\nest affiché, et rien n'est envoyé sur l'erreur standard
Exécutez pytest -q, puis lancez ls (ou dir sous Windows). Si un fichier notes.json apparaît dans le dossier de votre projet, l'un de vos tests — ou votre fonction — écrit dans un vrai chemin. Détecter cela et le corriger est le réflexe que ce chapitre vise à installer.
Solution
Commençons par le plan, sous la forme du tableau présenté dans « Avant d'écrire le test » :
| Cas | Entrée | Résultat attendu | |---|---|---| | Notes valides conservées | trois notes, dont une avec le titre " " | le fichier contient les deux autres, inchangées | | Dossier manquant | tmp_path / "a" / "b" | les deux dossiers sont créés, le fichier à l'intérieur | | Titre vide | les trois mêmes notes | un WARNING, puis INFO exported 2 notes | | Sortie | les trois mêmes notes | saved notes.json\n sur stdout, rien sur stderr | | Rien à exporter | [] | le fichier contient [] |
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.pyLa raison de chaque décision :
out_direst un paramètre. C'est ce qui permet à chaque test de passertmp_path; lelsfinal ne montre aucunnotes.jsondans le projet.- Le test JSON relit le fichier avec
json.loadsplutôt que de comparer du texte. La promesse est « ces notes, en tant que données » ; l'indentation exacte relève de la responsabilité dejson.dumps, pas de la nôtre. Comparer avecNOTES[0]etNOTES[2]prouve également que les notes valides ont été écrites sans modification. - Le titre
" "est composé d'espaces, et non de"". Un titre ne contenant que des espaces représente le cas limite — il semble non vide pourif not note["title"], et seul.strip()le détecte. Le test verrouille cette décision. caplog.set_levelest appelé en premier et cible"notes". Sans cela, la ligneINFOn'est jamais capturée. Comparer des paires(levelname, message)permet de vérifier le niveau, le texte et l'ordre en une seule assertion — sans toucher àcaplog.text, dont les noms de fichiers et les numéros de lignes casseraient le test à chaque modification du code.err == ""fait l'objet d'une assertion délibérée. « Rien ne s'est mal passé » fait partie du contrat, et unprintde débogage parasite sur l'erreur standard passerait inaperçu sans cela.- La liste vide a droit à son propre test. C'est le cas particulier auquel un lecteur du fichier sera confronté en premier sur un nouveau compte, et écrire
[]plutôt que de ne rien écrire du tout est une décision qu'il convient de maintenir.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz