Fixture — il setup scritto una volta sola
Basta ripetere il setup: scrivilo una sola volta con @pytest.fixture, richiedilo per nome, ottieni una copia nuova in ogni test e ripulisci con yield anche quando un test fallisce.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco un piccolo carrello della spesa, 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)E tre test per verificarlo, 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.01sPassano, ma sono noiosi da leggere. Tre test, nove righe di setup, tre righe che controllano effettivamente qualcosa. Il setup è identico in ciascuno di essi, quindi l'unica riga che differisce — la riga per cui il test esiste — rimane sepolta.
E questo ha un costo anche in seguito. Quando Cart() inizierà a richiedere un argomento, dovrai modificarlo in tre punti, o in trenta. Nel capitolo precedente, parametrize ha eliminato la ripetizione negli input di un test. Questo capitolo elimina la ripetizione nel suo setup.
Alla fine di questo capitolo sarai in grado di
- Scrivere una fixture con
@pytest.fixturee richiederla indicandola come parametro del test - Spiegare perché ogni test riceve una propria copia nuova e mostrarlo con una lista
- Costruire una fixture a partire da un'altra
- Scrivere una fixture con
yieldche ripulisce dopo l'esecuzione e prevedere l'ordine di setup e teardown - Osservare la creazione delle fixture con
--setup-show - Interpretare
fixture 'x' not founde l'errore "called directly" - Scegliere tra una fixture e una semplice funzione helper
Prerequisiti: eseguire un test su molti casi con `parametrize`.
Prima di scrivere il test
Le fixture non sono qualcosa da aggiungere ai test a posteriori. Derivano da un piano, e il piano viene prima di tutto.
Cosa viene promesso. Cart promette tre cose: add() registra una voce e rifiuta una quantità inferiore a 1 con ValueError; total() è la somma di prezzo × quantità; count() è la somma delle quantità. Nessun file, nessuna rete, nessuno stato globale — finora nulla che richieda di essere annullato.
Cosa deve essere pronto. Come in ogni capitolo: un ambiente virtuale con pytest installato e cart.py importabile dalla cartella in cui esegui pytest (metti il file di test accanto ad esso).
Quali casi. Scrivili e aggiungi una colonna che di solito viene tralasciata — lo stato da cui il test parte:
| Caso | Stato iniziale | Azione | Atteso | |---|---|---|---| | Totale normale | pen × 3, bag × 1 | total() | 895.0 | | Conteggio articoli | pen × 3, bag × 1 | count() | 4 | | Un articolo in più | pen × 3, bag × 1 | add("notebook", 60.0, 2) | total() è 1015.0 | | Caso limite: carrello vuoto | carrello vuoto | total() | 0 | | Quantità non valida | carrello vuoto | add("pen", 15.0, 0) | ValueError |
Leggi la seconda colonna dall'alto verso il basso. Tre righe partono dallo stesso carrello già riempito, due da un carrello vuoto. Ogni valore ripetuto in quella colonna è una fixture che aspetta di essere scritta — in questo caso, empty_cart e una cart costruita su di essa. E una riga che parte da uno stato che nessun altro condivide non ha affatto bisogno di una fixture: costruisce il proprio stato da sé.
Poni un'altra domanda per ciascuno stato iniziale: crearlo modifica qualcosa al di fuori del test? Un nuovo Cart non modifica nulla. Un codice sconto registrato in un dizionario globale, una connessione aperta, un file scritto — questi invece sì, ed è questo che ti indica che una fixture ha bisogno di una pulizia (yield, più avanti in questo capitolo).
Cosa non testare. Non scrivere test per dimostrare che pytest crea una fixture nuova per ciascun test; questa è una promessa di pytest, e questo capitolo te lo mostra una volta affinché tu possa fidartene. Testa invece la tua pulizia quando ripristina uno stato globale — quel codice è tuo e può contenere errori.
Il resto del capitolo trasforma questo piano in codice.
Una prima fixture
Sposta il setup in una funzione e aggiungi @pytest.fixture sopra di essa:
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.01sNei test nulla chiama cart(). Ciascun test ha semplicemente un parametro chiamato cart, e questa è l'intera richiesta.
Prima di eseguire un test, pytest esamina i nomi dei suoi parametri. Per ciascun nome trova una fixture corrispondente, la esegue e passa ciò che ha restituito come argomento. Il test dichiara cosa gli serve; non lo costruisce. Questo schema ha un nome — dependency injection — ma il meccanismo non è altro che "associare il nome del parametro al nome di una fixture".
Ora il test si legge come una singola riga di intento. E il setup risiede in un unico punto, quindi una modifica a Cart() richiede di modificare una sola funzione.
Ogni test ne riceve una nuova
test_add_one_more aggiunge un quaderno al carrello. I test eseguiti dopo di esso hanno visto un carrello con un quaderno all'interno? No — e vale la pena osservarlo chiaramente, perché è la proprietà che rende sicure le fixture.
Innanzitutto, la versione senza fixture. Una lista in cima al modulo, condivisa da due test:
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.01sIl secondo test è corretto; è fallito a causa di ciò che ha fatto il primo test. Eseguilo da solo e passerà:
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sUn test il cui risultato dipende da quali test sono stati eseguiti prima è uno dei bug più costosi in una suite di test, perché si manifesta solo a volte.
Ora gli stessi due test con la lista proveniente da una fixture. Un print all'interno della fixture mostra quando viene eseguita; -s indica a pytest di non catturare l'output a schermo, così possiamo vederlo:
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" compare due volte. La funzione fixture è stata eseguita una volta per ciascun test che l'ha richiesta, e ogni test ha ricevuto una lista che nessun altro aveva toccato. Questo è il comportamento predefinito e ha un nome, function scope: il valore della fixture vive per la durata di una singola funzione di test. Il capitolo otto mostra come cambiarlo e perché di solito non dovresti farlo.
Le fixture possono usare altre fixture
Una fixture può richiedere un'altra fixture esattamente nello stesso modo in cui lo fa un test — indicandone il nome come parametro:
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 si basa su empty_cart, così un test può richiedere qualsiasi livello di preparazione di cui abbia bisogno. Nessun test chiama nulla; pytest segue i nomi, da test_total a cart e da cart a empty_cart, e li costruisce nell'ordine richiesto dalla catena.
Vederlo in azione — --setup-show
Non devi aggiungere chiamate a print per scoprire quali fixture sono state eseguite. --setup-show stampa ogni operazione di setup e 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.01sLeggilo dall'alto verso il basso. La F indica il function scope — creata per un singolo test e scartata subito dopo. empty_cart viene preparata due volte, una per test, che è la freschezza vista nella sezione precedente. Dove viene usata cart, empty_cart viene preparata per prima, perché cart dipende da essa. E i teardown avvengono nell'ordine opposto rispetto ai setup: l'ultima creata è la prima a essere rimossa.
(Se hai installato dei plugin, potrebbero comparire righe aggiuntive per le loro fixture. Sono cose loro, non tue.)
Finora il teardown non ha fatto nulla di visibile, perché non c'era nulla da annullare. La situazione cambia adesso.
yield — setup e poi pulizia
Alcuni setup lasciano dietro di sé qualcosa che deve essere annullato: una connessione da chiudere, un file da eliminare, un'impostazione globale da ripristinare. Per questo, una fixture usa yield invece di 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.01sTutto ciò che precede yield è setup. Il valore dopo yield è ciò che il test riceve. A quel punto la fixture va in pausa — il test viene eseguito — e quando il test è terminato, pytest riprende la fixture dopo lo yield, e quella parte rimanente è il teardown. (Il . si trova davanti a "connection: close" perché il risultato del test viene stampato nel momento esatto in cui il test finisce, appena prima che inizi il teardown.)
La pulizia risiede nella stessa funzione del setup, poche righe sotto. Non puoi aggiungerne una e dimenticare l'altra senza che la cosa risulti evidente.
Con due fixture con yield, dove una dipende dall'altra, l'ordine è quello a cui aveva accennato --setup-show:
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.01sIl setup procede dal basso verso l'alto lungo la catena delle dipendenze; il teardown torna indietro scendendo. È l'unico ordine che può funzionare: saved_order elimina la propria riga attraverso la connessione, quindi la connessione deve essere ancora aperta quando lo fa.
Il teardown viene eseguito anche se il test fallisce
Perché prendersi il disturbo di usare una fixture per la pulizia, quando il test potrebbe semplicemente ripulire alla fine del suo corpo? Facciamo una prova. OPEN rappresenta qualsiasi cosa un test possa lasciare dietro di sé — una connessione aperta, un file temporaneo, un'impostazione modificata:
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 nasconde i traceback e mantiene solo il riepilogo.) Un errore, due fallimenti. Un assert che fallisce solleva un'eccezione, e sollevare un'eccezione interrompe il test all'istante, quindi la riga di pulizia sottostante non è mai stata eseguita. La connessione è rimasta in OPEN, e il test successivo — che era perfettamente corretto — è fallito a causa di ciò che ha ereditato. In una suite di grandi dimensioni, quel secondo fallimento è quello su cui perderesti un intero pomeriggio.
Ora lo stesso lavoro, ma con la pulizia dopo uno 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.01sUn errore, un fallimento. Il test rotto viene comunque segnalato — la fixture non nasconde nulla — ma il suo disordine non ha contagiato il test successivo.
Questo è il motivo, e la regola che ne consegue: la pulizia non va mai alla fine del corpo di un test. Va dopo lo yield di una fixture.
Una fixture o una semplice funzione?
Una fixture non è l'unico modo per condividere il setup. Anche una funzione ordinaria — ad esempio cart_with(*lines), che costruisce un carrello a partire dalle righe che riceve — funziona bene, e l'esempio completo qui sotto ne fa uso.
Ecco la differenza decisiva: un test chiama una funzione helper e può passarle degli argomenti. Un test non può chiamare una fixture; può solo indicarne il nome. Una fixture produce sempre la stessa cosa per ogni test che la richiede.
Quindi:
- Usa una fixture quando molti test hanno bisogno dello stesso stato iniziale, e in particolare quando quello stato deve essere ripulito successivamente. Solo una fixture garantisce il teardown.
- Usa una funzione helper quando ogni test ha bisogno di un oggetto leggermente diverso — un carrello con queste righe, un utente con quel nome. Gli argomenti esistono appositamente per le funzioni.
Si combinano bene: una fixture può chiamare una funzione helper, come fa il prossimo esempio. (C'è anche un modo per creare una fixture che restituisce una funzione — il pattern "factory" nel capitolo dodici. Non ne hai ancora bisogno.)
Un esempio completo
Un negozio in cui i codici sconto risiedono in un dizionario a livello di modulo. La registrazione di un codice modifica lo stato globale, quindi un test che ne registra uno deve poi rimuoverlo.
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.01sVale la pena notare tre cose.
Primo, ogni test richiede solo ciò che usa. test_small_cart voleva un carrello diverso, quindi non ha richiesto affatto cart; ha chiamato la funzione helper con le proprie righe — e --setup-show conferma che cart non è mai stata costruita per esso. test_zero_quantity_is_refused ha bisogno solo di un carrello vuoto e di una chiamata, quindi non usa alcuna fixture; corrisponde alla riga del piano "uno stato iniziale che nessun altro condivide".
Secondo, test_code_is_gone_afterwards è un test della fixture. Viene eseguito dopo test_summer_discount e controlla che SUMMER non esista più. Per capire perché è importante, modifica summer_code usando return "SUMMER" al posto dello yield e della riga di pulizia:
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.01sIl codice è trapelato dal test che lo aveva registrato verso quello successivo. Senza quel controllo, la fuga sarebbe passata inosservata.
Terzo, la funzione helper e la fixture lavorano insieme. cart_with sa come costruire un carrello; la fixture cart decide quale carrello viene condiviso dalla maggior parte dei test.
Quando qualcosa va storto
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.Il nome di un parametro non corrisponde ad alcuna fixture. O c'è un errore di battitura — qui crat invece di cart, che si trova proprio lì nell'elenco — o alla funzione manca la riga @pytest.fixture, nel qual caso sarà assente dall'elenco. Nota la E e ERROR, non F e FAILED: il test non è mai partito.
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.Il test ha scritto cart() nel proprio corpo. Una fixture si richiede indicandola come parametro, mai chiamandola. Se vuoi davvero chiamarla con i tuoi argomenti, ciò che ti serve è una funzione helper.
ERROR at setup of test_total invece di un fallimento L'eccezione è stata sollevata all'interno della fixture, prima dell'esecuzione del test — ad esempio ValueError: quantity must be at least 1: 0 a causa di una chiamata errata ad add() nel setup. Una E indica che "la preparazione si è rotta"; una F indica che "l'asserzione era falsa". Guarda prima nella fixture.
fixture function has more than one 'yield' Una fixture produce un valore con yield esattamente una volta. Il setup va sopra lo yield, la pulizia va sotto; per due valori, produci una tupla con yield oppure scrivi due fixture.
Un test passa da solo e fallisce in mezzo agli altri C'è qualcosa di condiviso tra i test — una lista o un dizionario a livello di modulo, oppure una variabile globale che una fixture ha modificato e non ha ripristinato. Creala all'interno di una fixture e, se si tratta di uno stato globale, annulla la modifica dopo lo yield.
La pulizia non è stata eseguita La pulizia si trova nel corpo del test dopo un assert che ha fallito, oppure la fixture usa return. Spostala in una fixture con yield, dopo lo yield.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Entrambi i test richiedono la stessa fixture ed entrambi aggiungono elementi alla lista. Cosa dice l'ultima riga di 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 vede ['pen', 'bag']
- B1 passed, 1 error
- C2 passed
- D2 failed
Questo viene eseguito con pytest -q -s. Tralasciando i punti e il riepilogo di pytest, in quale ordine vengono stampate le tre righe?
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
Questo test fallisce. Dov'è il problema?
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- ANon è possibile usare `len()` su una fixture
- BLa fixture necessita di `yield`, non di `return`
- CLa fixture deve restituire una tupla
- DIl test chiama direttamente la fixture; dovrebbe invece accettare `cart` come parametro
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 library.py con una lista a livello di modulo LOG = [] e una classe Library: add(title); borrow(title), che solleva ValueError se il titolo non è sullo scaffale e altrimenti lo rimuove e aggiunge "borrow <title>" a LOG; give_back(title); e available(), che restituisce una lista ordinata dei titoli sullo scaffale.
Poi scrivi test_library.py. Pianificalo prima — una tabella con la colonna dello stato iniziale, come all'inizio di questo capitolo — e poi scrivi:
- Una fixture
libraryche restituisce unaLibrarycon tre libri e almeno tre test che la utilizzano — uno dei quali prende in prestito un libro, in modo che un test successivo possa mostrare che lo scaffale è di nuovo pieno - Una fixture
borrowedche dipende dalibrary, prende in prestito un libro e ne restituisce il titolo, con due test che la utilizzano - Una fixture con
yieldchiamataclean_logche svuotaLOGprima del test e di nuovo dopo di esso; un test che la utilizza e un test successivo che asserisceLOG == [] - Una semplice funzione helper
library_with(*titles), usata dalibrarye da un test che ha bisogno di uno scaffale vuoto
Esegui pytest -q, poi pytest -q --setup-show, e controlla ogni riga rispetto a quanto ti aspettavi.
Infine, introduci un errore di proposito: in clean_log, sostituisci lo yield e la riga successiva con return LOG. Quale test fallisce, ed è quello che ha usato la fixture?
Soluzione
Prima di tutto il piano:
| Caso | Stato iniziale | Azione | Atteso | |---|---|---|---| | Tre libri sullo scaffale | Dune, Emma, Ulysses | available() | tutti e tre, ordinati | | Il prestito rimuove un libro | stesso | borrow("Dune") | Emma, Ulysses | | Nessuna fuga tra i test | stesso | — | ancora tre | | Impossibile prendere in prestito due volte | stesso, Emma in prestito | borrow("Emma") | ValueError | | Restituzione | stesso, Emma in prestito | give_back("Emma") | Emma sullo scaffale | | Il prestito viene registrato | stesso, LOG vuoto | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | Log ripulito | dopo il test sopra | — | LOG == [] | | Caso limite: scaffale vuoto | nessun libro | available() | [] |
Due stati iniziali si ripetono — lo scaffale con tre libri e lo scaffale con Emma fuori — quindi sono diventati delle fixture. Il log vuoto serve una sola volta, ma tocca uno stato globale che deve essere ripristinato in seguito, e solo una fixture garantisce questo ripristino; quindi anch'esso è una fixture. Lo scaffale vuoto serve una sola volta e non lascia nulla dietro di sé, quindi usa la funzione 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.01sCon --setup-show vedrai SETUP F library una volta per ciascuno dei sei test che la usano, borrowed impostata dopo library e smontata prima di essa, e nessuna riga di fixture per gli ultimi due test.
E l'esperimento, con return LOG al posto di 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.01sLe decisioni, una alla volta:
libraryusareturn, non fayield. Una nuovaLibrarynon modifica nulla all'esterno del test, quindi non c'è nulla da annullare.yieldserve per le fixture che hanno qualcosa da ripulire, non è una scelta di stile.test_next_test_still_sees_threeè lì di proposito. Viene eseguito subito dopo un test che ha preso in prestitoDune, e passa perchélibraryè stata creata di nuovo. In--setup-showpuoi verificarlo:SETUP F libraryuna volta per test.borrowedrestituisce il titolo. I test usanoborrowedinvece di ripetere"Emma", così se la fixture cambia il libro preso in prestito, i test si adeguano di conseguenza. E poichéborroweddipende dalibrary, entrambi i test che richiedono entrambe le fixture ricevono la stessa libreria — quella da cui è stata presa Emma.clean_logripulisce da entrambi i lati delloyield. Prima, perché altri test che chiamanoborrow()—test_borrow_removes_the_book, la fixtureborrowed— hanno già scritto inLOG; il test deve iniziare da uno stato noto. Dopo, affinché nulla trapeli al test successivo.- *L'esperimento fallisce nell'altro test.*
test_borrow_is_loggedcontinua a passare;test_log_is_empty_afterwards, che non ha fatto nulla di sbagliato, fallisce. È così che si presenta sempre una pulizia mancante: il fallimento si manifesta altrove. Ed è esattamente il motivo per cui la soluzione include un test che verifica la pulizia. library_withè una funzione helper, non una fixture, perché l'unico test che la usa desidera uno scaffale diverso, e solo una funzione può accettare argomenti. Anchelibraryla chiama, così il modo in cui viene popolata una libreria risiede in un unico punto.
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