Fixture-Scope und conftest.py — Setup sicher teilen
Aufwendiges Setup einmal bauen und teilen: Die fünf Scopes, --setup-show, die Gefahr von geteiltem Zustand und ScopeMismatch, conftest.py für Fixtures ohne Imports und Überschreibungen pro Verzeichnis, was autouse kostet und in welcher Reihenfolge Fixtures erstellt werden.
Wie möchtest du lernen?
Das Problem, das wir lösen
Die Fixtures des letzten Kapitels wurden für jeden Test frisch erstellt. Das ist der richtige Standardwert — bis das, was erstellt wird, aufwendig ist. Hier ist eine Datenbankverbindung, deren Aufbau eine halbe Sekunde dauert, ganz wie der Handshake mit einem echten Server:
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 zeigt, wo die Zeit geblieben ist:
... [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.65sDrei Tests, drei Verbindungen, anderthalb Sekunden für bloße Handshakes. Bei dreihundert Tests sind das zweieinhalb Minuten Leerlauf — und eine derart langsame Test-Suite wird irgendwann gar nicht mehr ausgeführt.
Die Frage, die dieses Kapitel beantwortet, lautet: Wie baut man etwas einmal auf und teilt es — ohne dass die Tests, die es sich teilen, einander in die Quere kommen?
Am Ende dieses Kapitels kannst du
- Den Scope einer Fixture wählen:
function,class,module,packageodersession - Mit
--setup-showbeobachten, wie Fixtures eingerichtet und abgebaut werden - Erklären, warum eine weit gefasste Fixture mit veränderlichem Zustand Tests voneinander abhängig macht — und das Problem beheben
- Einen
ScopeMismatch-Fehler lesen und wissen, welche Fixture geändert werden muss - Fixtures über
conftest.pyteilen und eine Fixture für ein einzelnes Verzeichnis überschreiben - Sagen, was
autouse=Truekostet, und jede verfügbare Fixture mit--fixturesauflisten - Die Reihenfolge vorhersagen, in der Fixtures erstellt werden
Voraussetzungen: Fixtures.
Bevor du den Test schreibst
Der Scope ist nichts, was man ganz am Ende hinzufügt, um eine langsame Suite schnell zu machen. Er wird entschieden, bevor der erste Test geschrieben wird — anhand zweier Fragen zu jedem Teil des Setups: Wie aufwendig ist die Erstellung, und verändert ein Test den Zustand? Beantworte diese zuerst, und der richtige Scope ergibt sich von selbst.
Was versprochen wird. Der zu testende Code ist hier eine kleine Benutzertabelle. Was er verspricht: Eine neue Datenbank hat keine Benutzer; das Einfügen eines Benutzers fügt genau eine Zeile hinzu; der Name, den du einfügst, ist genau der Name, den du zurückerhältst. Jedes dieser Versprechen muss gelten, ganz gleich, welche Tests zuvor gelaufen sind. Um genau diesen letzten Halbsatz geht es in diesem Kapitel.
Was vorhanden sein muss. Eine virtuelle Umgebung mit installiertem pytest; sqlite3, das mit Python mitgeliefert wird, sodass es nichts zu installieren gibt; der zu testende Code importierbar aus den Testdateien (hier Dateien nebeneinander in einem Verzeichnis, ausgeführt aus diesem Verzeichnis); und eine Entscheidung darüber, wo geteilte Fixtures liegen — eine conftest.py ganz oben im Testbaum.
Der Testplan. Drei Fälle, von denen jeder für sich allein und in beliebiger Reihenfolge bestehen muss:
| Fall | Eingabe | Erwartet | |---|---|---| | Frische Datenbank | Nichts eingefügt | COUNT(*) ist 0 | | Ein Insert | 'asha' | COUNT(*) ist 1 | | Name hin und zurück | 'ravi' | Der erste zurückgelesene Name ist 'ravi' |
Der Setup-Plan. Dann die gleiche Art von Tabelle für das Fundament, auf dem die Tests stehen:
| Ressource | Aufwand beim Erstellen | Verändert ein Test sie? | Scope | |---|---|---|---| | Verbindung + Tabelle | Langsam (eine halbe Sekunde) | Nein | Weit — module oder session | | Die Zeilen in der Tabelle | Kostenlos | Ja, jedes Insert | function — nach jedem Test zurücksetzen |
Lies die letzte Spalte genau: Ein Objekt, die Verbindung, enthält sowohl etwas Langsames als auch etwas Veränderliches. Genau deshalb endet dieses Kapitel bei zwei Fixtures — einer weiten und einer engen — statt nur einer.
Was du nicht testen solltest. Nicht sqlite3 selbst; das ist weitaus gründlicher getestet, als dein Code es je sein wird. Nicht die Fixtures direkt — eine Fixture wird durch die Tests geprüft, die sie nutzen, und durch das Lesen von --setup-show. Und nicht die Verzögerung von einer halben Sekunde: Geschwindigkeit misst man mit --durations, man prüft sie nicht mit assert.
scope= — einmal pro was?
Der Scope einer Fixture bestimmt, wie lange ein Wert existiert, bevor pytest ihn verwirft und einen neuen erstellt. Der Standard ist function: einmal pro Test. Ändere eine Zeile:
@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.52sZwei Dinge sind passiert. Die Laufzeit sank von 1,65 Sekunden auf 0,52 — eine Verbindung statt drei. Und ein Test, der eben noch bestand, schlägt nun fehl. Behalte diesen Fehlschlag im Hinterkopf; er bekommt weiter unten einen eigenen Abschnitt. Zuerst zu den Scopes selbst.
Es gibt fünf Scopes, vom engsten bis zum weitesten:
| Scope | Ein Wert pro | Abgebaut nach | |---|---|---| | function | Test (der Standard) | Jedem Test | | class | Testklasse | Dem letzten Test in der Klasse | | module | Testdatei | Dem letzten Test in der Datei | | package | Verzeichnis, in dem die Fixture definiert ist | Dem letzten Test in diesem Verzeichnis, inklusive Unterverzeichnissen | | session | Gesamten pytest-Lauf | Dem letzten Test des Testlaufs |
Ein Zähler macht den Unterschied sichtbar. Jede der folgenden Fixtures zählt, wie oft sie erstellt wurde:
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 (das -s lässt print-Ausgaben durch):
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01sDrei Tests haben die Fixtures verwendet. Die Function-Fixture wurde dreimal erstellt, die Class-Fixture zweimal (zwei Klassen) und die Module- und Session-Fixtures jeweils einmal.
Den Ablauf beobachten: --setup-show
Zählen funktioniert, aber pytest kann dir den gesamten zeitlichen Ablauf aufzeichnen. Führe dieselbe Datei mit pytest -q --setup-show aus:
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.01sDer Buchstabe nach SETUP steht für den Scope — Session, Package, Module, Class, Function — und die Einrückung schachtelt sie ineinander. Lies es von oben nach unten, und du siehst genau, wann jeder Wert entsteht und wann er stirbt. Wann immer du dir unsicher bist, was eine Fixture tut, ist dieses Flag das erste Werkzeug, zu dem du greifen solltest.
Eine geteilte Fixture teilt ihren Zustand
Zurück zum Fehlschlag. Mit scope="module" erhielten alle drei Tests dieselbe Verbindung. test_insert_one fügte 'asha' hinzu und nichts entfernte sie wieder. Dann fügte test_names_are_text 'ravi' hinzu, fragte nach dem ersten Namen und erhielt 'asha'.
Das deutlichste Anzeichen für diesen Fehler ist ein Test, der für sich allein besteht. pytest -q test_users.py::test_names_are_text:
. [100%]
1 passed in 0.50sAllein besteht er; in Gesellschaft schlägt er fehl. Tests, die davon abhängen, was vor ihnen gelaufen ist, sind schlimmer als langsame Tests: Sie schlagen in der einen Reihenfolge fehl und bestehen in einer anderen, und sie kosten jedes Mal einen ganzen Nachmittag.
Die Regel lautet: Fasse den Scope der aufwendigen Ressource weit, halte den Scope des Zustands eng. Teile die Fixture in zwei Teile auf — eine weite Fixture, der die Verbindung gehört, und eine mit Function Scope, die nach jedem Test aufräumt:
@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 wroteDie Tests ändern sich nicht — sie fordern weiterhin db an. 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.51sEine Verbindung, drei bestehende Tests. sqlite3 öffnet beim ersten INSERT eine Transaktion, und rollback() verwirft die gesamte Transaktion, sodass jeder Test mit einer leeren Tabelle beginnt.
Warum gehört das Aufräumen in eine Fixture, anstatt jeden Test mit DELETE FROM users beginnen zu lassen? Weil ein Cleanup, an den jeder Test selbst denken muss, ein Cleanup ist, den ein Test irgendwann vergessen wird — und der Test, der es vergisst, ist niemals derjenige, der fehlschlägt; es ist der nächste. In der Fixture erfolgt das Aufräumen nach jedem Test, der db anfordert — einschließlich der Tests, die nächstes Jahr von jemandem geschrieben werden, der diese Datei noch nie gesehen hat. Und warum rollback() statt DELETE? Ein Rollback macht alles rückgängig, was der Test geschrieben hat, in jeder Tabelle, ohne dass die Fixture wissen muss, was genau geschrieben wurde.
--setup-show zeigt diese Aufteilung:
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.51sEine Fixture mit Function Scope darf problemlos eine weiter gefasste Fixture anfordern, so wie db hier connection anfordert. Die umgekehrte Richtung ist nicht erlaubt.
ScopeMismatch — weit darf nicht nach eng fragen
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.01sÜberlege, was diese Anforderung bedeuten würde. connection lebt für den gesamten Testlauf; db_name wird nach dem ersten Test verworfen. Der Wert mit Session Scope würde etwas festhalten, das nach seinen eigenen Regeln gar nicht mehr existiert. Deshalb verweigert pytest die Ausführung — und beachte, dass dies ein Error beim Setup ist, kein Failure: Der Test lief gar nicht erst.
Die Fehlermeldung nennt beide Fixtures und beide Zeilen. Die Behebung ist immer dieselbe: Mache die angeforderte Fixture mindestens so weit wie diejenige, die sie anfordert — hier @pytest.fixture(scope="session") bei db_name — oder fasse die anfordernde Fixture enger.
conftest.py — Fixtures ohne Imports
Sobald eine zweite Testdatei db benötigt, ist das Kopieren der Fixture der falsche Weg. Verschiebe sie in eine Datei mit dem exakten Namen conftest.py. pytest lädt diese Datei automatisch, und jeder Test im selben Verzeichnis und darunter kann ihre Fixtures anfordern — ganz ohne import.
Ein Projekt mit einem Unterverzeichnis:
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 — völlig ohne 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 == 1Der Scope ist nun session: eine Verbindung für jede Datei im Projekt.
Eine Fixture für ein Verzeichnis überschreiben
Die Tests in reports/ benötigen alle eine Datenbank, die bereits Benutzer enthält. Eine conftest.py in diesem Verzeichnis kann eine Fixture mit demselben Namen definieren; für Tests in reports/ gewinnt die nähere Definition. Und wenn sie ihren eigenen Namen anfordert, erhält sie die Version der übergeordneten Fixture:
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 ===============================Schau dir die Reihenfolge an. Die reports/-Tests liefen zuerst und fügten jedes Mal drei Benutzer ein — dennoch fand test_starts_empty, das danach lief, eine leere Tabelle vor. Das übergeordnete db hat auch die vorab eingefügten Zeilen zurückgerollt, weil der Override auf ihm aufbaut. --setup-show zeigt die beiden Schichten, die beide db heißen:
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(Das ist der erste Test; die restlichen wiederholen das Muster.) Der Override wirkt nur nach unten: Tests im obersten Verzeichnis bekommen ihn niemals zu sehen.
--fixtures — was kann ich hier anfordern?
Wenn Fixtures über mehrere conftest.py-Dateien verteilt sind, brauchst du eine Möglichkeit zu sehen, was ein bestimmter Test anfordern kann. pytest --fixtures reports listet alles auf, was von diesem Verzeichnis aus sichtbar ist. Die Liste beginnt mit den eingebauten Fixtures von pytest (Kapitel neun) und denen installierter Plugins; deine eigenen stehen ganz am Ende:
------------------------ 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.Jeder Eintrag nennt den Namen, den Scope (sofern er nicht function ist), Datei und Zeile sowie die ersten Zeilen des Docstrings. Dieser letzte Teil ist der Grund, warum man für jede geteilte Fixture einen einzeiligen Docstring schreiben sollte: Es ist genau die Dokumentation, die andere Entwickler tatsächlich lesen werden.
autouse=True — und was es kostet
Eine mit autouse=True markierte Fixture wird von jedem Test in ihrer Reichweite verwendet, ganz gleich, ob der Test sie anfordert oder nicht. Wenn man sie zur obersten conftest.py hinzufügt, prüft diese hier, dass kein Test Zeilen zurücklässt:
@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"Das funktioniert — ein Test, der direkt über connection schreibt und das Rollback umgeht, fliegt beim Teardown auf. Aber füge nun einen Test hinzu, der überhaupt nichts mit Datenbanken zu tun hat:
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.50sEin einzeiliger String-Test öffnet nun eine Datenbank und dauert eine halbe Sekunde. Das ist der Preis von autouse: Es ist unsichtbar — nichts in test_slugify deutet darauf hin, dass eine Datenbank im Spiel ist — und es ist bedingungslos — es läuft für jeden Test unterhalb seiner conftest.py, auch für diejenigen, die es überhaupt nicht brauchen. Verwende autouse nur für Dinge, die schnell sind und wirklich universell gebraucht werden, und platziere es in der engsten conftest.py, die die betroffenen Tests abdeckt.
Welche Fixture wird zuerst erstellt?
Wenn ein Test mehrere Fixtures benötigt, ordnet pytest sie nach drei Regeln:
- Weiterer Scope zuerst. Session vor Module vor Function, ganz gleich, in welcher Reihenfolge sie in der Signatur des Tests stehen.
- Innerhalb eines Scopes kommen
autouse-Fixtures zuerst. - Abhängigkeiten vor den Fixtures, die sie benötigen.
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.01sDer Test hat cart vor connection angefordert, aber connection — mit Module Scope — wurde zuerst erstellt, nach seiner eigenen Abhängigkeit server. Unter den Fixtures mit Function Scope kam die autouse-Fixture audit zuerst, danach user, das von cart benötigt wird. Der Teardown läuft in exakt umgekehrter Reihenfolge ab. Verlasse dich außerhalb dieser Regeln nicht auf eine bestimmte Reihenfolge: Wenn eine Fixture vor einer anderen existieren muss, mache sie zu einer Abhängigkeit, indem du sie als Parameter benennst.
Ein vollständiges Beispiel
Ein kleines Shop-Modul, getestet gegen eine einzelne geteilte In-Memory-Datenbank.
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 ===============================Drei Dinge verdienen Aufmerksamkeit.
Erstens fand das langsame Setup ein einziges Mal beim ersten Test des Durchlaufs statt und nirgendwo sonst. Hier zahlt sich der Session Scope aus.
Zweitens liefen die Tests in test_orders.py erst, nachdem die Report-Tests insgesamt acht Bestellungen eingefügt hatten, und sahen dennoch null Umsatz. Ersetze connection.rollback() durch pass und führe den Befehl erneut aus — der Preis von geteiltem Zustand zeigt sich sofort:
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.52sDrittens importiert keine einzige Testdatei eine Fixture. test_orders.py importiert shop, den zu testenden Code, und sonst nichts; die Fixtures kommen über ihren Namen aus der nächstgelegenen conftest.py.
Wenn etwas schiefgeht
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Eine weite Fixture hat eine engere angefordert. Erweitere die angeforderte Fixture (hier db_name) mindestens auf den Scope der anfordernden Fixture oder fasse die anfordernde Fixture enger. Die beiden Zeilen unter Requesting fixture stack verraten dir, welche welche ist.
AssertionError: assert 'asha' == 'ravi' — aber der Test besteht, wenn er für sich allein ausgeführt wird Über eine Fixture, die weiter gefasst ist als function, sickert Zustand zwischen Tests durch. Führe den Testlauf mit --setup-show aus, um zu sehen, welche Fixture geteilt wird. Teile sie dann auf: Behalte das aufwendige Objekt weit gefasst und ergänze eine Fixture mit Function Scope, die die Änderungen jedes Tests rückgängig macht.
fixture 'report_title' not found Die Fixture existiert, aber nicht dort, wo dieser Test sie sehen kann. Eine conftest.py bedient nur ihr eigenes Verzeichnis und die Verzeichnisse darunter — eine Fixture in reports/conftest.py ist für einen Test im darüberliegenden Verzeichnis unsichtbar. Verschiebe sie nach oben in eine conftest.py, die beide abdeckt. pytest --fixtures path/to/test_file.py zeigt genau, was diese Datei sehen kann.
Zeilen aus einem Test tauchen in einem anderen auf, obwohl db ein Rollback durchführt Irgendetwas hat commit() aufgerufen. Ein Rollback verwirft nur das, was noch nicht committet wurde; Code, der committet — oder ein Test, der connection direkt statt db verwendet —, schreibt dauerhaft. Ein Wächter wie die no_rows_left-Fixture verwandelt das in einen sichtbaren ERROR at teardown ... AssertionError: 1 row(s) left behind.
Ein Test, der keine Datenbank anrührt, ist plötzlich langsam geworden Eine autouse-Fixture irgendwo darüber hängt von der aufwendigen Ressource ab. pytest --setup-show für diesen Test listet jede Fixture auf, die er tatsächlich verwendet hat — einschließlich derer, die er nie angefordert hat.
from conftest import db in einer Testdatei Importiere niemals aus conftest.py. pytest stellt dir die Fixtures bereits über ihren Namen bereit, und import conftest greift nach derjenigen conftest.py, die Python zufällig als Erstes findet. Im obigen Projekt führte diese Zeile in der obersten test_users.py dazu, dass die Datei aus reports/ importiert wurde — der vorbereitende Override — und test_starts_empty schlug mit assert 3 == 0 fehl. Eine importierte Fixture gilt zudem als in der Testdatei selbst definiert, was jede conftest.py überstimmt. Fordere Fixtures ausschließlich an, indem du sie als Parameter benennst.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Mit pytest -q -s ausführen. Was gibt test_report aus?
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}
Dieser Test bricht vor der Ausführung mit ScopeMismatch als Error ab. Was ist die kleinste korrekte Fehlerbehebung?
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:"- A`test_connects` in eine Klasse verschieben
- B`autouse=True` zu `connection` hinzufügen
- C`settings` in der Datei unter `connection` verschieben
- D`settings` mit `@pytest.fixture(scope="session")` versehen
Beide conftest.py-Dateien definieren db. Welches db erhält 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 aus der obersten Ebene, weil sie zuerst geladen wird
- BDie aus `reports/`, aber ihr `db`-Parameter erhält sie selbst, sodass sie endlos rekursiert
- CDie aus `reports/`, die die Version aus der obersten Ebene erhält und eine Zeile darauf aufsetzt
- DKeine von beiden — zwei Fixtures mit demselben Namen sind ein Fehler
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Baue ein kleines Projekt rund um eine library.py mit zwei Funktionen: add_book(conn, title, author) und books_by(conn, author), die eine sortierte Liste von Titeln zurückgibt.
- Schreibe in einer
conftest.pyauf oberster Ebene eineconnection-Fixture mit Session Scope, diesqlite3.connect(":memory:")öffnet, eine Tabellebooks (title TEXT, author TEXT)erstellt und für eine halbe Sekunde pausiert (sleep), um einen langsamen Server zu simulieren. Gib ihr einen Docstring. - Baue darauf eine
db-Fixture mit Function Scope auf, die nach jedem Test ein Rollback ausführt. - Schreibe
test_library.pymit drei Tests, die jeweils Bücher hinzufügen undbooks_byüberprüfen. Führepytest -q --durations=3aus und bestätige, dass die halbe Sekunde nur einmal auftaucht. - Erstelle
search/conftest.py, diedbso überschreibt, dass sie bereits mit fünf Büchern startet, undsearch/test_search.pymit zwei Tests, die sich darauf stützen. - Führe
pytest --setup-showaus und finde die beidendb-Schichten in der Ausgabe für diesearch/-Tests. Führe dannpytest --fixtures searchaus und überprüfe, dass beide Docstrings erscheinen.
Mache es danach absichtlich kaputt — zweimal. Ersetze zuerst die rollback()-Zeile durch pass und führe die gesamte Suite aus — welche Tests schlagen fehl, und besteht jeder davon, wenn er für sich allein ausgeführt wird? Gib connection als Zweites eine Fixture db_name mit Function Scope als Abhängigkeit und lies die ScopeMismatch-Meldung Zeile für Zeile.
Diese fünf Minuten sind der Kern des Kapitels. Der Scope ist eine Geschwindigkeitsentscheidung, aber die Fehler, die er erzeugt, sind Isolationsfehler — und der einzige Weg zu lernen, wie man sie erkennt, besteht darin, selbst ein paar davon zu provozieren.
Lösung
Die Struktur:
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 zeigt für jeden der beiden Tests dasselbe Bild wie im Kapitel: SETUP F db (fixtures used: connection), gefolgt von SETUP F db (fixtures used: db) — die übergeordnete Schicht, dann der Override darauf aufgesetzt. pytest --fixtures search listet connection mit [session scope], beide db-Fixtures mit ihrer Datei und Zeile sowie alle drei Docstrings auf.
Das erste Experiment — rollback() durch pass ersetzt — liefert:
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.52sund pytest -q test_library.py::test_one_book für sich allein:
. [100%]
1 passed in 0.50sDie search/-Tests liefen zuerst und hinterließen fünf Bücher, sodass test_one_book zwei Tagore-Titel statt eines einzigen fand. Für sich allein besteht er. Das ist das typische Muster von durchgesickertem Zustand, genau wie zuvor im Kapitel. Das zweite Experiment verwandelt alle fünf Tests in ein E, jeder mit demselben ScopeMismatch, den du vorhin kennengelernt hast, und zeigt nun auf conftest.py: def connection(db_name) fordert an, def db_name() ist die zu eng gefasste Fixture.
Warum jede dieser Entscheidungen getroffen wurde:
connectionhat Session Scope, weil es der einzige langsame Teil ist und nichts, was ein Test tut, die Verbindung selbst verändert — nur die Zeilen darin.dbhat Function Scope, weil sich die Zeilen tatsächlich ändern. Seine einzige Aufgabe ist das Rollback nach jedem Test; die Tests müssen außer seinem Namen überhaupt nichts darüber wissen.- Die Tests fordern
dban, niemalsconnection. Ein Test, derconnectiondirekt nähme, würde das Rollback umgehen und seine Zeilen in den nächsten Test durchsickern lassen. - Der
search/-Override fordertdban, nichtconnection, sodass die vorbereiteten Bücher Teil der Arbeit des Tests selbst werden und das Rollback der übergeordneten Fixture sie zusammen mit allem anderen entfernt. Sollte sich das Aufräumen indbjemals ändern, erbt der Override die Änderung, ohne dass er angefasst werden muss. test_author_match_is_exactsteht ganz bewusst im Plan: Der Test hält fest, dass"tagore"nicht"Tagore"ist — eine Verhaltensgrenze, die eines Tages jemand ändern möchte und dann ganz gezielt ändern sollte, gewarnt durch einen fehlschlagenden Test.- Jede geteilte Fixture hat einen Docstring, denn über
pytest --fixtureswird die nächste Person herausfinden, was sie hier anfordern kann.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen