Scope delle fixture e conftest.py — condividere il setup in sicurezza
Crea una volta sola un setup costoso e condividilo: i cinque scope, --setup-show, i rischi dello stato condiviso e ScopeMismatch, conftest.py per usare le fixture senza import e gli override per directory, cosa costa autouse e l'ordine di creazione delle fixture.
Come vuoi imparare?
Il problema che stiamo risolvendo
Nel capitolo precedente le fixture venivano create da zero per ogni test. Questo è il comportamento predefinito corretto — finché ciò che viene creato non diventa costoso. Ecco una connessione a un database che richiede mezzo secondo per aprirsi, proprio come potrebbe accadere con l'handshake di un server reale:
test_users.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT)")
return conn
@pytest.fixture
def db():
conn = connect()
yield conn
conn.close()
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
assert name == "ravi"pytest -q --durations=3 riporta dove è stato impiegato il tempo:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
0.50s setup test_users.py::test_insert_one
0.50s setup test_users.py::test_names_are_text
3 passed in 1.65sTre test, tre connessioni, un secondo e mezzo speso soltanto per stabilire la connessione. Con trecento test sono due minuti e mezzo persi nel nulla, e una suite così lenta finisce per non essere più eseguita.
La domanda a cui questo capitolo risponde è: come creare qualcosa una sola volta e condividerlo — senza che i test che lo condividono si intralcino a vicenda?
Alla fine di questo capitolo sarai in grado di
- Scegliere lo scope di una fixture:
function,class,module,packageosession - Osservare la creazione e la distruzione delle fixture con
--setup-show - Spiegare perché una fixture con scope ampio che mantiene uno stato mutabile rende i test dipendenti l'uno dall'altro — e risolvere il problema
- Leggere un errore
ScopeMismatche sapere quale fixture modificare - Condividere le fixture tramite
conftest.py, ed eseguire l'override di una di esse per una singola directory - Spiegare quanto costa
autouse=True, ed elencare ogni fixture disponibile con--fixtures - Prevedere l'ordine in cui le fixture vengono create
Prerequisiti: fixture.
Prima di scrivere il test
Lo scope non è qualcosa che si aggiunge alla fine per velocizzare una suite lenta. Si decide prima ancora di scrivere il primo test, rispondendo a due domande per ogni elemento del setup: quanto costa crearlo, e un test lo modifica? Rispondi prima a queste domande, e lo scope corretto ne sarà la conseguenza naturale.
Il contratto. Il codice sotto test qui è una piccola tabella di utenti. Cosa promette: un database nuovo non ha utenti; l'inserimento di un utente aggiunge esattamente una riga; il nome che inserisci è il nome che ottieni indietro. Ciascuna di queste promesse deve valere a prescindere da quali test siano stati eseguiti prima. È proprio quest'ultima clausola il vero fulcro di questo capitolo.
Cosa deve essere pronto. Un ambiente virtuale con pytest installato; sqlite3, incluso direttamente in Python, quindi non c'è nulla da installare; il codice sotto test importabile dai file di test (qui, file affiancati in un'unica directory, eseguiti da quella stessa directory); e una decisione su dove risiederanno le fixture condivise — un file conftest.py alla radice dell'albero dei test.
Il piano dei test. Tre casi, ciascuno dei quali deve passare da solo e in qualsiasi ordine:
| Caso | Input | Atteso | |---|---|---| | database nuovo | nessun inserimento | COUNT(*) è 0 | | un inserimento | 'asha' | COUNT(*) è 1 | | round-trip del nome | 'ravi' | il primo nome letto è 'ravi' |
Il piano del setup. Poi lo stesso tipo di tabella per le risorse su cui poggiano i test:
| risorsa | costo di creazione | un test la modifica? | scope | |---|---|---|---| | connessione + tabella | lento (mezzo secondo) | no | ampio — module o session | | le righe nella tabella | nullo | sì, a ogni inserimento | function — ripristinato dopo ogni test |
Leggi attentamente l'ultima colonna: un solo oggetto, la connessione, racchiude sia una cosa lenta sia una cosa che cambia. Ecco perché il capitolo finisce per utilizzare due fixture — una con scope ampio, una con scope ristretto — invece di una sola.
Cosa non testare. Non sqlite3 in sé; è testato molto più a fondo di quanto lo sarà mai il tuo codice. Non le fixture direttamente — una fixture viene verificata dai test che la utilizzano e leggendo l'output di --setup-show. E non il ritardo di mezzo secondo: la velocità si misura con --durations, non si verifica con un assert.
scope= — una volta per cosa?
Lo scope di una fixture indica quanto a lungo vive un valore prima che pytest lo scarti e ne crei un altro. Il valore predefinito è function: uno per ciascun test. Modifica una sola riga:
@pytest.fixture(scope="module")
def db():
conn = connect()
yield conn
conn.close()pytest -q:
..F [100%]
=================================== FAILURES ===================================
_____________________________ test_names_are_text ______________________________
db = <sqlite3.Connection object at 0x79350aa45c60>
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
> assert name == "ravi"
E AssertionError: assert 'asha' == 'ravi'
E
E - ravi
E + asha
test_users.py:35: AssertionError
=========================== short test summary info ============================
FAILED test_users.py::test_names_are_text - AssertionError: assert 'asha' == ...
1 failed, 2 passed in 0.52sSono successe due cose. Il tempo di esecuzione è passato da 1,65 secondi a 0,52 — una sola connessione invece di tre. E un test che un momento fa passava, adesso fallisce. Tieni a mente questo fallimento: gli dedicheremo un'intera sezione più avanti. Prima vediamo gli scope.
Ce ne sono cinque, dal più ristretto al più ampio:
| scope | un valore per | distrutto dopo | |---|---|---| | function | test (il valore predefinito) | ciascun test | | class | classe di test | l'ultimo test nella classe | | module | file di test | l'ultimo test nel file | | package | directory in cui la fixture è definita | l'ultimo test in quella directory, sottodirectory incluse | | session | l'intera esecuzione di pytest | l'ultimo test dell'esecuzione |
Un contatore rende evidente la differenza. Ciascuna fixture qui sotto conta quante volte è stata creata:
test_scopes.py:
from collections import Counter
import pytest
made = Counter()
@pytest.fixture(scope="session")
def per_session():
made["session"] += 1
@pytest.fixture(scope="module")
def per_module():
made["module"] += 1
@pytest.fixture(scope="class")
def per_class():
made["class"] += 1
@pytest.fixture
def per_test():
made["function"] += 1
class TestFirst:
def test_a(self, per_session, per_module, per_class, per_test):
pass
def test_b(self, per_session, per_module, per_class, per_test):
pass
class TestSecond:
def test_c(self, per_session, per_module, per_class, per_test):
pass
def test_report():
print(dict(made))pytest -q -s (l'opzione -s lascia passare l'output di print):
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01sTre test hanno usato le fixture. La fixture con scope di funzione è stata creata tre volte, quella di classe due volte (due classi), e le fixture di modulo e di sessione una volta ciascuna.
Vederlo in azione: --setup-show
Usare un contatore funziona, ma pytest può tracciare l'intera sequenza temporale al posto tuo. Esegui lo stesso file con pytest -q --setup-show:
SETUP S per_session
SETUP M per_module
SETUP C per_class
SETUP F per_test
test_scopes.py::TestFirst::test_a (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
SETUP F per_test
test_scopes.py::TestFirst::test_b (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
SETUP C per_class
SETUP F per_test
test_scopes.py::TestSecond::test_c (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
test_scopes.py::test_report .
TEARDOWN M per_module
TEARDOWN S per_session
4 passed in 0.01sLa lettera dopo SETUP indica lo scope — Session, Package, Module, Class, Function — e l'indentazione mostra come sono annidati. Leggilo dall'alto verso il basso e vedrai esattamente quando ogni valore nasce e quando muore. Ogni volta che non hai la certezza di cosa stia facendo una fixture, questa è la prima opzione da usare.
Una fixture condivisa condivide il suo stato
Torniamo al fallimento. Con scope="module" tutti e tre i test hanno ricevuto la stessa connessione. test_insert_one ha inserito 'asha' e nulla l'ha rimossa. Poi test_names_are_text ha inserito 'ravi', ha richiesto il primo nome e ha ottenuto 'asha'.
Il segnale più evidente di questo bug è un test che passa se eseguito da solo. pytest -q test_users.py::test_names_are_text:
. [100%]
1 passed in 0.50sDa solo passa; insieme agli altri fallisce. I test che dipendono da ciò che è stato eseguito prima di loro sono peggiori dei test lenti: falliscono in un ordine e passano in un altro, e ogni volta fanno perdere un pomeriggio intero.
La regola: amplia lo scope della risorsa costosa, mantieni ristretto lo scope dello stato. Dividi la fixture in due — una con scope ampio che gestisce la connessione, e una con scope di funzione che ripulisce dopo ogni test:
@pytest.fixture(scope="module")
def connection():
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
yield connection
connection.rollback() # undo whatever this test wroteI test non cambiano — continuano a richiedere db. pytest -q --durations=3:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
(2 durations < 0.005s hidden. Use -vv to show these durations.)
3 passed in 0.51sUna sola connessione, tre test che passano. sqlite3 apre una transazione al primo INSERT, e rollback() annulla l'intera transazione, così ogni test inizia con una tabella vuota.
Perché mettere la pulizia in una fixture, invece di iniziare ogni test con DELETE FROM users? Perché una pulizia che ogni test deve ricordarsi di fare è una pulizia che prima o poi un test dimenticherà — e il test che la dimentica non è mai quello che fallisce; a fallire è quello successivo. Nella fixture, la pulizia avviene dopo ogni test che richiede db, compresi i test che scriverà l'anno prossimo qualcuno che non ha mai letto questo file. E perché rollback() invece di DELETE? Un rollback annulla tutto ciò che il test ha scritto, in ogni tabella, senza che la fixture debba sapere quali siano state quelle scritture.
--setup-show mostra questa divisione:
SETUP M connection
SETUP F db (fixtures used: connection)
test_users.py::test_starts_empty (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_insert_one (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_names_are_text (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN M connection
3 passed in 0.51sUna fixture con scope di funzione può richiedere liberamente una fixture con scope più ampio, proprio come qui db richiede connection. La direzione opposta non è consentita.
ScopeMismatch — lo scope ampio non può richiederne uno ristretto
test_mismatch.py:
import sqlite3
import pytest
@pytest.fixture
def db_name():
return ":memory:"
@pytest.fixture(scope="session")
def connection(db_name):
conn = sqlite3.connect(db_name)
yield conn
conn.close()
def test_connects(connection):
assert connection.execute("SELECT 1").fetchone() == (1,)pytest -q:
E [100%]
==================================== ERRORS ====================================
_______________________ ERROR at setup of test_connects ________________________
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Requesting fixture stack:
test_mismatch.py:11: def connection(db_name)
Requested fixture:
test_mismatch.py:6: def db_name()
=========================== short test summary info ============================
ERROR test_mismatch.py::test_connects - Failed: ScopeMismatch: You tried to a...
1 error in 0.01sRifletti su cosa comporterebbe questa richiesta. connection vive per l'intera esecuzione; db_name viene scartata dopo il primo test. Il valore a livello di sessione continuerebbe a fare riferimento a qualcosa che, secondo le sue stesse regole, non esiste più. Per questo pytest rifiuta la richiesta — e nota che si tratta di un errore (Error) in fase di setup, non di un fallimento (Failure): il test non è mai stato nemmeno eseguito.
Il messaggio indica entrambe le fixture ed entrambe le righe. La correzione è sempre la stessa: rendi la fixture richiesta ampia almeno quanto quella che la richiede — in questo caso @pytest.fixture(scope="session") su db_name — oppure restringi lo scope della fixture richiedente.
conftest.py — fixture senza import
Non appena un secondo file di test ha bisogno di db, copiare la fixture è la soluzione sbagliata. Spostala in un file chiamato esattamente conftest.py. Pytest carica quel file automaticamente, e ogni test nella stessa directory e nelle sue sottodirectory può richiederne le fixture — senza alcun import.
Un progetto con una sottodirectory:
conftest.py
test_users.py
reports/
conftest.py
test_reports.pyconftest.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, city TEXT)")
return conn
@pytest.fixture(scope="session")
def connection():
"""One database connection, shared by the whole run."""
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every write is rolled back after the test."""
yield connection
connection.rollback()test_users.py — nessun import:
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1Lo scope ora è session: un'unica connessione per ogni file del progetto.
Eseguire l'override di una fixture per una singola directory
I test in reports/ richiedono tutti un database che contenga già degli utenti. Un file conftest.py in quella directory può definire una fixture con lo stesso nome; per i test in reports/ vince la definizione più vicina. E se richiede il suo stesso nome come parametro, riceve la versione del livello genitore:
reports/conftest.py:
import pytest
@pytest.fixture
def db(db):
"""The parent db, with three users already in it."""
db.executemany(
"INSERT INTO users VALUES (?, ?)",
[("asha", "Dhaka"), ("ravi", "Pune"), ("omar", "Cairo")],
)
return dbreports/test_reports.py:
def test_user_count(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 3
def test_cities_sorted(db):
rows = db.execute("SELECT city FROM users ORDER BY city").fetchall()
assert [city for (city,) in rows] == ["Cairo", "Dhaka", "Pune"]pytest -v:
============================= test session starts ==============================
collecting ... collected 4 items
reports/test_reports.py::test_user_count PASSED [ 25%]
reports/test_reports.py::test_cities_sorted PASSED [ 50%]
test_users.py::test_starts_empty PASSED [ 75%]
test_users.py::test_insert_one PASSED [100%]
============================== 4 passed in 0.51s ===============================Osserva l'ordine. I test di reports/ sono stati eseguiti per primi e hanno inserito tre utenti ogni volta — eppure test_starts_empty, eseguito dopo di loro, ha comunque trovato una tabella vuota. Anche le righe inserite dall'override sono state annullate dal rollback del db genitore, perché l'override è costruito sopra di esso. --setup-show mostra i due livelli, entrambi chiamati db:
SETUP S connection
SETUP F db (fixtures used: connection)
SETUP F db (fixtures used: db)
reports/test_reports.py::test_user_count (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN F db(Questo è il primo test; gli altri ripetono lo stesso schema.) L'override ha effetto solo verso il basso: i test nella directory principale non lo vedono mai.
--fixtures — cosa posso richiedere qui?
Con le fixture distribuite su più file conftest.py, serve un modo per vedere cosa può richiedere un determinato test. pytest --fixtures reports elenca tutto ciò che è visibile da quella directory. L'elenco inizia con le fixture integrate di pytest (capitolo nove) e quelle dei plugin installati; le tue compaiono alla fine:
------------------------ fixtures defined from conftest ------------------------
connection [session scope] -- conftest.py:15
One database connection, shared by the whole run.
db -- conftest.py:23
The shared connection; every write is rolled back after the test.
db -- reports/conftest.py:5
The parent db, with three users already in it.Ogni voce indica il nome, lo scope (quando non è function), il file e la riga, e le prime righe della docstring. Quest'ultimo dettaglio è il motivo per cui vale la pena scrivere una docstring di una riga per ogni fixture condivisa: è la documentazione che le persone leggeranno davvero.
autouse=True — e quanto costa
Una fixture contrassegnata con autouse=True viene usata da ogni test nel suo raggio d'azione, che il test la richieda o meno. Aggiunta al conftest.py di livello superiore, questa fixture controlla che nessun test lasci righe indietro:
@pytest.fixture(autouse=True)
def no_rows_left(connection):
"""After every test, check that the users table is empty again."""
yield
count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0, f"{count} row(s) left behind"Funziona: un test che scrive direttamente tramite connection saltando il rollback viene intercettato durante il teardown. Ma ora aggiungi un test che non ha nulla a che fare con i database:
test_text.py:
def slugify(title):
return title.strip().lower().replace(" ", "-")
def test_slugify():
assert slugify(" Hello World ") == "hello-world"pytest -q --setup-show test_text.py:
SETUP S connection
SETUP F no_rows_left (fixtures used: connection)
test_text.py::test_slugify (fixtures used: connection, no_rows_left) .
TEARDOWN F no_rows_left
TEARDOWN S connection
1 passed in 0.50sUn test sulle stringhe di una sola riga adesso apre un database e richiede mezzo secondo. Questo è il costo di autouse: è invisibile — nulla in test_slugify fa pensare che ci sia di mezzo un database — ed è incondizionato — viene eseguito per ogni test sotto il suo conftest.py, compresi quelli che non ne hanno bisogno. Riserva autouse per operazioni economiche e realmente universali, e collocalo nel conftest.py più specifico che copre i test che ne hanno bisogno.
Quale fixture viene creata per prima?
Quando un test ha bisogno di più fixture, pytest le ordina in base a tre regole:
- Prima lo scope più ampio. Session prima di module prima di function, a prescindere dall'ordine nella firma del test.
- All'interno dello stesso scope, prima le fixture
autouse. - Le dipendenze prima delle fixture che ne hanno bisogno.
test_order.py:
import pytest
log = []
@pytest.fixture(scope="session")
def server():
log.append("server")
@pytest.fixture(scope="module")
def connection(server):
log.append("connection")
@pytest.fixture
def user():
log.append("user")
@pytest.fixture
def cart(user):
log.append("cart")
@pytest.fixture(autouse=True)
def audit():
log.append("audit")
def test_order(cart, connection):
print(log)pytest -q -s:
['server', 'connection', 'audit', 'user', 'cart']
.
1 passed in 0.01sIl test ha richiesto cart prima di connection, ma connection — con scope di modulo — è stata creata per prima, dopo la propria dipendenza server. Tra le fixture con scope di funzione, la fixture autouse audit è arrivata per prima, seguita da user, di cui cart ha bisogno. Il teardown viene eseguito esattamente nell'ordine inverso. Al di là di queste regole, non fare affidamento sull'ordine: se una fixture deve esistere prima di un'altra, trasformala in una dipendenza indicandola come parametro.
Un esempio completo
Un piccolo modulo per un negozio, testato su un unico database in memoria condiviso.
shop.py:
def add_order(conn, customer, total):
conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)", (customer, total)
)
def revenue(conn):
return conn.execute("SELECT COALESCE(SUM(total), 0) FROM orders").fetchone()[0]
def top_customer(conn):
row = conn.execute(
"SELECT customer FROM orders GROUP BY customer "
"ORDER BY SUM(total) DESC LIMIT 1"
).fetchone()
return row[0] if row else Noneconftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the orders table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (customer TEXT, total REAL)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; whatever a test writes is rolled back."""
yield connection
connection.rollback()test_orders.py:
from shop import add_order, revenue, top_customer
def test_revenue_starts_at_zero(db):
assert revenue(db) == 0
def test_add_order_counts_toward_revenue(db):
add_order(db, "asha", 250.0)
assert revenue(db) == 250.0
def test_no_top_customer_without_orders(db):
assert top_customer(db) is Nonereports/conftest.py:
import pytest
from shop import add_order
@pytest.fixture
def db(db):
"""The parent db, with four orders from three customers already in it."""
add_order(db, "asha", 250.0)
add_order(db, "ravi", 900.0)
add_order(db, "omar", 120.0)
add_order(db, "asha", 700.0)
return dbreports/test_reports.py:
from shop import revenue, top_customer
def test_total_revenue(db):
assert revenue(db) == 1970.0
def test_top_customer_by_total(db):
assert top_customer(db) == "asha"pytest -v --durations=1:
============================= test session starts ==============================
collecting ... collected 5 items
reports/test_reports.py::test_total_revenue PASSED [ 20%]
reports/test_reports.py::test_top_customer_by_total PASSED [ 40%]
test_orders.py::test_revenue_starts_at_zero PASSED [ 60%]
test_orders.py::test_add_order_counts_toward_revenue PASSED [ 80%]
test_orders.py::test_no_top_customer_without_orders PASSED [100%]
============================= slowest 1 durations ==============================
0.50s setup reports/test_reports.py::test_total_revenue
============================== 5 passed in 0.51s ===============================Ci sono tre aspetti degni di nota.
Primo: il setup lento è avvenuto una sola volta, al primo test dell'intera sessione, e in nessun altro punto. È qui che lo scope di sessione dà i suoi frutti.
Secondo: i test in test_orders.py sono stati eseguiti dopo che i test dei report avevano inserito otto ordini in totale, e hanno comunque rilevato un fatturato pari a zero. Sostituisci connection.rollback() con pass ed esegui di nuovo: il costo dello stato condiviso si manifesterà immediatamente:
FAILED test_orders.py::test_revenue_starts_at_zero - assert 3940.0 == 0
FAILED test_orders.py::test_add_order_counts_toward_revenue - assert 4190.0 =...
FAILED test_orders.py::test_no_top_customer_without_orders - AssertionError: ...
3 failed, 2 passed in 0.52sTerzo: nessun file di test importa una fixture. test_orders.py importa shop, il codice sotto test, e null'altro; le fixture arrivano per nome dal conftest.py più vicino.
Quando qualcosa va storto
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Una fixture con scope ampio ne ha richiesta una con scope più ristretto. Amplia la fixture richiesta (qui db_name) almeno fino allo scope di quella richiedente, oppure restringi quella richiedente. Le due righe sotto Requesting fixture stack indicano chi è chi.
AssertionError: assert 'asha' == 'ravi' — ma il test passa se eseguito da solo C'è una fuga di stato tra i test attraverso una fixture con scope più ampio di function. Esegui con --setup-show per vedere quale fixture è condivisa, poi dividila: mantieni ampio lo scope dell'oggetto costoso e aggiungi una fixture con scope di funzione che annulla le modifiche di ciascun test.
fixture 'report_title' not found La fixture esiste, ma non dove questo test possa vederla. Un file conftest.py serve solo la propria directory e quelle sottostanti — una fixture in reports/conftest.py è invisibile a un test nella directory superiore. Spostala in un conftest.py che copra entrambe le directory. pytest --fixtures path/to/test_file.py mostra esattamente cosa può vedere quel file.
Le righe di un test compaiono in un altro anche se db esegue il rollback Qualcosa ha chiamato commit(). Un rollback annulla solo ciò che non è stato confermato con un commit, quindi il codice che effettua un commit — o un test che usa direttamente connection invece di db — scrive in modo permanente. Un controllo di sicurezza come la fixture no_rows_left trasforma questa anomalia in un chiaro ERROR at teardown ... AssertionError: 1 row(s) left behind.
Un test che non tocca alcun database è diventato lento Una fixture autouse da qualche parte sopra di esso dipende dalla risorsa costosa. Eseguire pytest --setup-show su quel test elenca ogni fixture effettivamente utilizzata, comprese quelle che il test non ha mai richiesto.
from conftest import db in un file di test Non importare da conftest.py. Pytest ti fornisce già le sue fixture cercandole per nome, e import conftest carica il primo conftest.py che Python trova. Nel progetto precedente, aggiungendo quella riga a test_users.py di livello superiore è stato importato quello in reports/ — l'override con i dati di test precaricati — e test_starts_empty è fallito con assert 3 == 0. Inoltre, una fixture importata viene considerata come definita nel file di test stesso, scavalcando qualsiasi conftest.py. Richiedi le fixture indicandole come parametri, e in nessun altro modo.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Esegui con pytest -q -s. Cosa stampa test_report?
import pytest
calls = {"module": 0, "function": 0}
@pytest.fixture(scope="module")
def conn():
calls["module"] += 1
@pytest.fixture
def row(conn):
calls["function"] += 1
def test_a(row):
pass
def test_b(row):
pass
def test_c(conn):
pass
def test_report():
print(calls)- A{'module': 1, 'function': 2}
- B{'module': 3, 'function': 2}
- C{'module': 1, 'function': 3}
- D{'module': 3, 'function': 3}
Questo test genera l'errore ScopeMismatch prima dell'esecuzione. Qual è la correzione minima valida?
import pytest
@pytest.fixture
def settings():
return {"db": ":memory:"}
@pytest.fixture(scope="session")
def connection(settings):
return settings["db"]
def test_connects(connection):
assert connection == ":memory:"- AMettere `test_connects` all'interno di una classe
- BAggiungere `autouse=True` a `connection`
- CSpostare `settings` sotto `connection` nel file
- DAssegnare a `settings` `@pytest.fixture(scope="session")`
Entrambi i file conftest.py definiscono db. Quale db riceve test_count in reports/test_reports.py?
# conftest.py
@pytest.fixture
def db(connection):
yield connection
connection.rollback()
# reports/conftest.py
@pytest.fixture
def db(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
return db
# reports/test_reports.py
def test_count(db):
...- AQuella di primo livello, perché viene caricata per prima
- BQuella in `reports/`, ma il suo parametro `db` riceve se stesso, entrando in una ricorsione infinita
- CQuella in `reports/`, che riceve quella di livello superiore e aggiunge una riga sopra di essa
- DNessuna delle due: due fixture con lo stesso nome producono un errore
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
Costruisci un piccolo progetto attorno a library.py con due funzioni: add_book(conn, title, author) e books_by(conn, author), che restituisce una lista ordinata di titoli.
- In un file
conftest.pydi primo livello, scrivi una fixtureconnectioncon scope di sessione che apresqlite3.connect(":memory:"), crea una tabellabooks (title TEXT, author TEXT)e attende mezzo secondo simulando un server lento. Aggiungi una docstring. - Aggiungi sopra di essa una fixture
dbcon scope di funzione che esegue il rollback dopo ogni test. - Scrivi
test_library.pycon tre test che aggiungono ciascuno dei libri e verificanobooks_by. Eseguipytest -q --durations=3e conferma che il mezzo secondo compaia una sola volta. - Crea
search/conftest.pyche esegue l'override didbper iniziare con cinque libri già presenti al suo interno, esearch/test_search.pycon due test che fanno affidamento su di essi. - Esegui
pytest --setup-showe individua i due livelli didbnell'output per i test disearch/. Poi eseguipytest --fixtures searche verifica che compaiano entrambe le docstring.
Poi introduci un errore di proposito, due volte. Prima sostituisci la riga con rollback() con pass ed esegui l'intera suite: quali test falliscono, e ciascuno di essi passa se eseguito da solo? Secondo, fai dipendere connection da una fixture db_name con scope di funzione, e leggi il messaggio ScopeMismatch riga per riga.
Questi cinque minuti sono il succo dell'intero capitolo. Lo scope è una decisione legata alle prestazioni, ma i bug che crea sono problemi di isolamento — e l'unico modo per imparare a riconoscerli è provocarne qualcuno di persona.
Soluzione
La struttura del progetto:
library.py
conftest.py
test_library.py
search/
conftest.py
test_search.pylibrary.py:
def add_book(conn, title, author):
conn.execute("INSERT INTO books VALUES (?, ?)", (title, author))
def books_by(conn, author):
rows = conn.execute(
"SELECT title FROM books WHERE author = ? ORDER BY title", (author,)
).fetchall()
return [title for (title,) in rows]conftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the books table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE books (title TEXT, author TEXT)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every test's writes are rolled back."""
yield connection
connection.rollback()test_library.py:
from library import add_book, books_by
def test_unknown_author_has_no_books(db):
assert books_by(db, "Nobody") == []
def test_one_book(db):
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali"]
def test_titles_come_back_sorted(db):
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali", "Kabuliwala"]search/conftest.py:
import pytest
from library import add_book
@pytest.fixture
def db(db):
"""The parent db, with five books by three authors already in it."""
add_book(db, "Gitanjali", "Tagore")
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Godaan", "Premchand")
add_book(db, "Nirmala", "Premchand")
add_book(db, "Palace Walk", "Mahfouz")
return dbsearch/test_search.py:
from library import books_by
def test_two_books_by_premchand(db):
assert books_by(db, "Premchand") == ["Godaan", "Nirmala"]
def test_author_match_is_exact(db):
assert books_by(db, "tagore") == []pytest -q --durations=3:
..... [100%]
============================= slowest 3 durations ==============================
0.50s setup search/test_search.py::test_two_books_by_premchand
(2 durations < 0.005s hidden. Use -vv to show these durations.)
5 passed in 0.51spytest -q --setup-show search mostra, per ciascuno dei due test, la stessa situazione vista nel capitolo: SETUP F db (fixtures used: connection) seguito da SETUP F db (fixtures used: db) — il livello genitore, poi l'override sopra di esso. pytest --fixtures search elenca connection con [session scope], entrambe le fixture db con file e riga, e tutte e tre le docstring.
Il primo esperimento — sostituire rollback() con pass — produce:
FAILED test_library.py::test_one_book - AssertionError: assert ['Gitanjali',....
FAILED test_library.py::test_titles_come_back_sorted - AssertionError: assert...
2 failed, 3 passed in 0.52sed eseguendo pytest -q test_library.py::test_one_book da solo:
. [100%]
1 passed in 0.50sI test in search/ sono stati eseguiti per primi e hanno lasciato cinque libri nel database, quindi test_one_book ha trovato due titoli di Tagore invece di uno. Da solo passa. Questa è la tipica firma di una fuga di stato, esattamente come visto nel capitolo. Il secondo esperimento trasforma tutti e cinque i test in E, ciascuno con lo stesso errore ScopeMismatch incontrato in precedenza, che ora punta a conftest.py: def connection(db_name) è la funzione richiedente, mentre def db_name() è la fixture con scope troppo ristretto.
Le motivazioni di ogni decisione:
connectionha scope di sessione perché è l'unica operazione lenta, e nulla di ciò che fa un test modifica la connessione in sé — cambiano solo le righe al suo interno.dbha scope di funzione perché le righe cambiano. Il suo unico compito è eseguire il rollback dopo ogni test; per i test è sufficiente conoscerne il nome, senza dover sapere altro.- I test richiedono
db, maiconnection. Un test che usasse direttamenteconnectionsalterebbe il rollback e lascerebbe trapelare le proprie righe nel test successivo. - L'override in
search/richiededb, nonconnection, così i libri precaricati diventano parte del lavoro del test stesso e il rollback del genitore li rimuove insieme a tutto il resto. Se la logica di pulizia indbdovesse cambiare, l'override erediterà la modifica senza dover essere toccato. test_author_match_is_exactè nel piano di proposito: definisce esplicitamente che"tagore"non corrisponde a"Tagore", un limite che un giorno qualcuno potrebbe voler cambiare — e che dovrebbe cambiare intenzionalmente, guidato da un test che fallisce.- Ogni fixture condivisa ha una docstring, perché
pytest --fixturesè lo strumento con cui la persona successiva scoprirà cosa può richiedere.
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