Fixtures — une préparation écrite une seule fois
Cessez de répéter la préparation : écrivez-la une seule fois avec @pytest.fixture, demandez-la par son nom, obtenez une copie neuve pour chaque test et nettoyez avec yield même si un test échoue.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici un petit panier d'achat, cart.py :
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self.lines)
def count(self):
return sum(quantity for _, _, quantity in self.lines)Et trois tests pour l'accompagner, test_cart.py :
from cart import Cart
def test_total():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.total() == 895.0
def test_count():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.count() == 4
def test_add_one_more():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sIls réussissent, mais ils sont fastidieux à lire. Trois tests, neuf lignes de préparation, trois lignes qui vérifient réellement quelque chose. La préparation est identique dans chacun d'eux, de sorte que la seule ligne qui diffère — celle pour laquelle le test existe — est enfouie.
Cela a aussi un coût pour la suite. Lorsque Cart() aura besoin d'un argument, vous devrez le modifier à trois endroits, ou à trente. Au chapitre précédent, parametrize éliminait la répétition dans les entrées d'un test. Ce chapitre élimine la répétition dans sa préparation.
À la fin de ce chapitre, vous saurez
- Écrire une fixture avec
@pytest.fixtureet la demander en la nommant comme paramètre de test - Expliquer pourquoi chaque test reçoit sa propre copie neuve, et le démontrer à l'aide d'une liste
- Construire une fixture à partir d'une autre
- Écrire une fixture
yieldqui effectue son propre nettoyage, et prédire l'ordre de préparation et de nettoyage - Observer la création des fixtures avec
--setup-show - Comprendre
fixture 'x' not foundet l'erreur « called directly » - Choisir entre une fixture et une simple fonction utilitaire
Prérequis : exécuter un test sur plusieurs cas avec `parametrize`.
Avant d'écrire le test
Les fixtures ne sont pas quelque chose que l'on ajoute aux tests après coup. Elles découlent d'un plan, et ce plan vient en premier.
Ce qui est promis. Cart promet trois choses : add() enregistre une ligne et refuse une quantité inférieure à 1 avec une ValueError ; total() est la somme des prix × quantités ; count() est la somme des quantités. Pas de fichiers, pas de réseau, pas d'état global — jusqu'ici rien qui n'ait besoin d'être annulé.
Ce qui doit être en place. Comme pour chaque chapitre : un environnement virtuel avec pytest installé, et cart.py importable depuis le dossier dans lequel vous exécutez pytest (placez le fichier de test à côté).
Quels cas. Notez-les par écrit, en ajoutant une colonne que l'on oublie souvent — l'état initial dont part le test :
| Cas | État initial | Action | Attendu | |---|---|---|---| | Total normal | pen × 3, bag × 1 | total() | 895.0 | | Nombre d'articles | pen × 3, bag × 1 | count() | 4 | | Une ligne de plus | pen × 3, bag × 1 | add("notebook", 60.0, 2) | total() vaut 1015.0 | | Cas limite : panier vide | panier vide | total() | 0 | | Quantité invalide | panier vide | add("pen", 15.0, 0) | ValueError |
Lisez la deuxième colonne de haut en bas. Trois lignes partent du même panier rempli, deux d'un panier vide. Chaque valeur répétée dans cette colonne est une fixture qui attend d'être écrite — ici, empty_cart et une fixture cart construite à partir d'elle. Et une ligne qui part d'un état que personne d'autre ne partage n'a pas du tout besoin de fixture ; elle construit le sien.
Posez-vous une question supplémentaire pour chaque état initial : le créer change-t-il quoi que ce soit en dehors du test ? Un nouveau Cart ne change rien. Un code de réduction enregistré dans un dictionnaire global, une connexion ouverte, un fichier écrit — eux, oui, et c'est ce qui vous indique qu'une fixture a besoin d'un nettoyage (yield, plus loin dans ce chapitre).
Ce qu'il ne faut pas tester. N'écrivez pas de tests pour prouver que pytest construit une fixture toute neuve pour chaque test ; c'est la promesse de pytest, et ce chapitre vous le montre une fois pour que vous puissiez lui faire confiance. Testez en revanche votre propre nettoyage lorsqu'il rétablit un état global — ce code est le vôtre, et il peut contenir des erreurs.
Le reste du chapitre transforme ce plan en code.
Une première fixture
Déplacez la préparation dans une fonction et placez @pytest.fixture au-dessus d'elle :
import pytest
from cart import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("pen", 15.0, 3)
c.add("bag", 850.0)
return c
def test_total(cart):
assert cart.total() == 895.0
def test_count(cart):
assert cart.count() == 4
def test_add_one_more(cart):
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sRien dans les tests n'appelle cart(). Chaque test possède simplement un paramètre nommé cart, et c'est là toute la demande.
Avant d'exécuter un test, pytest examine le nom de ses paramètres. Pour chaque nom, il cherche une fixture portant ce nom, l'exécute, et transmet la valeur qu'elle a renvoyée comme argument. Le test indique ce dont il a besoin ; il ne le construit pas lui-même. Cette organisation porte un nom — l'injection de dépendances — mais le mécanisme ne fait rien d'autre que « faire correspondre le nom du paramètre au nom d'une fixture ».
Le test se lit désormais comme une seule ligne d'intention. Et la préparation réside à un seul endroit, si bien qu'une modification apportée à Cart() n'implique de changer qu'une seule fonction.
Chaque test reçoit une copie neuve
test_add_one_more ajoute un carnet au panier. Les tests exécutés après lui ont-ils vu un panier contenant ce carnet ? Non — et cela mérite d'être bien compris, car c'est précisément cette propriété qui rend les fixtures sûres.
D'abord, la version sans fixture. Une liste en haut du module, partagée par deux tests :
ITEMS = ["pen", "bag"]
def test_add_item():
ITEMS.append("notebook")
assert len(ITEMS) == 3
def test_starts_with_two():
assert len(ITEMS) == 2pytest -q
.F [100%]
=================================== FAILURES ===================================
_____________________________ test_starts_with_two _____________________________
def test_starts_with_two():
> assert len(ITEMS) == 2
E AssertionError: assert 3 == 2
E + where 3 = len(['pen', 'bag', 'notebook'])
test_shared.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_shared.py::test_starts_with_two - AssertionError: assert 3 == 2
1 failed, 1 passed in 0.01sLe second test est correct ; il a échoué à cause de ce que le premier test a fait. Exécutez-le seul, et il réussit :
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sUn test dont le résultat dépend des tests exécutés avant lui constitue l'un des bugs les plus coûteux dans une suite de tests, car il ne se manifeste que par moments.
Voyons maintenant les deux mêmes tests, mais avec une liste issue d'une fixture. Un print à l'intérieur de la fixture montre le moment où elle s'exécute ; -s indique à pytest de ne pas capturer les sorties affichées, afin que nous puissions les voir :
import pytest
@pytest.fixture
def items():
print("building a new list")
return ["pen", "bag"]
def test_add_item(items):
items.append("notebook")
assert len(items) == 3
def test_starts_with_two(items):
assert len(items) == 2pytest -q -s
building a new list
.building a new list
.
2 passed in 0.01s« building a new list » apparaît deux fois. La fonction de fixture s'est exécutée une fois pour chaque test qui l'a demandée, et chaque test a reçu une liste à laquelle personne d'autre n'avait touché. C'est le comportement par défaut, et il porte un nom, la portée de fonction (function scope) : la valeur de la fixture vit aussi longtemps qu'une fonction de test. Le chapitre huit montre comment changer cela, et pourquoi vous devriez généralement vous en abstenir.
Les fixtures peuvent utiliser des fixtures
Une fixture peut demander une autre fixture exactement de la même manière qu'un test — en la nommant comme paramètre :
import pytest
from cart import Cart
@pytest.fixture
def empty_cart():
return Cart()
@pytest.fixture
def cart(empty_cart):
empty_cart.add("pen", 15.0, 3)
empty_cart.add("bag", 850.0)
return empty_cart
def test_new_cart_is_empty(empty_cart):
assert empty_cart.total() == 0
def test_total(cart):
assert cart.total() == 895.0pytest -q
.. [100%]
2 passed in 0.01scart s'appuie sur empty_cart, ce qui permet à un test de demander le niveau de préparation dont il a besoin. Aucun des deux tests n'appelle quoi que ce soit ; pytest suit les noms, de test_total à cart et de cart à empty_cart, et les construit dans l'ordre qu'exige cette chaîne.
Le voir en action — --setup-show
Vous n'avez pas besoin d'ajouter des appels à print pour savoir quelles fixtures ont été exécutées. --setup-show affiche chaque préparation (setup) et chaque nettoyage (teardown) :
pytest -q --setup-show
SETUP F empty_cart
test_cart.py::test_new_cart_is_empty (fixtures used: empty_cart) .
TEARDOWN F empty_cart
SETUP F empty_cart
SETUP F cart (fixtures used: empty_cart)
test_cart.py::test_total (fixtures used: cart, empty_cart) .
TEARDOWN F cart
TEARDOWN F empty_cart
2 passed in 0.01sLisez la sortie depuis le début. La lettre F indique la portée de fonction (function scope) — construite pour un test et supprimée après lui. empty_cart est préparée deux fois, une fois par test, ce qui correspond à la fraîcheur vue dans la section précédente. Là où cart est utilisée, empty_cart est préparée en premier, car cart en dépend. Et les nettoyages interviennent dans l'ordre inverse des préparations : dernière construite, première supprimée.
(Si des plugins sont installés, des lignes supplémentaires correspondant à leurs propres fixtures peuvent apparaître. Ce sont les leurs, pas les vôtres.)
Jusqu'ici, le nettoyage n'a rien fait de visible, car il n'y avait rien à annuler. Cela va changer dès maintenant.
yield — préparation, puis nettoyage
Certaines préparations laissent derrière elles des traces qui doivent être annulées : une connexion à fermer, un fichier à supprimer, un réglage global à rétablir. Pour cela, une fixture utilise yield au lieu de return :
import pytest
@pytest.fixture
def connection():
print("\nconnection: open")
conn = {"orders": []}
yield conn
print("connection: close")
def test_starts_empty(connection):
print("test: running")
assert connection["orders"] == []pytest -q -s
connection: open
test: running
.connection: close
1 passed in 0.01sTout ce qui se trouve avant yield constitue la préparation. La valeur située après yield est ce que le test reçoit. La fixture se met ensuite en pause — le test s'exécute — et quand le test est terminé, pytest reprend la fixture après le yield ; ce reste constitue le nettoyage. (Le . se trouve devant « connection: close » parce que le résultat du test est affiché au moment même où le test se termine, juste avant que le nettoyage ne commence.)
Le nettoyage se trouve dans la même fonction que la préparation, quelques lignes plus bas. Impossible d'ajouter l'un et d'oublier l'autre sans que cela ne saute aux yeux.
Avec deux fixtures yield, dont l'une dépend de l'autre, l'ordre est celui que --setup-show laissait présager :
import pytest
@pytest.fixture
def connection():
print("\n1. connection: open")
conn = {"orders": []}
yield conn
print("5. connection: close")
@pytest.fixture
def saved_order(connection):
print("2. saved_order: insert")
connection["orders"].append("A-100")
yield "A-100"
connection["orders"].remove("A-100")
print("4. saved_order: delete")
def test_order_is_saved(connection, saved_order):
print("3. test: running")
assert saved_order in connection["orders"]pytest -q -s
1. connection: open
2. saved_order: insert
3. test: running
.4. saved_order: delete
5. connection: close
1 passed in 0.01sLa préparation monte la chaîne de dépendances de bas en haut ; le nettoyage redescend en sens inverse. C'est le seul ordre possible : saved_order supprime sa ligne via la connexion, la connexion doit donc toujours être ouverte lorsqu'elle le fait.
Le nettoyage s'exécute même en cas d'échec du test
Pourquoi se donner la peine d'utiliser une fixture pour le nettoyage, alors que le test pourrait simplement faire son nettoyage à la fin ? Essayez. OPEN représente ici tout ce qu'un test peut laisser derrière lui — une connexion ouverte, un fichier temporaire, un paramètre modifié :
OPEN = []
def test_broken():
OPEN.append("connection")
assert OPEN == ["A-100"]
OPEN.remove("connection")
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_body.py::test_broken - AssertionError: assert ['connec...
FAILED test_cleanup_in_body.py::test_nothing_left_open - AssertionError: asse...
2 failed in 0.01s(--tb=no masque les traces d'appels et ne conserve que le résumé.) Une seule erreur, deux échecs. Un assert défaillant lève une exception, et lever une exception interrompt le test sur-le-champ ; la ligne de nettoyage située en dessous ne s'est donc jamais exécutée. La connexion est restée dans OPEN, et le test suivant — qui était parfaitement correct — a échoué à cause de ce qu'il a hérité. Dans une vaste suite de tests, ce second échec est celui sur lequel vous pourriez perdre une après-midi entière.
Reprenons maintenant le même principe, avec le nettoyage placé après un yield :
import pytest
OPEN = []
@pytest.fixture
def connection():
OPEN.append("connection")
yield "connection"
OPEN.remove("connection")
def test_broken(connection):
assert OPEN == ["A-100"]
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
F. [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_fixture.py::test_broken - AssertionError: assert ['con...
1 failed, 1 passed in 0.01sUne seule erreur, un seul échec. Le test défaillant est toujours signalé — la fixture ne cache rien — mais son désordre n'a pas atteint le test suivant.
Voilà la raison, et la règle qui en découle : le nettoyage ne doit jamais être placé à la fin du corps d'un test. Il se place après le yield d'une fixture.
Une fixture ou une simple fonction ?
Une fixture n'est pas le seul moyen de partager une préparation. Une fonction ordinaire — disons cart_with(*lines), qui construit un panier à partir des lignes qu'on lui fournit — fonctionne aussi, et l'exemple complet ci-dessous en utilise une.
Voici la différence fondamentale : un test appelle une fonction utilitaire, et peut lui transmettre des arguments. Un test ne peut pas appeler une fixture ; il peut seulement la nommer. Une fixture produit toujours la même chose pour chaque test qui la demande.
Ainsi :
- Utilisez une fixture lorsque de nombreux tests ont besoin du même état initial, et tout particulièrement lorsque cet état nécessite un nettoyage par la suite. Seule une fixture garantit l'exécution du nettoyage.
- Utilisez une fonction utilitaire lorsque chaque test a besoin d'un objet légèrement différent — un panier avec telles lignes, un utilisateur avec tel nom. Les arguments sont précisément là pour cela.
Elles se combinent très bien : une fixture peut appeler une fonction utilitaire, comme le montre l'exemple suivant. (Il existe aussi un moyen de créer une fixture qui renvoie une fonction — le patron « fabrique » au chapitre douze. Vous n'en avez pas encore besoin.)
Un exemple complet
Une boutique dans laquelle les codes de réduction résident dans un dictionnaire au niveau du module. L'enregistrement d'un code modifie l'état global, de sorte qu'un test qui en enregistre un doit le retirer ensuite.
shop.py :
DISCOUNTS = {}
def register_discount(code, percent):
DISCOUNTS[code] = percent
def remove_discount(code):
del DISCOUNTS[code]
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self, code=None):
amount = sum(price * quantity for _, price, quantity in self.lines)
if code is not None:
amount = amount * (100 - DISCOUNTS[code]) / 100
return round(amount, 2)test_shop.py :
import pytest
from shop import Cart, register_discount, remove_discount
def cart_with(*lines):
# A plain helper: it takes arguments, so each test can ask for its own cart.
cart = Cart()
for name, price, quantity in lines:
cart.add(name, price, quantity)
return cart
@pytest.fixture
def cart():
# The cart most tests share. Built fresh for every test that asks.
return cart_with(("pen", 15.0, 3), ("bag", 850.0, 1))
@pytest.fixture
def summer_code():
# Changes global state, so it must undo that change afterwards.
register_discount("SUMMER", 10)
yield "SUMMER"
remove_discount("SUMMER")
def test_total(cart):
assert cart.total() == 895.0
def test_summer_discount(cart, summer_code):
assert cart.total(summer_code) == 805.5
def test_code_is_gone_afterwards(cart):
with pytest.raises(KeyError):
cart.total("SUMMER")
def test_small_cart(summer_code):
cart = cart_with(("pen", 15.0, 2))
assert cart.total(summer_code) == 27.0
def test_zero_quantity_is_refused():
with pytest.raises(ValueError):
Cart().add("pen", 15.0, 0)pytest -q --setup-show
SETUP F cart
test_shop.py::test_total (fixtures used: cart) .
TEARDOWN F cart
SETUP F cart
SETUP F summer_code
test_shop.py::test_summer_discount (fixtures used: cart, summer_code) .
TEARDOWN F summer_code
TEARDOWN F cart
SETUP F cart
test_shop.py::test_code_is_gone_afterwards (fixtures used: cart) .
TEARDOWN F cart
SETUP F summer_code
test_shop.py::test_small_cart (fixtures used: summer_code) .
TEARDOWN F summer_code
test_shop.py::test_zero_quantity_is_refused .
5 passed in 0.01sTrois éléments méritent d'être soulignés.
Premièrement, chaque test ne demande que ce qu'il utilise. test_small_cart avait besoin d'un panier différent, il n'a donc pas du tout demandé cart ; il a appelé la fonction utilitaire avec ses propres lignes — et --setup-show confirme que cart n'a jamais été construite pour lui. test_zero_quantity_is_refused n'a besoin que d'un panier vide et d'un seul appel, il n'utilise donc aucune fixture ; c'est la ligne « état initial que personne d'autre ne partage » de notre plan.
Deuxièmement, test_code_is_gone_afterwards est un test de la fixture. Il s'exécute après test_summer_discount et vérifie que SUMMER n'existe plus. Pour comprendre son importance, remplacez dans summer_code le yield et la ligne de nettoyage par return "SUMMER" :
pytest -q --tb=no
..F.. [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_code_is_gone_afterwards - Failed: DID NOT RAISE Key...
1 failed, 4 passed in 0.01sLe code s'est échappé du test qui l'avait enregistré pour contaminer le suivant. Sans cette vérification, la fuite serait passée inaperçue.
Troisièmement, la fonction utilitaire et la fixture fonctionnent de concert. cart_with sait comment construire un panier ; la fixture cart décide quel panier la plupart des tests partagent.
En cas de problème
fixture 'crat' not found
_________________________ ERROR at setup of test_empty _________________________
file .../test_typo.py, line 11
def test_empty(crat):
E fixture 'crat' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, cart, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, subtests, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.Un nom de paramètre ne correspond à aucune fixture. Soit il est mal orthographié — ici crat au lieu de cart, qui figure pourtant dans la liste —, soit la fonction n'a pas sa ligne @pytest.fixture, auquel cas elle sera absente de la liste. Remarquez le E et la mention ERROR, et non F et FAILED : le test n'a même jamais commencé.
Fixture "cart" called directly
Fixture "cart" called directly. Fixtures are not meant to be called directly,
but are created automatically when test functions request them as parameters.Le test a écrit cart() dans son corps. Une fixture se demande en la nommant comme paramètre, jamais en l'appelant. Si vous souhaitez réellement l'appeler avec vos propres arguments, ce qu'il vous faut est une fonction utilitaire.
ERROR at setup of test_total au lieu d'un échec L'exception a été levée à l'intérieur de la fixture, avant l'exécution du test — par exemple ValueError: quantity must be at least 1: 0 provoquée par un mauvais appel à add() dans la préparation. Un E signifie « la préparation a échoué » ; un F signifie « l'affirmation était fausse ». Regardez d'abord dans la fixture.
fixture function has more than one 'yield' Une fixture effectue un yield exactement une fois. La préparation se place au-dessus du yield, le nettoyage en dessous ; pour fournir deux valeurs, renvoyez un tuple avec yield ou écrivez deux fixtures.
Un test réussit isolément mais échoue au milieu des autres Quelque chose est partagé entre les tests — une liste ou un dictionnaire au niveau du module, ou une variable globale qu'une fixture a modifiée sans la restaurer. Construisez-la dans une fixture, et s'il s'agit d'un état global, annulez la modification après yield.
Le nettoyage ne s'est pas exécuté Le nettoyage se trouve dans le corps du test après un assert défaillant, ou bien la fixture utilise return. Déplacez-le dans une fixture yield, après le yield.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Les deux tests demandent la même fixture, et tous deux ajoutent un élément à la liste. Que dit la dernière ligne de pytest -q ?
import pytest
@pytest.fixture
def basket():
return []
def test_one(basket):
basket.append("pen")
assert basket == ["pen"]
def test_two(basket):
basket.append("bag")
assert basket == ["bag"]- A1 failed, 1 passed — test_two voit ['pen', 'bag']
- B1 passed, 1 error
- C2 passed
- D2 failed
Ce code est exécuté avec pytest -q -s. En omettant les points et le résumé propres à pytest, dans quel ordre les trois lignes sont-elles affichées ?
import pytest
@pytest.fixture
def db():
print("open")
yield "db"
print("close")
def test_query(db):
print("query")- Aopen query
- Bopen close query
- Cquery open close
- Dopen query close
Ce test échoue. Où se situe le problème ?
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- A`len()` ne peut pas être utilisé sur une fixture
- BLa fixture a besoin de `yield`, et non de `return`
- CLa fixture doit renvoyer un tuple
- DLe test appelle lui-même la fixture ; il devrait plutôt prendre `cart` comme paramètre
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 library.py avec une variable LOG = [] au niveau du module et une classe Library : add(title) ; borrow(title), qui lève une ValueError si le livre n'est pas sur l'étagère et, sinon, le retire et ajoute "borrow <title>" à LOG ; give_back(title) ; et available(), qui renvoie une liste triée des titres présents sur l'étagère.
Écrivez ensuite test_library.py. Planifiez-le d'abord — avec un tableau comprenant une colonne état initial, comme au début de ce chapitre — puis écrivez :
- Une fixture
libraryrenvoyant uneLibrarycontenant trois livres, et au moins trois tests qui l'utilisent — l'un d'eux empruntant un livre, afin qu'un test ultérieur puisse montrer que l'étagère est de nouveau pleine - Une fixture
borrowedqui dépend delibrary, emprunte un livre et renvoie son titre, avec deux tests qui l'utilisent - Une fixture
yieldclean_logqui videLOGavant le test et à nouveau après lui ; un test qui l'utilise, suivi d'un autre test vérifiant queLOG == [] - Une simple fonction utilitaire
library_with(*titles), utilisée parlibraryet par un test ayant besoin d'une étagère vide
Exécutez pytest -q, puis pytest -q --setup-show, et vérifiez chaque ligne par rapport à vos attentes.
Enfin, cassez-le volontairement : dans clean_log, remplacez le yield et la ligne qui le suit par return LOG. Quel test échoue, et s'agit-il de celui qui utilisait la fixture ?
Solution
Le plan d'abord :
| Cas | État initial | Action | Attendu | |---|---|---|---| | Trois livres sur l'étagère | Dune, Emma, Ulysses | available() | les trois, triés | | Emprunter retire un livre | identique | borrow("Dune") | Emma, Ulysses | | Aucune fuite entre les tests | identique | — | toujours trois | | Impossible d'emprunter deux fois | identique, Emma emprunté | borrow("Emma") | ValueError | | Rendre le livre | identique, Emma emprunté | give_back("Emma") | Emma sur l'étagère | | L'emprunt est journalisé | identique, LOG vide | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | Journal nettoyé | après le test ci-dessus | — | LOG == [] | | Cas limite : étagère vide | aucun livre | available() | [] |
Deux états initiaux se répètent — l'étagère de trois livres et l'étagère dont Emma a été retiré — ils sont donc devenus des fixtures. Le journal vide n'est nécessaire qu'une seule fois, mais il touche à un état global qui doit être restauré par la suite, et seule une fixture le garantit ; c'est donc également une fixture. L'étagère vide n'est nécessaire qu'une fois et ne laisse rien derrière elle, elle utilise donc la fonction utilitaire.
library.py :
LOG = []
class Library:
def __init__(self):
self.shelf = set()
def add(self, title):
self.shelf.add(title)
def borrow(self, title):
if title not in self.shelf:
raise ValueError(f"not on the shelf: {title}")
self.shelf.remove(title)
LOG.append(f"borrow {title}")
def give_back(self, title):
self.shelf.add(title)
def available(self):
return sorted(self.shelf)test_library.py :
import pytest
from library import LOG, Library
def library_with(*titles):
# Helper: each caller chooses its own shelf.
library = Library()
for title in titles:
library.add(title)
return library
@pytest.fixture
def library():
return library_with("Dune", "Emma", "Ulysses")
@pytest.fixture
def borrowed(library):
library.borrow("Emma")
return "Emma"
@pytest.fixture
def clean_log():
LOG.clear()
yield LOG
LOG.clear()
def test_three_books_on_the_shelf(library):
assert library.available() == ["Dune", "Emma", "Ulysses"]
def test_borrow_removes_the_book(library):
library.borrow("Dune")
assert library.available() == ["Emma", "Ulysses"]
def test_next_test_still_sees_three(library):
assert len(library.available()) == 3
def test_cannot_borrow_twice(library, borrowed):
with pytest.raises(ValueError):
library.borrow(borrowed)
def test_give_back_returns_it(library, borrowed):
library.give_back(borrowed)
assert borrowed in library.available()
def test_borrow_is_logged(library, clean_log):
library.borrow("Ulysses")
assert clean_log == ["borrow Ulysses"]
def test_log_is_empty_afterwards():
assert LOG == []
def test_empty_library():
library = library_with()
assert library.available() == []pytest -q
........ [100%]
8 passed in 0.01sAvec --setup-show, vous verrez SETUP F library une fois pour chacun des six tests qui l'utilisent, borrowed préparée après library et nettoyée avant elle, et aucune ligne de fixture pour les deux derniers tests.
Et pour l'expérience, avec return LOG à la place du yield :
pytest -q --tb=no
......F. [100%]
=========================== short test summary info ============================
FAILED test_library.py::test_log_is_empty_afterwards - AssertionError: assert...
1 failed, 7 passed in 0.01sLes décisions, une par une :
libraryutilisereturn, pasyield. Une nouvelleLibraryne modifie rien en dehors du test, il n'y a donc rien à annuler.yieldest réservé aux fixtures qui ont quelque chose à nettoyer, ce n'est pas un choix de style.test_next_test_still_sees_threeest là intentionnellement. Il s'exécute juste après un test qui a empruntéDune, et réussit parce quelibrarya été construite à nouveau. Avec--setup-show, vous pouvez le compter :SETUP F libraryune fois par test.borrowedrenvoie le titre. Les tests utilisentborrowedau lieu de répéter"Emma", de sorte que si la fixture modifie le livre qu'elle emprunte, les tests suivent automatiquement. Et commeborroweddépend delibrary, les deux tests qui demandent les deux reçoivent la même bibliothèque — celle dont Emma a été retiré.clean_logvide la liste de part et d'autre duyield. Avant, car d'autres tests appelantborrow()—test_borrow_removes_the_book, la fixtureborrowed— ont déjà écrit dansLOG; le test doit démarrer depuis un état connu. Après, pour que rien ne contamine le test suivant.- *L'expérience échoue dans l'autre test.*
test_borrow_is_loggedréussit toujours ;test_log_is_empty_afterwards, qui n'avait pourtant rien fait de mal, échoue. C'est exactement à cela que ressemble toujours un nettoyage manquant : l'échec se manifeste ailleurs. Et c'est précisément pourquoi la solution contient un test vérifiant le nettoyage. library_withest une fonction utilitaire, pas une fixture, car le seul test qui l'utilise veut une étagère différente, et seule une fonction peut accepter des arguments.libraryl'appelle également, ce qui permet de regrouper en un seul endroit la façon dont une bibliothèque est garnie.
É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