Fixture-scope en conftest.py — setup veilig delen
Bouw trage setup één keer op en deel deze: de vijf scopes, --setup-show, het gevaar van gedeelde toestand en ScopeMismatch, conftest.py voor fixtures zonder imports en overrides per map, wat autouse kost en de volgorde waarin fixtures worden aangemaakt.
Hoe wil je leren?
Het probleem dat we oplossen
De fixtures uit het vorige hoofdstuk werden voor elke test opnieuw opgebouwd. Dat is de juiste standaardwaarde — totdat datgene wat opgebouwd wordt kostbaar is. Hier is een databaseverbinding die een halve seconde nodig heeft om te openen, net zoals een echte server-handshake dat zou doen:
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 rapporteert waar de tijd aan opging:
... [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.65sDrie tests, drie verbindingen, anderhalve seconde verspild aan handshakes. Met driehonderd tests is dat tweeënhalve minuut wachten op niets, en een suite die zo traag is wordt op den duur niet meer uitgevoerd.
De vraag die dit hoofdstuk beantwoordt is: hoe bouw je iets één keer op om het te delen — zonder dat de tests die het delen elkaar in de weg zitten?
Aan het einde van dit hoofdstuk kun je
- De scope van een fixture kiezen:
function,class,module,packageofsession - Zien hoe fixtures worden opgebouwd en opgeruimd met
--setup-show - Uitleggen waarom een fixture met een brede scope die een muteerbare toestand bevat ervoor zorgt dat tests van elkaar afhankelijk worden — en dit oplossen
- Een
ScopeMismatch-error begrijpen en weten welke fixture je moet aanpassen - Fixtures delen via
conftest.py, en er één overriden voor een specifieke map - Vertellen wat
autouse=Truekost, en elke beschikbare fixture tonen met--fixtures - Voorspellen in welke volgorde fixtures worden aangemaakt
Voorkennis: fixtures.
Voordat je de test schrijft
Scope is niet iets wat je achteraf toevoegt om een trage suite snel te maken. Het wordt bepaald voordat de eerste test wordt geschreven, aan de hand van twee vragen over elk onderdeel van de setup: hoeveel kost het om op te bouwen, en verandert een test het? Beantwoord die vragen eerst, en de juiste scope volgt vanzelf.
Het contract. De code die we hier testen is een kleine tabel met gebruikers. Wat deze belooft: een nieuwe database heeft geen gebruikers; het invoegen van een gebruiker voegt precies één rij toe; de naam die je invoegt is de naam die je terugkrijgt. Elk van die beloften moet gelden ongeacht welke tests er eerder zijn uitgevoerd. Die laatste voorwaarde is waar dit hoofdstuk werkelijk over gaat.
Wat er aanwezig moet zijn. Een virtuele omgeving waarin pytest is geïnstalleerd; sqlite3, dat standaard met Python meekomt, dus daar hoef je niets voor te installeren; de te testen code importeerbaar vanuit de testbestanden (hier bestanden naast elkaar in één map, uitgevoerd vanuit die map); en een beslissing over waar gedeelde fixtures komen te staan — een conftest.py in de hoofdmap van de tests.
Het testplan. Drie gevallen, die elk afzonderlijk moeten slagen én in willekeurige volgorde:
| geval | invoer | verwacht | |---|---|---| | verse database | niets ingevoegd | COUNT(*) is 0 | | één insert | 'asha' | COUNT(*) is 1 | | naam heen en terug | 'ravi' | de eerste naam die wordt uitgelezen is 'ravi' |
Het setupplan. Vervolgens hetzelfde soort tabel voor waar de tests op steunen:
| resource | kosten om te bouwen | verandert een test dit? | scope | |---|---|---|---| | verbinding + tabel | traag (halve seconde) | nee | breed — module of session | | de rijen in de tabel | gratis | ja, elke insert | function — reset na elke test |
Lees de laatste kolom aandachtig: één object, de verbinding, bevat zowel iets wat traag is als iets wat verandert. Daarom eindigt het hoofdstuk met twee fixtures — één brede en één smalle — in plaats van één.
Wat je niet test. Niet sqlite3 zelf; dat is veel grondiger getest dan jouw code ooit zal worden. Niet de fixtures rechtstreeks — een fixture wordt gecontroleerd door de tests die hem gebruiken, en door --setup-show te lezen. En ook niet de vertraging van een halve seconde: snelheid is iets wat je meet met --durations, niet iets waarop je assert.
scope= — eenmaal per wat?
De scope van een fixture bepaalt hoe lang één waarde blijft bestaan voordat pytest deze weggooit en een nieuwe opbouwt. De standaardwaarde is function: één per test. Pas één regel aan:
@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.52sEr zijn twee dingen gebeurd. De run ging van 1.65 seconden naar 0.52 — één verbinding in plaats van drie. En een test die zojuist nog slaagde, faalt nu. Houd die failure in gedachten; die krijgt hieronder een eigen sectie. Eerst de scopes zelf.
Er zijn er vijf, van smalst naar breedst:
| scope | één waarde per | opgeruimd na | |---|---|---| | function | test (de standaardwaarde) | elke test | | class | testklasse | de laatste test in de klasse | | module | testbestand | de laatste test in het bestand | | package | map waarin de fixture is gedefinieerd | de laatste test in die map, inclusief submappen | | session | gehele pytest-run | de laatste test van de run |
Een teller maakt het verschil zichtbaar. Elke fixture hieronder telt hoe vaak deze is opgebouwd:
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 (-s laat print door):
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01sDrie tests hebben de fixtures gebruikt. De function-fixture werd drie keer opgebouwd, de class-fixture twee keer (twee klassen), en de module- en session-fixtures elk één keer.
Kijken hoe het gebeurt: --setup-show
Tellen werkt, maar pytest kan de hele tijdlijn voor je uittekenen. Voer hetzelfde bestand uit met 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.01sDe letter na SETUP is de scope — Session, Package, Module, Class, Function — en het inspringen nest ze in elkaar. Lees het van boven naar beneden en je ziet precies wanneer elke waarde ontstaat en wanneer deze verdwijnt. Wanneer je twijfelt over wat een fixture doet, is dit de eerste vlag om naar te grijpen.
Een gedeelde fixture deelt zijn toestand
Terug naar de failure. Met scope="module" ontvingen alle drie de tests dezelfde verbinding. test_insert_one voegde 'asha' toe en niets verwijderde haar. Vervolgens voegde test_names_are_text 'ravi' toe, vroeg om de eerste naam, en kreeg 'asha'.
Het duidelijkste signaal van deze bug is een test die op zichzelf slaagt. pytest -q test_users.py::test_names_are_text:
. [100%]
1 passed in 0.50sAlleen slaagt hij; samen faalt hij. Tests die afhankelijk zijn van wat er vóór hen is uitgevoerd, zijn erger dan trage tests: ze falen in de ene volgorde en slagen in de andere, en ze kosten je telkens een hele middag.
De regel: verbreed de scope van het kostbare onderdeel, houd de scope van de toestand smal. Splits de fixture in tweeën — een brede die eigenaar is van de verbinding, en een met function-scope die na elke test opruimt:
@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 wroteDe tests veranderen niet — ze vragen nog steeds om 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.51sEén verbinding, drie slagende tests. sqlite3 opent een transactie bij de eerste INSERT, en rollback() gooit de hele transactie weg, waardoor elke test met een lege tabel begint.
Waarom zet je het opruimen in een fixture, in plaats van elke test te beginnen met DELETE FROM users? Omdat een opruimactie waar elke test aan moet denken, een opruimactie is die één test een keer zal vergeten — en de test die het vergeet is nooit degene die faalt; dat is de volgende. In de fixture vindt het opruimen plaats na elke test die om db vraagt, inclusief de tests die volgend jaar worden geschreven door iemand die dit bestand nog nooit heeft gezien. En waarom rollback() in plaats van DELETE? Een rollback maakt alles wat de test heeft geschreven ongedaan, in elke tabel, zonder dat de fixture hoeft te weten wat er precies geschreven is.
--setup-show toont de splitsing:
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.51sEen fixture met function-scope mag gerust om een bredere fixture vragen, zoals db hier om connection vraagt. De omgekeerde richting is niet toegestaan.
ScopeMismatch — breed mag niet vragen om smal
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.01sBedenk wat dat verzoek zou betekenen. connection blijft gedurende de hele run bestaan; db_name wordt na de eerste test weggegooid. De session-waarde zou iets vasthouden dat volgens zijn eigen regels niet meer bestaat. Daarom weigert pytest — en merk op dat het een Error is tijdens de setup, geen Failure: de test is nooit uitgevoerd.
Het foutbericht noemt beide fixtures en beide regels. De oplossing is altijd dezelfde: maak de aangevraagde fixture minstens zo breed als de fixture die erom vraagt — hier @pytest.fixture(scope="session") op db_name — of maak de aanvragende fixture smaller.
conftest.py — fixtures zonder imports
Zodra een tweede testbestand db nodig heeft, is het kopiëren van de fixture het verkeerde antwoord. Verplaats hem naar een bestand dat exact conftest.py heet. Pytest laadt dat bestand vanzelf, en elke test in dezelfde map en daaronder kan om de bijbehorende fixtures vragen — zonder enige import.
Een project met een submap:
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 — helemaal geen imports:
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 == 1De scope is nu session: één verbinding voor elk bestand in het project.
Een fixture overriden voor één map
De tests in reports/ willen allemaal een database waarin al gebruikers staan. Een conftest.py in die map kan een fixture definiëren met dezelfde naam; voor tests in reports/ wint de dichtstbijzijnde definitie. En als deze om zijn eigen naam vraagt, ontvangt hij de versie van de bovenliggende map:
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 ===============================Kijk naar de volgorde. De tests in reports/ werden als eerste uitgevoerd en voegden telkens drie gebruikers in — en toch trof test_starts_empty, die daarna draaide, een lege tabel aan. De bovenliggende db draaide ook de vooraf ingevoerde rijen terug, omdat de override bovenop die fixture is gebouwd. --setup-show laat de twee lagen zien, die beide db heten:
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(Dat is de eerste test; de rest herhaalt dit patroon.) De override werkt alleen naar beneden: tests in de hoofdmap zien deze nooit.
--fixtures — wat kan ik hier aanvragen?
Wanneer fixtures verspreid zijn over meerdere conftest.py-bestanden, heb je een manier nodig om te zien wat een bepaalde test kan aanvragen. pytest --fixtures reports toont alles wat zichtbaar is vanuit die map. De lijst begint met de ingebouwde fixtures van pytest (hoofdstuk negen) en die van geïnstalleerde plugins; die van jou komen aan het einde:
------------------------ 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.Elk item vermeldt de naam, de scope (indien het niet function is), het bestand en regelnummer, en de eerste regels van de docstring. Dat laatste is de reden om een docstring van één regel te schrijven bij elke gedeelde fixture: het is de documentatie die mensen daadwerkelijk zullen lezen.
autouse=True — en wat het kost
Een fixture gemarkeerd met autouse=True wordt gebruikt door elke test binnen zijn bereik, of de test er nu om vraagt of niet. Toegevoegd aan de bovenste conftest.py controleert deze fixture dat geen enkele test rijen achterlaat:
@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"Het werkt — een test die rechtstreeks via connection schrijft en de rollback overslaat, wordt tijdens de teardown onderschept. Maar voeg nu eens een test toe die niets met databases te maken heeft:
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.50sEen string-test van één regel opent nu een database en duurt een halve seconde. Dat is de prijs van autouse: het is onzichtbaar — niets in test_slugify wijst erop dat er een database bij betrokken is — en het is onvoorwaardelijk — het draait voor elke test onder die conftest.py, inclusief tests die het helemaal niet nodig hebben. Bewaar autouse voor zaken die goedkoop en echt universeel zijn, en plaats het in de smalst mogelijke conftest.py die de tests dekt die het nodig hebben.
Welke fixture wordt als eerste aangemaakt?
Wanneer een test meerdere fixtures nodig heeft, ordent pytest deze volgens drie regels:
- Bredere scope eerst. Session vóór module vóór function, ongeacht de volgorde in de functiesignatuur van de test.
- Binnen dezelfde scope:
autouse-fixtures eerst. - Afhankelijkheden vóór de fixtures die ze nodig hebben.
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.01sDe test vroeg om cart vóór connection, maar connection — met module-scope — werd als eerste opgebouwd, na zijn eigen afhankelijkheid server. Van de fixtures met function-scope kwam de autouse-fixture audit als eerste, daarna user, die cart nodig heeft. Teardown verloopt in exact de omgekeerde volgorde. Vertrouw buiten deze regels niet op volgorde: als de ene fixture vóór de andere moet bestaan, maak er dan een afhankelijkheid van door deze als parameter te benoemen.
Een compleet voorbeeld
Een kleine winkelmodule, getest tegen één gedeelde in-memory database.
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 ===============================Drie dingen zijn het opmerken waard.
Ten eerste vond de trage setup één keer plaats, bij de eerste test van de run, en nergens anders. Dat is de winst van session-scope.
Ten tweede werden de tests in test_orders.py uitgevoerd nadat de rapportagetests in totaal acht bestellingen hadden ingevoerd, en zagen ze nog steeds nul omzet. Vervang connection.rollback() door pass en voer het opnieuw uit; de prijs van gedeelde toestand wordt meteen zichtbaar:
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.52sTen derde importeert geen enkel testbestand een fixture. test_orders.py importeert shop, de te testen code, en verder niets; de fixtures komen op naam binnen vanuit de dichtstbijzijnde conftest.py.
Wanneer het misgaat
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Een brede fixture heeft om een smallere fixture gevraagd. Verbreed de aangevraagde fixture (hier db_name) tot minstens de scope van de fixture die erom vraagt, of maak de aanvragende fixture smaller. De twee regels onder Requesting fixture stack vertellen je welke wat is.
AssertionError: assert 'asha' == 'ravi' — maar de test slaagt als hij los wordt uitgevoerd Toestand lekt tussen tests via een fixture die breder is dan function. Voer uit met --setup-show om te zien welke fixture wordt gedeeld, en splits deze vervolgens: houd het kostbare object breed en voeg een fixture met function-scope toe die de wijzigingen van elke test ongedaan maakt.
fixture 'report_title' not found De fixture bestaat wel, maar niet op een plek waar deze test hem kan zien. Een conftest.py bedient alleen zijn eigen map en de onderliggende mappen — een fixture in reports/conftest.py is onzichtbaar voor een test in de bovenliggende map. Verplaats deze omhoog naar een conftest.py die beide dekt. pytest --fixtures path/to/test_file.py laat precies zien wat dat bestand kan zien.
Rijen van de ene test verschijnen in een andere, ook al voert db een rollback uit Iets heeft commit() aangeroepen. Een rollback gooit alleen weg wat nog niet gecommit is, dus code die commit — of een test die rechtstreeks connection gebruikt in plaats van db — schrijft permanent weg. Een bewaker zoals de fixture no_rows_left maakt dat zichtbaar als ERROR at teardown ... AssertionError: 1 row(s) left behind.
Een test die geen database aanraakt is traag geworden Een autouse-fixture ergens erboven hangt af van de kostbare fixture. pytest --setup-show op die test toont elke fixture die daadwerkelijk is gebruikt, inclusief de fixtures waar hij nooit om heeft gevraagd.
from conftest import db in een testbestand Importeer niet uit conftest.py. Pytest levert de fixtures al automatisch op naam, en import conftest pakt welke conftest.py Python toevallig als eerste tegenkomt. In het bovenstaande project zorgde het toevoegen van die regel aan test_users.py op het hoogste niveau ervoor dat die in reports/ werd geïmporteerd — de override met de vooraf ingevoerde data — waardoor test_starts_empty faalde met assert 3 == 0. Een geïmporteerde fixture telt bovendien alsof deze in het testbestand zelf is gedefinieerd, wat voorrang heeft op elke conftest.py. Vraag fixtures alleen aan door ze als parameters te benoemen, en verder niets.
Stap 4 van 6 — Voorspellen
Test je begrip
Voer uit met pytest -q -s. Wat print 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}
Deze test geeft vóór uitvoering een error met ScopeMismatch. Wat is de kleinste correcte oplossing?
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:"- APlaats `test_connects` in een klasse
- BVoeg `autouse=True` toe aan `connection`
- CVerplaats `settings` naar onder `connection` in het bestand
- DGeef `settings` `@pytest.fixture(scope="session")`
Beide conftest.py-bestanden definiëren db. Welke db ontvangt 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):
...- ADie op het hoogste niveau, omdat deze als eerste wordt geladen
- BDie uit `reports/`, maar de parameter `db` ontvangt zichzelf, waardoor er een oneindige recursie ontstaat
- CDie uit `reports/`, die de fixture van het hoogste niveau ontvangt en daar een rij aan toevoegt
- DGeen van beide — twee fixtures met dezelfde naam is een error
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
Bouw een klein project rond een library.py met twee functies: add_book(conn, title, author) en books_by(conn, author), die een gesorteerde lijst van titels retourneert.
- Schrijf in een
conftest.pyop het hoogste niveau eenconnection-fixture met session-scope diesqlite3.connect(":memory:")opent, een tabelbooks (title TEXT, author TEXT)aanmaakt, en een halve seconde pauzeert om een trage server na te bootsen. Geef deze een docstring. - Voeg daarbovenop een
db-fixture met function-scope toe die na elke test een rollback uitvoert. - Schrijf
test_library.pymet drie tests die elk boeken toevoegen enbooks_bycontroleren. Voerpytest -q --durations=3uit en controleer of de halve seconde één keer verschijnt. - Maak
search/conftest.pyaan diedboverschrijft zodat deze start met al vijf boeken erin, ensearch/test_search.pymet twee tests die daarop steunen. - Voer
pytest --setup-showuit en zoek naar de tweedb-lagen in de uitvoer voor desearch/-tests. Voer daarnapytest --fixtures searchuit en controleer of beide docstrings verschijnen.
Laat het daarna met opzet stukgaan, twee keer. Vervang eerst de rollback()-regel door pass en voer de hele suite uit — welke tests falen, en slaagt elk van die tests wanneer deze alleen wordt uitgevoerd? Geef connection vervolgens een fixture met function-scope genaamd db_name om van afhankelijk te zijn, en lees het ScopeMismatch-bericht regel voor regel.
Die vijf minuten zijn de kern van dit hoofdstuk. Scope is een beslissing over snelheid, maar de bugs die het creëert zijn isolatieproblemen — en de enige manier om ze te leren herkennen is door er zelf een paar te veroorzaken.
Oplossing
De indeling:
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 laat voor elk van de twee tests hetzelfde beeld zien als in het hoofdstuk: SETUP F db (fixtures used: connection) gevolgd door SETUP F db (fixtures used: db) — de bovenliggende laag, en daarna de override erbovenop. pytest --fixtures search toont connection met [session scope], beide db-fixtures met hun bestand en regelnummer, en alle drie de docstrings.
Het eerste experiment — rollback() vervangen door pass — levert op:
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.52sen pytest -q test_library.py::test_one_book op zichzelf:
. [100%]
1 passed in 0.50sDe search/-tests werden als eerste uitgevoerd en lieten vijf boeken achter, waardoor test_one_book twee titels van Tagore vond in plaats van één. Los uitgevoerd slaagt hij. Dat is het kenmerk van weglekkende toestand, precies zoals in het hoofdstuk. Het tweede experiment verandert alle vijf de tests in een E, elk met dezelfde ScopeMismatch die je eerder zag, nu wijzend naar conftest.py: def connection(db_name) is de aanvrager, def db_name() de fixture die te smal is.
Waarom elke beslissing:
connectionheeft session-scope omdat het het enige trage onderdeel is, en niets wat een test doet verandert de verbinding zelf — alleen de rijen erin.dbheeft function-scope omdat de rijen wel veranderen. Zijn enige taak is de rollback na elke test; de tests hoeven behalve de naam niet eens te weten dat hij bestaat.- De tests vragen om
db, nooit omconnection. Een test die rechtstreeksconnectionzou gebruiken, zou de rollback overslaan en zijn rijen laten weglekken naar de volgende test. - De override in
search/vraagt omdb, niet omconnection, zodat de vooraf ingevoerde boeken onderdeel worden van het eigen werk van de test en de rollback van de bovenliggende fixture ze samen met al het andere verwijdert. Als het opruimen indbooit verandert, erft de override die verandering zonder dat je hem hoeft aan te raken. test_author_match_is_exactstaat met opzet in het plan: het legt vast dat"tagore"niet hetzelfde is als"Tagore", een grens die iemand op een dag zal willen aanpassen — en bewust moet aanpassen, waarbij een falende test dat aangeeft.- Elke gedeelde fixture heeft een docstring, omdat
pytest --fixturesde manier is waarop de volgende persoon ontdekt wat er kan worden aangevraagd.
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