Fixtures — setup één keer geschreven
Stop met het herhalen van setup: schrijf het één keer met @pytest.fixture, vraag het op via de naam, ontvang in elke test een nieuw exemplaar en ruim op met yield, zelfs wanneer een test faalt.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een klein winkelmandje, 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)En drie tests ervoor, 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.01sZe slagen, en ze zijn vermoeiend om te lezen. Drie tests, negen regels setup, drie regels die daadwerkelijk iets controleren. De setup is in elke test hetzelfde, waardoor die ene regel die verschilt — de regel waar de test voor bestaat — ondergesneeuwd raakt.
Het kost later ook tijd en moeite. Wanneer Cart() een argument nodig gaat hebben, moet je dat op drie plekken aanpassen, of op dertig. In het vorige hoofdstuk nam parametrize herhaling weg in de invoer van een test. Dit hoofdstuk neemt herhaling weg in de setup.
Aan het einde van dit hoofdstuk kun je
- Een fixture schrijven met
@pytest.fixtureen deze aanvragen door hem als testparameter te benoemen - Uitleggen waarom elke test een eigen nieuw exemplaar krijgt, en dit aantonen met een lijst
- Een fixture opbouwen op basis van een andere fixture
- Een
yield-fixture schrijven die achteraf opruimt, en de volgorde van setup en teardown voorspellen - Zien hoe fixtures worden aangemaakt met behulp van
--setup-show fixture 'x' not founden de error "called directly" begrijpen- Kiezen tussen een fixture en een gewone helperfunctie
Voorkennis: één test uitvoeren over veel gevallen met `parametrize`.
Voordat je de test schrijft
Fixtures zijn niet iets wat je achteraf aan tests toevoegt. Ze vloeien voort uit een plan, en dat plan komt eerst.
Wat er beloofd wordt. Cart belooft drie dingen: add() legt een regel vast en weigert een aantal kleiner dan 1 met ValueError; total() is de som van prijs × aantal; count() is de som van de aantallen. Geen bestanden, geen netwerk, geen globale toestand — tot nu toe niets wat ongedaan gemaakt moet worden.
Wat er aanwezig moet zijn. Hetzelfde als in elk hoofdstuk: een virtuele omgeving waarin pytest is geïnstalleerd, en cart.py importeerbaar vanuit de map waarin je pytest uitvoert (plaats het testbestand ernaast).
Welke gevallen. Schrijf ze op, en voeg één kolom toe die mensen meestal weglaten — de begintoestand van de test:
| Geval | Begintoestand | Actie | Verwacht | |---|---|---|---| | Normaal totaal | pen × 3, bag × 1 | total() | 895.0 | | Aantal artikelen | pen × 3, bag × 1 | count() | 4 | | Nog een regel erbij | pen × 3, bag × 1 | add("notebook", 60.0, 2) | total() is 1015.0 | | Randgeval: niets erin | leeg winkelmandje | total() | 0 | | Ongeldig aantal | leeg winkelmandje | add("pen", 15.0, 0) | ValueError |
Lees de tweede kolom van boven naar beneden. Drie rijen beginnen met hetzelfde gevulde winkelmandje, twee met een leeg mandje. Elke herhaalde waarde in die kolom is een fixture die erop wacht om geschreven te worden — hier zijn dat empty_cart en een cart die daarop voortbouwt. En een rij die begint vanuit een toestand die door niemand anders wordt gedeeld, heeft helemaal geen fixture nodig; die bouwt zijn eigen toestand op.
Stel jezelf bij elke begintoestand nog één vraag: verandert het aanmaken ervan iets buiten de test? Een nieuwe Cart doet dat niet. Een geregistreerde kortingscode in een globale dictionary, een geopende verbinding, een weggeschreven bestand — die doen dat wel, en dat is wat aangeeft dat een fixture opgeruimd moet worden (yield, later in dit hoofdstuk).
Wat je niet test. Schrijf geen tests om te bewijzen dat pytest voor elke test een nieuw exemplaar van een fixture bouwt; dat is de belofte van pytest, en dit hoofdstuk laat dat één keer zien zodat je erop kunt vertrouwen. Test wel je eigen opruimcode wanneer die een globale toestand herstelt — die code is van jou, en daar kunnen fouten in zitten.
De rest van het hoofdstuk zet dit plan om in code.
Een eerste fixture
Verplaats de setup naar een functie en zet @pytest.fixture erboven:
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.01sNiets in de tests roept cart() aan. Elke test heeft simpelweg een parameter genaamd cart, en dat is het hele verzoek.
Voordat een test wordt uitgevoerd, kijkt pytest naar de namen van de parameters. Voor elke naam zoekt pytest een fixture met die naam, voert deze uit en geeft wat deze geretourneerd heeft door als het argument. De test geeft aan wat hij nodig heeft; hij bouwt het niet zelf. Deze opzet heeft een naam — dependency injection — maar het mechanisme is niets meer dan "koppel de parameternaam aan een fixturenaam".
De test leest nu als één regel die de intentie uitdrukt. En de setup bevindt zich op één plek, dus een wijziging aan Cart() is een wijziging in één functie.
Elke test krijgt een nieuw exemplaar
test_add_one_more voegt een notebook toe aan het winkelmandje. Zagen de tests die erna draaiden een mandje met een notebook erin? Nee — en het is de moeite waard om dat goed te zien, want dat is de eigenschap die fixtures veilig maakt.
Eerst de versie zonder fixture. Een lijst bovenaan de module, gedeeld door twee 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.01sDe tweede test klopt; hij faalde vanwege wat de eerste test deed. Voer hem los uit en hij slaagt:
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sEen test waarvan het resultaat afhangt van welke tests ervoor zijn uitgevoerd, is een van de duurste bugs in een testsuite, omdat hij slechts af en toe de kop opsteekt.
Nu dezelfde twee tests waarbij de lijst uit een fixture komt. Een print in de fixture laat zien wanneer deze draait; -s vertelt pytest om geprinte uitvoer niet af te vangen, zodat we die kunnen zien:
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" verschijnt twee keer. De fixturefunctie werd één keer uitgevoerd voor elke test die erom vroeg, en elke test kreeg een lijst die door niemand anders was aangeraakt. Dat is de standaard, en het heeft een naam, function scope: de waarde van de fixture blijft net zo lang bestaan als één testfunctie. Hoofdstuk acht laat zien hoe je dat kunt veranderen, en waarom je dat meestal niet moet doen.
Fixtures kunnen fixtures gebruiken
Een fixture kan op precies dezelfde manier om een andere fixture vragen als een test — door deze als parameter te benoemen:
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 bouwt voort op empty_cart, waardoor een test om precies het gewenste voorbereidingsniveau kan vragen. Geen van beide tests roept iets aan; pytest volgt de namen, van test_total naar cart en van cart naar empty_cart, en bouwt ze op in de volgorde die die keten vereist.
Het zien gebeuren — --setup-show
Je hoeft geen print-aanroepen toe te voegen om te achterhalen welke fixtures zijn uitgevoerd. --setup-show print elke setup en 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.01sLees het van boven naar beneden. De F staat voor function scope — opgebouwd voor één test en daarna weggegooid. empty_cart wordt twee keer klaargezet, één keer per test, wat de versheid uit de vorige paragraaf laat zien. Waar cart wordt gebruikt, wordt empty_cart eerst klaargezet, omdat cart ervan afhankelijk is. En de teardowns gebeuren in de omgekeerde volgorde van de setups: wat als laatste is opgebouwd, wordt als eerste verwijderd.
(Als je plugins hebt geïnstalleerd, kunnen er extra regels voor hun eigen fixtures verschijnen. Die zijn van hen, niet van jou.)
Tot nu toe deed teardown niets zichtbaars, omdat er niets ongedaan te maken viel. Dat verandert hierna.
yield — setup, daarna opruimen
Sommige setups laten iets achter dat ongedaan gemaakt moet worden: een verbinding die gesloten moet worden, een bestand dat verwijderd moet worden, een globale instelling die teruggezet moet worden. Daarvoor gebruikt een fixture yield in plaats van 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.01sAlles vóór yield is setup. De waarde na yield is wat de test ontvangt. Daarna pauzeert de fixture — de test wordt uitgevoerd — en wanneer de test klaar is, hervat pytest de fixture na de yield, en dat restant is de teardown. (De . staat voor "connection: close" omdat het testresultaat wordt geprint op het moment dat de test eindigt, vlak voordat de teardown begint.)
De opruimcode staat in dezelfde functie als de setup, een paar regels eronder. Je kunt niet het ene toevoegen en het andere vergeten zonder dat dat meteen opvalt.
Bij twee yield-fixtures, waarbij de ene afhankelijk is van de andere, is de volgorde degene waar --setup-show al naar hintte:
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.01sSetup loopt van onderaan de afhankelijkheidsketen omhoog; teardown loopt weer omlaag. Dat is de enige volgorde die werkt: saved_order verwijdert zijn rij via de verbinding, dus de verbinding moet nog open zijn wanneer dat gebeurt.
Teardown wordt zelfs uitgevoerd wanneer de test faalt
Waarom zou je de moeite nemen van een fixture voor het opruimen, als de test aan het einde gewoon zelf kan opruimen? Probeer het maar eens. OPEN staat voor alles wat een test kan achterlaten — een openstaande verbinding, een tijdelijk bestand, een gewijzigde instelling:
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 verbergt de tracebacks en toont alleen de samenvatting.) Eén fout, twee failures. Een falende assert werpt een exception op, en dat beëindigt de test ter plekke, waardoor de opruimregel eronder nooit is uitgevoerd. De verbinding bleef in OPEN staan, en de volgende test — die volkomen correct was — faalde door wat hij erfde. In een grote testsuite is die tweede fout degene waar je een hele middag aan kwijt zou zijn.
Nu hetzelfde werk, maar met de opruimcode na een 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.01sEén fout, één failure. De kapotte test wordt nog steeds gerapporteerd — de fixture verbergt niets — maar de rommel bereikte de volgende test niet.
Dat is de reden, en de regel die daaruit volgt: opruimcode hoort nooit aan het einde van een testbody te staan. Die hoort na de yield van een fixture.
Een fixture of een gewone functie?
Een fixture is niet de enige manier om setup te delen. Een gewone functie — zoals cart_with(*lines), die een mandje opbouwt uit de regels die hij doorkrijgt — werkt ook, en het complete voorbeeld hieronder maakt daar gebruik van.
Hier is het verschil dat de doorslag geeft: een test roept een helper aan en kan daar argumenten aan meegeven. Een test kan een fixture niet aanroepen; hij kan deze alleen benoemen. Een fixture levert altijd hetzelfde op voor elke test die erom vraagt.
Dus:
- Gebruik een fixture wanneer veel tests dezelfde begintoestand nodig hebben, en vooral wanneer die toestand achteraf opgeruimd moet worden. Alleen een fixture krijgt een gegarandeerde teardown.
- Gebruik een helperfunctie wanneer elke test een net iets ander object nodig heeft — een mandje met deze regels, een gebruiker met die naam. Argumenten zijn precies waar functies voor bedoeld zijn.
Ze laten zich goed combineren: een fixture kan een helper aanroepen, zoals het volgende voorbeeld laat zien. (Er is ook een manier om een fixture te maken die een functie teruggeeft — het "factory"-patroon in hoofdstuk twaalf. Dat heb je nu nog niet nodig.)
Een compleet voorbeeld
Een webwinkel waar kortingscodes in een dictionary op moduleniveau staan. Het registreren van een code verandert een globale toestand, dus een test die een code registreert moet deze daarna weer verwijderen.
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.01sDrie dingen vallen hierbij op.
Ten eerste vraagt elke test alleen om wat hij gebruikt. test_small_cart wilde een ander mandje, dus vroeg hij helemaal niet om cart; hij riep de helper aan met eigen regels — en --setup-show bevestigt dat cart nooit voor deze test is opgebouwd. test_zero_quantity_is_refused heeft alleen een leeg mandje en één aanroep nodig, dus gebruikt hij geen enkele fixture; dat is de rij "begintoestand die door niemand anders wordt gedeeld" uit het plan.
Ten tweede is test_code_is_gone_afterwards een test van de fixture. Deze draait na test_summer_discount en controleert of SUMMER niet meer bestaat. Om te zien waarom dat uitmaakt, verander je summer_code in return "SUMMER" in plaats van de yield en de opruimregel:
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.01sDe code lekte weg uit de test die hem registreerde naar de volgende test. Zonder die controle was dat lek onopgemerkt gebleven.
Ten derde werken de helper en de fixture samen. cart_with weet hoe je een mandje bouwt; de fixture cart bepaalt welk mandje de meeste tests delen.
Wanneer het misgaat
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.Een parameternaam kwam met geen enkele fixture overeen. Ofwel is de naam verkeerd gespeld — hier crat in plaats van cart, die gewoon in de lijst staat — of de functie mist de regel @pytest.fixture, waardoor deze in de lijst ontbreekt. Let op de E en ERROR, niet F en FAILED: de test is nooit van start gegaan.
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.De test bevatte cart() in de body. Een fixture vraag je aan door hem als parameter te benoemen, nooit door hem aan te roepen. Als je hem echt wilt aanroepen met je eigen argumenten, wil je eigenlijk een helperfunctie.
ERROR at setup of test_total in plaats van een failure De exception werd opgeworpen binnen de fixture, voordat de test werd uitgevoerd — bijvoorbeeld ValueError: quantity must be at least 1: 0 door een foute add() in de setup. Een E betekent "de voorbereiding ging stuk"; een F betekent "de bewering was onjuist". Kijk eerst in de fixture.
fixture function has more than one 'yield' Een fixture yieldt precies één keer. Setup staat boven de yield, het opruimen eronder; voor twee waarden yield je een tuple of schrijf je twee fixtures.
Een test slaagt los uitgevoerd, maar faalt tussen de andere Er wordt iets gedeeld tussen tests — een lijst of dictionary op moduleniveau, of een globale variabele die door een fixture is gewijzigd en niet is teruggezet. Bouw het op in een fixture, en als het om een globale toestand gaat, maak de wijziging dan na yield ongedaan.
De opruimcode is niet uitgevoerd De opruimcode staat in de testbody na een falende assert, of de fixture gebruikt return. Verplaats deze naar een yield-fixture, na de yield.
Stap 4 van 6 — Voorspellen
Test je begrip
Beide tests vragen om dezelfde fixture, en beide voegen iets toe aan de lijst. Wat staat er op de laatste regel van 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 ziet ['pen', 'bag']
- B1 passed, 1 error
- C2 passed
- D2 failed
Dit wordt uitgevoerd met pytest -q -s. Als de punten en de samenvatting van pytest zelf worden weggelaten, in welke volgorde worden de drie regels dan geprint?
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
Deze test faalt. Waar zit het probleem?
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- A`len()` kan niet worden gebruikt op een fixture
- BDe fixture heeft `yield` nodig, niet `return`
- CDe fixture moet een tuple retourneren
- DDe test roept de fixture zelf aan; deze zou in plaats daarvan `cart` als parameter moeten aannemen
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf library.py met op moduleniveau LOG = [] en een klasse Library: add(title); borrow(title), die een ValueError opwerpt als de titel niet op de plank staat en deze anders verwijdert en "borrow <title>" toevoegt aan LOG; give_back(title); en available(), die een gesorteerde lijst retourneert van de titels op de plank.
Schrijf daarna test_library.py. Maak eerst een plan — een tabel met een kolom begintoestand, zoals aan het begin van dit hoofdstuk — en schrijf vervolgens:
- Een fixture
librarydie eenLibrarymet drie boeken retourneert, en minstens drie tests die deze gebruiken — waarvan er één een boek leent, zodat een latere test kan laten zien dat de plank weer vol is - Een fixture
borroweddie afhankelijk is vanlibrary, één boek leent en de titel ervan retourneert, met twee tests die deze gebruiken - Een
yield-fixtureclean_logdieLOGleegmaakt vóór de test en nogmaals erna; één test die deze gebruikt, en daarna één test die controleert datLOG == [] - Een gewone helper
library_with(*titles), gebruikt doorlibraryen door één test die een lege plank nodig heeft
Voer pytest -q uit, daarna pytest -q --setup-show, en vergelijk elke regel met wat je verwachtte.
Laat het ten slotte met opzet stukgaan: vervang in clean_log de yield en de regel eronder door return LOG. Welke test faalt er, en is dat de test die de fixture gebruikte?
Oplossing
Eerst het plan:
| Geval | Begintoestand | Actie | Verwacht | |---|---|---|---| | Drie boeken op de plank | Dune, Emma, Ulysses | available() | alle drie, gesorteerd | | Lenen verwijdert een boek | hetzelfde | borrow("Dune") | Emma, Ulysses | | Geen lek tussen tests | hetzelfde | — | nog steeds drie | | Kan niet twee keer lenen | hetzelfde, Emma uitgeleend | borrow("Emma") | ValueError | | Terugbrengen | hetzelfde, Emma uitgeleend | give_back("Emma") | Emma op de plank | | Lenen wordt gelogd | hetzelfde, LOG leeg | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | Log opgeruimd | na de bovenstaande test | — | LOG == [] | | Randgeval: lege plank | geen boeken | available() | [] |
Twee begintoestanden herhalen zich — de plank met drie boeken en de plank waar Emma van uitgeleend is — dus dat werden fixtures. Het lege logboek is maar één keer nodig, maar raakt aan een globale toestand die achteraf hersteld moet worden, en alleen een fixture garandeert dat; dus dat is ook een fixture. De lege plank is één keer nodig en laat niets achter, dus die gebruikt de helper.
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.01sMet --setup-show zie je SETUP F library één keer voor elk van de zes tests die deze gebruiken, borrowed klaargezet na library en opgeruimd ervóór, en helemaal geen fixture-regels voor de laatste twee tests.
En het experiment, met return LOG in plaats van de 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.01sDe beslissingen, één voor één:
libraryretourneert, hij yieldt niet. Een nieuweLibraryverandert niets buiten de test, dus er valt niets ongedaan te maken.yieldis bedoeld voor fixtures die iets moeten opruimen, niet als stijlmiddel.test_next_test_still_sees_threestaat er met opzet. Deze draait direct na een test dieDuneheeft geleend, en slaagt omdatlibraryopnieuw is opgebouwd. In--setup-showkun je het tellen:SETUP F libraryéén keer per test.borrowedretourneert de titel. De tests gebruikenborrowedin plaats van"Emma"te herhalen, zodat wanneer de fixture verandert welk boek wordt geleend, de tests automatisch meegaan. En omdatborrowedafhankelijk is vanlibrary, ontvangen beide tests die om beide vragen dezelfde library — die waar Emma uit is gehaald.clean_logmaakt schoon aan weerszijden van deyield. Ervóór, omdat andere tests dieborrow()aanroepen —test_borrow_removes_the_book, de fixtureborrowed— al naarLOGhebben geschreven; de test moet starten vanuit een bekende toestand. Erna, zodat er niets weglekt naar de volgende test.- *Het experiment faalt in de andere test.*
test_borrow_is_loggedslaagt nog steeds;test_log_is_empty_afterwards, die niets verkeerd deed, faalt. Dat is hoe ontbrekende opruimcode er altijd uitziet: het probleem duikt ergens anders op. En dat is precies waarom de oplossing een test bevat die de opruimcode controleert. library_withis een helper, geen fixture, omdat de ene test die deze gebruikt een andere plank wil, en alleen een functie argumenten kan aannemen.libraryroept deze ook aan, zodat de kennis over hoe een bibliotheek wordt gevuld op één plek blijft.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken