Fixtures — Setup einmal schreiben
Schluss mit wiederholtem Setup: Einmal mit @pytest.fixture schreiben, per Name anfordern, in jedem Test eine frische Kopie erhalten und mit yield aufräumen, selbst wenn ein Test fehlschlägt.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist ein kleiner Warenkorb, cart.py:
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self.lines)
def count(self):
return sum(quantity for _, _, quantity in self.lines)Und drei Tests dafür, test_cart.py:
from cart import Cart
def test_total():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.total() == 895.0
def test_count():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.count() == 4
def test_add_one_more():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sSie bestehen, sind aber mühsam zu lesen. Drei Tests, neun Zeilen Setup, drei Zeilen, die tatsächlich etwas prüfen. Das Setup ist in jedem Test dasselbe, sodass die eine Zeile, die sich unterscheidet — die Zeile, für die der Test überhaupt da ist —, untergeht.
Das rächt sich auch später. Wenn Cart() irgendwann ein Argument benötigt, musst du es an drei Stellen ändern, oder an dreißig. Im letzten Kapitel hat parametrize Wiederholungen bei den Eingaben eines Tests beseitigt. Dieses Kapitel beseitigt Wiederholungen in seinem Setup.
Am Ende dieses Kapitels kannst du
- Eine Fixture mit
@pytest.fixtureschreiben und anfordern, indem du sie als Test-Parameter benennst - Erklären, warum jeder Test seine eigene frische Kopie erhält, und das anhand einer Liste zeigen
- Eine Fixture auf einer anderen aufbauen
- Eine
yield-Fixture schreiben, die hinter sich aufräumt, und die Reihenfolge von Setup und Teardown vorhersagen - Mit
--setup-showbeobachten, wie Fixtures erstellt werden fixture 'x' not foundund den „called directly“-Fehler lesen- Zwischen einer Fixture und einer einfachen Hilfsfunktion wählen
Voraussetzungen: Einen Test mit `parametrize` über viele Fälle ausführen.
Bevor du den Test schreibst
Fixtures sind nichts, was man Tests erst nachträglich hinzufügt. Sie ergeben sich aus einem Plan, und der Plan kommt zuerst.
Was versprochen wird. Cart verspricht drei Dinge: add() erfasst einen Posten und verweigert eine Menge unter 1 mit ValueError; total() ist die Summe aus Preis × Menge; count() ist die Summe der Mengen. Keine Dateien, kein Netzwerk, kein globaler Zustand — bisher nichts, was rückgängig gemacht werden müsste.
Was vorhanden sein muss. Dasselbe wie in jedem Kapitel: eine virtuelle Umgebung mit installiertem pytest und cart.py importierbar aus dem Ordner, in dem du pytest ausführst (lege die Testdatei daneben).
Welche Fälle. Schreibe sie auf und füge eine Spalte hinzu, die meistens weggelassen wird — der Zustand, von dem der Test ausgeht:
| Fall | Ausgangszustand | Aktion | Erwartet | |---|---|---|---| | Normaler Gesamtbetrag | pen × 3, bag × 1 | total() | 895.0 | | Anzahl Artikel | pen × 3, bag × 1 | count() | 4 | | Ein weiterer Posten | pen × 3, bag × 1 | add("notebook", 60.0, 2) | total() ist 1015.0 | | Randfall: nichts enthalten | leerer Warenkorb | total() | 0 | | Ungültige Menge | leerer Warenkorb | add("pen", 15.0, 0) | ValueError |
Lies die zweite Spalte von oben nach unten. Drei Zeilen gehen vom selben befüllten Warenkorb aus, zwei von einem leeren. Jeder wiederholte Wert in dieser Spalte ist eine Fixture, die nur darauf wartet, geschrieben zu werden — hier empty_cart und ein darauf aufbauender cart. Und eine Zeile, die von einem Zustand ausgeht, den sonst niemand teilt, braucht überhaupt keine Fixture; sie baut ihren eigenen Zustand auf.
Stelle jedem Ausgangszustand noch eine weitere Frage: Verändert seine Erstellung irgendetwas außerhalb des Tests? Ein neues Cart tut das nicht. Ein registrierter Rabattcode in einem globalen Dictionary, eine geöffnete Verbindung, eine geschriebene Datei — all das tut es, und genau daran erkennst du, dass eine Fixture aufräumen muss (yield, später in diesem Kapitel).
Was du nicht testen solltest. Schreibe keine Tests, die beweisen, dass pytest für jeden Test eine frische Fixture baut; das ist das Versprechen von pytest, und dieses Kapitel zeigt es einmal, damit du darauf vertrauen kannst. Teste aber deinen eigenen Cleanup, wenn er einen globalen Zustand wiederherstellt — dieser Code stammt von dir und kann fehlerhaft sein.
Der Rest des Kapitels verwandelt diesen Plan in Code.
Eine erste Fixture
Verschiebe das Setup in eine Funktion und setze @pytest.fixture darüber:
import pytest
from cart import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("pen", 15.0, 3)
c.add("bag", 850.0)
return c
def test_total(cart):
assert cart.total() == 895.0
def test_count(cart):
assert cart.count() == 4
def test_add_one_more(cart):
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sNichts in den Tests ruft cart() auf. Jeder Test hat einfach einen Parameter namens cart, und das ist schon die gesamte Anforderung.
Vor dem Ausführen eines Tests sieht sich pytest dessen Parameternamen an. Zu jedem Namen sucht es eine Fixture mit diesem Namen, führt sie aus und übergibt ihren Rückgabewert als Argument. Der Test sagt, was er braucht; er baut es nicht selbst. Dieses Prinzip hat einen Namen — Dependency Injection —, aber der Mechanismus ist nichts weiter als „gleiche den Parameternamen mit einem Fixture-Namen ab“.
Der Test liest sich nun als eine einzige Zeile reiner Absicht. Und das Setup liegt an einem einzigen Ort, sodass eine Änderung an Cart() nur eine einzige Funktion betrifft.
Jeder Test bekommt eine frische Kopie
test_add_one_more fügt dem Warenkorb ein Notizbuch hinzu. Haben die danach ausgeführten Tests einen Warenkorb mit einem Notizbuch darin gesehen? Nein — und es lohnt sich, das genau zu verstehen, denn diese Eigenschaft macht Fixtures sicher.
Zuerst die Version ohne Fixture. Eine Liste ganz oben im Modul, die von zwei Tests geteilt wird:
ITEMS = ["pen", "bag"]
def test_add_item():
ITEMS.append("notebook")
assert len(ITEMS) == 3
def test_starts_with_two():
assert len(ITEMS) == 2pytest -q
.F [100%]
=================================== FAILURES ===================================
_____________________________ test_starts_with_two _____________________________
def test_starts_with_two():
> assert len(ITEMS) == 2
E AssertionError: assert 3 == 2
E + where 3 = len(['pen', 'bag', 'notebook'])
test_shared.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_shared.py::test_starts_with_two - AssertionError: assert 3 == 2
1 failed, 1 passed in 0.01sDer zweite Test ist korrekt; er ist nur wegen dessen fehlgeschlagen, was der erste Test getan hat. Führe ihn alleine aus und er besteht:
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sEin Test, dessen Ergebnis davon abhängt, welche Tests vor ihm liefen, gehört zu den teuersten Fehlern in einer Testsuite, weil er nur manchmal auftritt.
Nun dieselben zwei Tests, bei denen die Liste aus einer Fixture stammt. Ein print innerhalb der Fixture zeigt, wann sie ausgeführt wird; -s weist pytest an, Ausgaben nicht abzufangen, damit wir sie sehen können:
import pytest
@pytest.fixture
def items():
print("building a new list")
return ["pen", "bag"]
def test_add_item(items):
items.append("notebook")
assert len(items) == 3
def test_starts_with_two(items):
assert len(items) == 2pytest -q -s
building a new list
.building a new list
.
2 passed in 0.01s„building a new list“ erscheint zweimal. Die Fixture-Funktion lief einmal für jeden Test, der sie angefordert hat, und jeder Test erhielt eine Liste, die niemand sonst angerührt hatte. Das ist das Standardverhalten, und es hat einen Namen: Function Scope. Der Wert der Fixture existiert so lange wie eine Testfunktion. Kapitel acht zeigt, wie man das ändert und warum man es meistens nicht tun sollte.
Fixtures können Fixtures nutzen
Eine Fixture kann eine andere Fixture auf genau dieselbe Weise anfordern wie ein Test — indem sie sie als Parameter benennt:
import pytest
from cart import Cart
@pytest.fixture
def empty_cart():
return Cart()
@pytest.fixture
def cart(empty_cart):
empty_cart.add("pen", 15.0, 3)
empty_cart.add("bag", 850.0)
return empty_cart
def test_new_cart_is_empty(empty_cart):
assert empty_cart.total() == 0
def test_total(cart):
assert cart.total() == 895.0pytest -q
.. [100%]
2 passed in 0.01scart baut auf empty_cart auf, sodass ein Test genau die Vorbereitungsstufe anfordern kann, die er benötigt. Keiner der Tests ruft irgendetwas auf; pytest folgt den Namen, von test_total zu cart und von cart zu empty_cart, und erstellt sie in der Reihenfolge, die diese Kette erfordert.
Den Ablauf beobachten — --setup-show
Du musst keine print-Aufrufe einbauen, um herauszufinden, welche Fixtures gelaufen sind. --setup-show gibt jedes Setup und jeden Teardown aus:
pytest -q --setup-show
SETUP F empty_cart
test_cart.py::test_new_cart_is_empty (fixtures used: empty_cart) .
TEARDOWN F empty_cart
SETUP F empty_cart
SETUP F cart (fixtures used: empty_cart)
test_cart.py::test_total (fixtures used: cart, empty_cart) .
TEARDOWN F cart
TEARDOWN F empty_cart
2 passed in 0.01sLies es von oben nach unten. Das F steht für Function Scope — für einen Test erstellt und danach verworfen. empty_cart wird zweimal eingerichtet, einmal pro Test, was der Frische aus dem vorherigen Abschnitt entspricht. Wo cart verwendet wird, wird empty_cart zuerst eingerichtet, da cart davon abhängt. Und die Teardowns erfolgen in der umgekehrten Reihenfolge der Setups: zuletzt erstellt, zuerst abgebaut.
(Wenn du Plugins installiert hast, können zusätzliche Zeilen für deren eigene Fixtures erscheinen. Diese gehören zu den Plugins, nicht zu dir.)
Bisher hat der Teardown nichts Sichtbares getan, weil es nichts rückgängig zu machen gab. Das ändert sich als Nächstes.
yield — erst Setup, dann Cleanup
Manches Setup hinterlässt etwas, das wieder rückgängig gemacht werden muss: eine Verbindung, die geschlossen werden muss, eine Datei, die gelöscht werden soll, eine globale Einstellung, die zurückgesetzt werden muss. Dafür verwendet eine Fixture yield statt return:
import pytest
@pytest.fixture
def connection():
print("\nconnection: open")
conn = {"orders": []}
yield conn
print("connection: close")
def test_starts_empty(connection):
print("test: running")
assert connection["orders"] == []pytest -q -s
connection: open
test: running
.connection: close
1 passed in 0.01sAlles vor yield ist Setup. Der Wert nach yield ist das, was der Test empfängt. Dann pausiert die Fixture — der Test läuft — und wenn der Test beendet ist, setzt pytest die Fixture nach dem yield fort; dieser verbleibende Teil ist der Teardown. (Der . steht vor „connection: close“, weil das Ergebnis des Tests in dem Moment ausgegeben wird, in dem der Test endet, unmittelbar bevor der Teardown beginnt.)
Der Cleanup befindet sich in derselben Funktion wie das Setup, nur wenige Zeilen darunter. Du kannst nicht das eine hinzufügen und das andere vergessen, ohne dass es sofort auffällt.
Bei zwei yield-Fixtures, von denen eine von der anderen abhängt, entspricht die Reihenfolge dem, was --setup-show bereits angedeutet hat:
import pytest
@pytest.fixture
def connection():
print("\n1. connection: open")
conn = {"orders": []}
yield conn
print("5. connection: close")
@pytest.fixture
def saved_order(connection):
print("2. saved_order: insert")
connection["orders"].append("A-100")
yield "A-100"
connection["orders"].remove("A-100")
print("4. saved_order: delete")
def test_order_is_saved(connection, saved_order):
print("3. test: running")
assert saved_order in connection["orders"]pytest -q -s
1. connection: open
2. saved_order: insert
3. test: running
.4. saved_order: delete
5. connection: close
1 passed in 0.01sDas Setup verläuft von der Basis der Abhängigkeitskette nach oben; der Teardown verläuft wieder nach unten. Das ist die einzige Reihenfolge, die funktioniert: saved_order löscht seine Zeile über die Verbindung, die Verbindung muss also noch offen sein, wenn dies geschieht.
Teardown läuft auch dann, wenn der Test fehlschlägt
Warum die Mühe mit einer Fixture fürs Aufräumen, wenn der Test einfach an seinem Ende aufräumen könnte? Probiere es aus. OPEN steht stellvertretend für alles, was ein Test hinterlassen kann — eine offene Verbindung, eine temporäre Datei, eine geänderte Einstellung:
OPEN = []
def test_broken():
OPEN.append("connection")
assert OPEN == ["A-100"]
OPEN.remove("connection")
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_body.py::test_broken - AssertionError: assert ['connec...
FAILED test_cleanup_in_body.py::test_nothing_left_open - AssertionError: asse...
2 failed in 0.01s(--tb=no blendet die Tracebacks aus und behält nur die Zusammenfassung.) Ein Fehler, zwei Fehlschläge. Ein fehlschlagendes assert löst eine Exception (Ausnahme) aus, und das beendet den Test auf der Stelle, sodass die Cleanup-Zeile darunter nie ausgeführt wurde. Die Verbindung blieb in OPEN, und der nächste Test — der völlig fehlerfrei war — schlug wegen dessen fehl, was er geerbt hatte. In einer großen Suite ist dieser zweite Fehlschlag derjenige, mit dem man einen ganzen Nachmittag verbringen würde.
Nun derselbe Ablauf, mit dem Cleanup nach einem yield:
import pytest
OPEN = []
@pytest.fixture
def connection():
OPEN.append("connection")
yield "connection"
OPEN.remove("connection")
def test_broken(connection):
assert OPEN == ["A-100"]
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
F. [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_fixture.py::test_broken - AssertionError: assert ['con...
1 failed, 1 passed in 0.01sEin Fehler, ein Fehlschlag. Der defekte Test wird immer noch gemeldet — die Fixture verbirgt nichts —, aber seine Hinterlassenschaften haben den nächsten Test nicht erreicht.
Das ist der Grund und die daraus folgende Regel: Cleanup gehört niemals an das Ende eines Testrumpfs. Er gehört hinter das yield einer Fixture.
Eine Fixture oder eine einfache Funktion?
Eine Fixture ist nicht die einzige Möglichkeit, Setup zu teilen. Eine gewöhnliche Funktion — etwa cart_with(*lines), die einen Warenkorb aus allen übergebenen Posten baut — funktioniert ebenfalls, und das vollständige Beispiel unten verwendet genau das.
Hier ist der entscheidende Unterschied: Ein Test ruft eine Hilfsfunktion auf und kann ihr Argumente übergeben. Ein Test kann eine Fixture nicht aufrufen; er kann sie nur benennen. Eine Fixture erzeugt für jeden anfordernden Test stets dasselbe.
Also:
- Verwende eine Fixture, wenn viele Tests denselben Ausgangszustand benötigen, und besonders dann, wenn dieser Zustand danach aufgeräumt werden muss. Nur eine Fixture erhält einen garantierten Teardown.
- Verwende eine Hilfsfunktion, wenn jeder Test ein etwas anderes Objekt benötigt — einen Warenkorb mit diesen Posten, einen Benutzer mit jenem Namen. Für Argumente sind Funktionen da.
Beide lassen sich gut kombinieren: Eine Fixture kann eine Hilfsfunktion aufrufen, wie es das nächste Beispiel zeigt. (Es gibt auch einen Weg, eine Fixture zu erstellen, die eine Funktion zurückgibt — das „Factory“-Muster in Kapitel zwölf. Das brauchst du im Moment noch nicht.)
Ein vollständiges Beispiel
Ein Shop, bei dem Rabattcodes in einem Dictionary auf Modulebene liegen. Das Registrieren eines Codes verändert den globalen Zustand, daher muss ein Test, der einen registriert, ihn auch wieder entfernen.
shop.py:
DISCOUNTS = {}
def register_discount(code, percent):
DISCOUNTS[code] = percent
def remove_discount(code):
del DISCOUNTS[code]
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self, code=None):
amount = sum(price * quantity for _, price, quantity in self.lines)
if code is not None:
amount = amount * (100 - DISCOUNTS[code]) / 100
return round(amount, 2)test_shop.py:
import pytest
from shop import Cart, register_discount, remove_discount
def cart_with(*lines):
# A plain helper: it takes arguments, so each test can ask for its own cart.
cart = Cart()
for name, price, quantity in lines:
cart.add(name, price, quantity)
return cart
@pytest.fixture
def cart():
# The cart most tests share. Built fresh for every test that asks.
return cart_with(("pen", 15.0, 3), ("bag", 850.0, 1))
@pytest.fixture
def summer_code():
# Changes global state, so it must undo that change afterwards.
register_discount("SUMMER", 10)
yield "SUMMER"
remove_discount("SUMMER")
def test_total(cart):
assert cart.total() == 895.0
def test_summer_discount(cart, summer_code):
assert cart.total(summer_code) == 805.5
def test_code_is_gone_afterwards(cart):
with pytest.raises(KeyError):
cart.total("SUMMER")
def test_small_cart(summer_code):
cart = cart_with(("pen", 15.0, 2))
assert cart.total(summer_code) == 27.0
def test_zero_quantity_is_refused():
with pytest.raises(ValueError):
Cart().add("pen", 15.0, 0)pytest -q --setup-show
SETUP F cart
test_shop.py::test_total (fixtures used: cart) .
TEARDOWN F cart
SETUP F cart
SETUP F summer_code
test_shop.py::test_summer_discount (fixtures used: cart, summer_code) .
TEARDOWN F summer_code
TEARDOWN F cart
SETUP F cart
test_shop.py::test_code_is_gone_afterwards (fixtures used: cart) .
TEARDOWN F cart
SETUP F summer_code
test_shop.py::test_small_cart (fixtures used: summer_code) .
TEARDOWN F summer_code
test_shop.py::test_zero_quantity_is_refused .
5 passed in 0.01sDrei Dinge sind bemerkenswert.
Erstens: Jeder Test fordert nur das an, was er auch verwendet. test_small_cart wollte einen anderen Warenkorb, forderte cart also gar nicht an; er rief die Hilfsfunktion mit seinen eigenen Posten auf — und --setup-show bestätigt, dass cart dafür niemals erstellt wurde. test_zero_quantity_is_refused benötigt nur einen leeren Warenkorb und einen einzigen Aufruf, nutzt also gar keine Fixture; das ist die Zeile „Ausgangszustand, den sonst niemand teilt“ aus dem Plan.
Zweitens: test_code_is_gone_afterwards ist ein Test der Fixture. Er läuft nach test_summer_discount und prüft, ob SUMMER nicht mehr existiert. Um zu sehen, warum das wichtig ist, ändere summer_code so, dass es return "SUMMER" statt yield und der Cleanup-Zeile verwendet:
pytest -q --tb=no
..F.. [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_code_is_gone_afterwards - Failed: DID NOT RAISE Key...
1 failed, 4 passed in 0.01sDer Code ist aus dem Test, der ihn registriert hat, in den nächsten entwichen. Ohne diese Prüfung wäre dieser Leak unbemerkt geblieben.
Drittens: Die Hilfsfunktion und die Fixture arbeiten zusammen. cart_with weiß, wie man einen Warenkorb baut; die Fixture cart entscheidet, welchen Warenkorb die meisten Tests gemeinsam nutzen.
Wenn etwas schiefgeht
fixture 'crat' not found
_________________________ ERROR at setup of test_empty _________________________
file .../test_typo.py, line 11
def test_empty(crat):
E fixture 'crat' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, cart, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, subtests, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.Ein Parametername passte zu keiner Fixture. Entweder ist er falsch geschrieben — hier crat statt cart, was direkt in der Liste steht — oder der Funktion fehlt ihre @pytest.fixture-Zeile, wodurch sie in der Liste gar nicht erst auftaucht. Beachte das E und ERROR, nicht F und FAILED: Der Test wurde gar nicht erst gestartet.
Fixture "cart" called directly
Fixture "cart" called directly. Fixtures are not meant to be called directly,
but are created automatically when test functions request them as parameters.Der Test hat cart() in seinem Rumpf aufgerufen. Eine Fixture wird angefordert, indem man sie als Parameter benennt, niemals durch Aufrufen. Wenn du sie wirklich mit eigenen Argumenten aufrufen möchtest, suchst du eine Hilfsfunktion.
ERROR at setup of test_total statt eines Fehlschlags Die Exception wurde innerhalb der Fixture ausgelöst, bevor der Test lief — zum Beispiel ValueError: quantity must be at least 1: 0 durch ein fehlerhaftes add() im Setup. Ein E bedeutet „die Vorbereitung ist fehlgeschlagen“; ein F bedeutet „die Behauptung war falsch“. Suche zuerst in der Fixture nach der Ursache.
fixture function has more than one 'yield' Eine Fixture führt yield genau einmal aus. Setup gehört über das yield, Cleanup darunter; für zwei Werte gib ein Tupel per yield zurück oder schreibe zwei Fixtures.
Ein Test besteht isoliert, schlägt aber zusammen mit anderen fehl Etwas wird zwischen Tests geteilt — eine Liste oder ein Dictionary auf Modulebene oder eine globale Variable, die eine Fixture geändert und nicht zurückgesetzt hat. Baue das Objekt in einer Fixture auf, und wenn es sich um einen globalen Zustand handelt, mache die Änderung nach yield rückgängig.
Der Cleanup wurde nicht ausgeführt Der Cleanup steht im Testrumpf hinter einem fehlschlagenden assert, oder die Fixture verwendet return. Verschiebe ihn in eine yield-Fixture hinter das yield.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Beide Tests fordern dieselbe Fixture an und beide hängen etwas an die Liste an. Wie lautet die letzte Zeile von pytest -q?
import pytest
@pytest.fixture
def basket():
return []
def test_one(basket):
basket.append("pen")
assert basket == ["pen"]
def test_two(basket):
basket.append("bag")
assert basket == ["bag"]- A1 failed, 1 passed — test_two sieht ['pen', 'bag']
- B1 passed, 1 error
- C2 passed
- D2 failed
Dies wird mit pytest -q -s ausgeführt. In welcher Reihenfolge werden die drei Zeilen ausgegeben (ohne pytests eigene Punkte und Zusammenfassung)?
import pytest
@pytest.fixture
def db():
print("open")
yield "db"
print("close")
def test_query(db):
print("query")- Aopen query
- Bopen close query
- Cquery open close
- Dopen query close
Dieser Test schlägt fehl. Wo liegt das Problem?
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- A`len()` kann nicht auf eine Fixture angewendet werden
- BDie Fixture benötigt `yield`, nicht `return`
- CDie Fixture muss ein Tupel zurückgeben
- DDer Test ruft die Fixture selbst auf; er sollte stattdessen `cart` als Parameter entgegennehmen
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
Schreibe library.py mit einem LOG = [] auf Modulebene und einer Klasse Library: add(title); borrow(title), was einen ValueError auslöst, wenn der Titel nicht im Regal steht, und ihn andernfalls entfernt und "borrow <title>" an LOG anhängt; give_back(title); sowie available(), was eine sortierte Liste der Titel im Regal zurückgibt.
Schreibe anschließend test_library.py. Plane es zuerst — eine Tabelle mit einer Spalte für den Ausgangszustand, wie am Anfang dieses Kapitels — und schreibe dann:
- Eine Fixture
library, die eineLibrarymit drei Büchern zurückgibt, und mindestens drei Tests, die sie nutzen — einer davon leiht ein Buch aus, sodass ein späterer Test zeigen kann, dass das Regal wieder voll ist - Eine Fixture
borrowed, die vonlibraryabhängt, ein Buch ausleiht und dessen Titel zurückgibt, mit zwei Tests, die sie nutzen - Eine
yield-Fixtureclean_log, dieLOGvor dem Test und nochmals danach leert; einen Test, der sie nutzt, und einen Test danach, derLOG == []prüft - Eine einfache Hilfsfunktion
library_with(*titles), die vonlibraryund von einem Test genutzt wird, der ein leeres Regal benötigt
Führe pytest -q aus, danach pytest -q --setup-show, und vergleiche jede Zeile mit deinen Erwartungen.
Lasse es schließlich absichtlich fehlschlagen: Ersetze in clean_log das yield und die Zeile danach durch return LOG. Welcher Test schlägt fehl, und ist es derjenige, der die Fixture genutzt hat?
Lösung
Zuerst der Plan:
| Fall | Ausgangszustand | Aktion | Erwartet | |---|---|---|---| | Drei Bücher im Regal | Dune, Emma, Ulysses | available() | alle drei, sortiert | | Ausleihen entfernt ein Buch | derselbe | borrow("Dune") | Emma, Ulysses | | Kein Leak zwischen Tests | derselbe | — | immer noch drei | | Kann nicht zweimal ausleihen | derselbe, Emma ausgeliehen | borrow("Emma") | ValueError | | Zurückgeben | derselbe, Emma ausgeliehen | give_back("Emma") | Emma im Regal | | Ausleihen wird protokolliert | derselbe, LOG leer | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | Log aufgeräumt | nach dem obigen Test | — | LOG == [] | | Randfall: leeres Regal | keine Bücher | available() | [] |
Zwei Ausgangszustände wiederholen sich — das Regal mit drei Büchern und das Regal, aus dem Emma ausgeliehen ist —, sie wurden also zu Fixtures. Das leere Log wird nur einmal benötigt, betrifft aber einen globalen Zustand, der danach zurückgesetzt werden muss, und nur eine Fixture garantiert das; es ist also ebenfalls eine Fixture. Das leere Regal wird einmal gebraucht und hinterlässt nichts, daher nutzt es die Hilfsfunktion.
library.py:
LOG = []
class Library:
def __init__(self):
self.shelf = set()
def add(self, title):
self.shelf.add(title)
def borrow(self, title):
if title not in self.shelf:
raise ValueError(f"not on the shelf: {title}")
self.shelf.remove(title)
LOG.append(f"borrow {title}")
def give_back(self, title):
self.shelf.add(title)
def available(self):
return sorted(self.shelf)test_library.py:
import pytest
from library import LOG, Library
def library_with(*titles):
# Helper: each caller chooses its own shelf.
library = Library()
for title in titles:
library.add(title)
return library
@pytest.fixture
def library():
return library_with("Dune", "Emma", "Ulysses")
@pytest.fixture
def borrowed(library):
library.borrow("Emma")
return "Emma"
@pytest.fixture
def clean_log():
LOG.clear()
yield LOG
LOG.clear()
def test_three_books_on_the_shelf(library):
assert library.available() == ["Dune", "Emma", "Ulysses"]
def test_borrow_removes_the_book(library):
library.borrow("Dune")
assert library.available() == ["Emma", "Ulysses"]
def test_next_test_still_sees_three(library):
assert len(library.available()) == 3
def test_cannot_borrow_twice(library, borrowed):
with pytest.raises(ValueError):
library.borrow(borrowed)
def test_give_back_returns_it(library, borrowed):
library.give_back(borrowed)
assert borrowed in library.available()
def test_borrow_is_logged(library, clean_log):
library.borrow("Ulysses")
assert clean_log == ["borrow Ulysses"]
def test_log_is_empty_afterwards():
assert LOG == []
def test_empty_library():
library = library_with()
assert library.available() == []pytest -q
........ [100%]
8 passed in 0.01sMit --setup-show siehst du SETUP F library einmal für jeden der sechs Tests, die sie verwenden, borrowed nach library eingerichtet und vor ihr abgebaut, sowie überhaupt keine Fixture-Zeilen für die letzten beiden Tests.
Und das Experiment mit return LOG anstelle des yield:
pytest -q --tb=no
......F. [100%]
=========================== short test summary info ============================
FAILED test_library.py::test_log_is_empty_afterwards - AssertionError: assert...
1 failed, 7 passed in 0.01sDie Entscheidungen im Einzelnen:
libraryverwendetreturn, nichtyield. Eine neueLibraryändert nichts außerhalb des Tests, es gibt also nichts rückgängig zu machen.yieldist für Fixtures gedacht, die etwas aufräumen müssen, keine Frage des persönlichen Stils.test_next_test_still_sees_threeist mit Absicht da. Er läuft direkt nach einem Test, derDuneausgeliehen hat, und besteht, weillibraryerneut erstellt wurde. In--setup-showkannst du es nachzählen:SETUP F libraryeinmal pro Test.borrowedgibt den Titel zurück. Die Tests verwendenborrowed, statt"Emma"zu wiederholen; ändert die Fixture also das ausgeliehene Buch, ziehen die Tests automatisch mit. Und weilborrowedvonlibraryabhängt, erhalten beide Tests, die beides anfordern, dieselbe Library — diejenige, aus der Emma entnommen wurde.clean_logleert auf beiden Seiten desyield. Vorher, weil andere Tests, dieborrow()aufrufen —test_borrow_removes_the_book, dieborrowed-Fixture —, bereits inLOGgeschrieben haben; der Test muss von einem bekannten Zustand ausgehen. Nachher, damit nichts in den nächsten Test entweicht.- *Das Experiment schlägt im anderen Test fehl.*
test_borrow_is_loggedbesteht weiterhin;test_log_is_empty_afterwards, der überhaupt nichts falsch gemacht hat, schlägt fehl. So sieht ein fehlender Cleanup immer aus: Der Fehler zeigt sich an einer ganz anderen Stelle. Und genau deshalb enthält die Lösung einen Test, der das Aufräumen überprüft. library_withist eine Hilfsfunktion, keine Fixture, weil der eine Test, der sie nutzt, ein anderes Regal wünscht und nur eine Funktion Argumente entgegennehmen kann.libraryruft sie ebenfalls auf, sodass das Wissen darüber, wie eine Bibliothek befüllt wird, an einem einzigen Ort liegt.
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