Exceptions testen — pytest.raises
Fehler sind Verhalten, also brauchen sie auch Tests. pytest.raises, DID NOT RAISE, Unterklassen-Matching, warum match= eine Regex ist, die abgefangene Exception mit excinfo untersuchen und warum nur die letzte Zeile in den with-Block gehört.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist eine kleine Funktion, die ein Guthaben schützt. Sie hat zwei eingebaute Verweigerungen: ein Betrag, der nicht positiv ist, und ein Betrag, der größer ist als das vorhandene Guthaben.
wallet.py:
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amountDiese beiden raise-Zeilen sind genauso Verhalten wie das return. Ein Aufrufer verlässt sich darauf, dass withdraw(100.0, -5.0) die Ausführung verweigert — und wenn diese Verweigerung eines Tages verschwindet, fließt Geld in die falsche Richtung. Das verdient also einen Test. Der erste Test, den die meisten schreiben, sieht so aus:
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passNimm nun an, jemand löscht die Prüfung if amount <= 0 aus withdraw. Führe pytest -q aus:
. [100%]
1 passed in 0.01sDie Prüfung ist weg und der Test besteht trotzdem. Ohne Exception (Ausnahme) beendet sich der try-Block einfach, except wird übersprungen und die Funktion endet ohne fehlgeschlagenes assert. Der Test hat lediglich geprüft, dass ein ValueError ignoriert wird, falls er auftritt. Er hat nie verlangt, dass der Fehler auftreten muss.
In diesem Kapitel geht es darum, das sauber auszudrücken — und um die Handvoll Möglichkeiten, wie ein Test für einen Fehler bestehen kann, obwohl er das nicht sollte.
Am Ende dieses Kapitels kannst du
- Mit
pytest.raisestesten, dass Code eine Exception auslöst, undDID NOT RAISElesen - Erklären, warum
pytest.raises(Exception)fast immer zu weit gefasst ist - Die Meldung mit
match=prüfen, im Wissen, dass es sich um einen regulären Ausdruck handelt - Die abgefangene Exception über
excinfountersuchen - Nur die auslösende Zeile im
with-Block behalten und begründen, warum - Eine
ExceptionGrouperkennen und prüfen, was in ihr steckt
Voraussetzungen: Test-Discovery und Tests ausführen.
Bevor du den Test schreibst
Ein Test für einen Fehler ist nur so gut wie die Entscheidung dahinter. Bevor du pytest.raises tippst, kläre drei Dinge auf dem Papier.
1. Was genau ist versprochen? Lies withdraw als Vertrag, nicht als Code:
- Eingabe: ein Guthaben und ein Betrag. Ausgabe: das neue Guthaben,
balance - amount. - Erste Verweigerung: Ein Betrag von null oder weniger löst
ValueErroraus, und die Meldung nennt den Betrag. - Zweite Verweigerung: Ein Betrag, der größer ist als das Guthaben, löst
InsufficientFundsaus, dasbalanceundamountals Attribute trägt. - Seiteneffekte: keine.
withdrawändert nichts außerhalb von sich selbst, daher gilt „nach einer Verweigerung hat sich nichts geändert“ hier automatisch — im Bank-Beispiel am Ende wird das anders sein.
Jede Verweigerung ist ein eigenes Versprechen mit drei Teilen, die du prüfen kannst: welche Klasse, welche Meldung und welcher Zustand zurückbleibt.
2. Was muss vorhanden sein? Nichts, was über das letzte Kapitel hinausgeht. pytest.raises ist Teil von pytest, und re gehört zur Standardbibliothek. Du brauchst die aktive virtuelle Umgebung, pytest installiert und wallet.py muss aus der Testdatei importierbar sein — in diesem Kapitel liegt sie im selben Ordner wie test_wallet.py. Überprüfe die ersten beiden Punkte einmal:
$ python -m pytest --version
pytest 9.1.1Sieh dir dann das tatsächliche Verhalten des Codes an, bevor du eine einzige Behauptung darüber aufstellst. Eine kurze Stichprobe beantwortet Fragen, die eine Vermutung nicht klären kann — wird 0.0 zurückgegeben oder verweigert? Wie sieht die Meldung aus?
from wallet import InsufficientFunds, withdraw
print(withdraw(100.0, 30.0))
print(withdraw(100.0, 100.0))
try:
withdraw(100.0, 100.01)
except InsufficientFunds as error:
print(repr(error))70.0
0.0
InsufficientFunds('cannot withdraw 100.01; balance is 100.00')3. Welche Fälle? Fehler treten an Grenzen auf, also plane um sie herum — der Regelfall, die Ränder jeder Verweigerung auf beiden Seiten und das eindeutig Ungültige:
| Fall | Eingabe | Erwartet | |---|---|---| | Normale Abhebung | withdraw(100.0, 30.0) | gibt 70.0 zurück | | Gesamtes Guthaben (Grenze, erlaubt) | withdraw(100.0, 100.0) | gibt 0.0 zurück, kein Fehler | | Knapp darüber (Grenze, verweigert) | withdraw(100.0, 100.01) | InsufficientFunds, amount == 100.01 | | Null (Grenze, verweigert) | withdraw(100.0, 0) | ValueError | | Negativ | withdraw(100.0, -5.0) | ValueError, Meldung enthält (got -5.0) | | Eindeutige Überziehung | withdraw(100.0, 150.0) | InsufficientFunds, balance == 100.0, amount == 150.0 |
Die beiden „erlaubt“-Zeilen sind genauso wichtig wie die Verweigerungen. Eine Prüfung mit >= statt > verweigert den Fall mit dem gesamten Guthaben — und nur ein Test, der dort keinen Fehler erwartet, wird das bemerken.
Was du nicht testen solltest. Pythons eigenes Verhalten: Dass der Aufruf von withdraw(100.0) mit einem fehlenden Argument einen TypeError auslöst, ist Pythons Versprechen, nicht deins. Den genauen Wortlaut jeder Meldung, es sei denn, der Wortlaut selbst ist der Vertrag — ein Bruchstück, das den Wert enthält, genügt. Und alles, was die Funktion nie versprochen hat, etwa was passiert, wenn du den String "ten" übergibst. Solche Tests hinzuzufügen erzeugt Tests, die bei harmlosen Änderungen fehlschlagen und nichts absichern.
Der Rest des Kapitels setzt diese Tabelle in Tests um, Werkzeug für Werkzeug.
pytest.raises — „das muss eine Exception auslösen“
pytest.raises ist ein Kontextmanager. Von allem innerhalb des with-Blocks wird erwartet, dass es die benannte Exception auslöst:
import pytest
from wallet import withdraw
def test_negative_amount_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, -5.0)
def test_zero_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 0).. [100%]
2 passed in 0.01sWenn ein ValueError aus dem Block hervorgeht, fängt pytest.raises ihn ab und der Test läuft nach dem Block weiter. Das ist die Hälfte, die try/except bereits hatte. Die andere Hälfte ist das, was passiert, wenn nichts ausgelöst wird. Lösche die Prüfung if amount <= 0 noch einmal und führe dieselbe Datei aus:
FF [100%]
=================================== FAILURES ===================================
_______________________ test_negative_amount_is_rejected _______________________
def test_negative_amount_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:7: Failed
____________________________ test_zero_is_rejected _____________________________
def test_zero_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:12: Failed
=========================== short test summary info ============================
FAILED test_wallet.py::test_negative_amount_is_rejected - Failed: DID NOT RAI...
FAILED test_wallet.py::test_zero_is_rejected - Failed: DID NOT RAISE ValueError
2 failed in 0.01sDID NOT RAISE ValueError ist der Satz, den die try/except-Variante nie sagen konnte. Mehr ist pytest.raises nicht: den erwarteten Fehler abfangen und laut fehlschlagen, wenn er ausbleibt.
Welche Exceptions als Treffer gelten
pytest.raises(X) passt genau so wie except X:: auf X selbst oder jede Unterklasse davon. Pythons Exceptions bilden einen Stammbaum, von dem du hier einen Ast siehst:
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
TrueEin KeyError ist also ein LookupError. Das ist nützlich, aber auch die Falle. Hier sind zwei Tests, die beide bestehen:
import pytest
from wallet import withdraw
def test_missing_price_is_a_lookup_error():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_too_broad():
with pytest.raises(Exception):
withdraw(100.0) # forgot the amount.. [100%]
2 passed in 0.01sDer erste ist in Ordnung — die Elternklasse wird ganz bewusst abgefangen. Der zweite ist ein Test, der nichts beweist. withdraw(100.0) hat den Funktionskörper nie erreicht: Python hat wegen des fehlenden Arguments einen TypeError ausgelöst, und da jede gewöhnliche Exception eine Unterklasse von Exception ist, hat pytest.raises(Exception) das bereitwillig akzeptiert. Ein Tippfehler, ein falscher Import, ein AttributeError — sie alle würden diesen Test grün werden lassen.
Die Regel: Benenne die spezifischste Exception, die der Code laut Versprechen auslöst. Je weiter die Klasse gefasst ist, desto mehr unzusammenhängende Fehler verdeckt sie.
Die andere Richtung ist streng. Eine Exception, die nicht der genannten Klasse oder einer Unterklasse davon entspricht, wird nicht abgefangen; sie entkommt dem Block und lässt den Test als gewöhnlichen Fehler scheitern. (pytest -q --tb=short kürzt jeden Traceback auf eine Zeile pro Frame.)
import pytest
from wallet import withdraw
def test_overdraft_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 150.0)F [100%]
=================================== FAILURES ===================================
__________________________ test_overdraft_is_rejected __________________________
test_wallet.py:8: in test_overdraft_is_rejected
withdraw(100.0, 150.0)
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_overdraft_is_rejected - wallet.InsufficientFunds:...
1 failed in 0.01sNicht DID NOT RAISE — es wurde etwas ausgelöst, nur nicht das, was du angegeben hast, und pytest zeigt dir, woher es kam.
match= — die Meldung prüfen
Der Typ sagt, welche Art von Verweigerung vorliegt. Die Meldung sagt warum und enthält oft den fehlerhaften Wert. match= überprüft sie:
import pytest
from wallet import withdraw
def test_message_says_positive():
with pytest.raises(ValueError, match="must be positive"):
withdraw(100.0, -5.0)
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0).F [100%]
=================================== FAILURES ===================================
______________________________ test_whole_message ______________________________
def test_whole_message():
> with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
E AssertionError: Regex pattern did not match.
E Expected regex: 'amount must be positive (got -5.0)'
E Actual message: 'amount must be positive (got -5.0)'
E Did you mean to `re.escape()` the regex?
test_wallet.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_message - AssertionError: Regex pattern did...
1 failed, 1 passed in 0.01sDer erwartete Text und die tatsächliche Meldung sind identisch, und trotzdem ist der Test fehlgeschlagen. Der Grund steht in der ersten Zeile: match= ist kein einfacher String. Es ist ein regulärer Ausdruck, und pytest führt re.search(pattern, str(exception)) aus. Zwei Konsequenzen:
re.searchsucht an beliebiger Stelle in der Meldung."must be positive"hat mitten im Text getroffen. Um den Anfang oder das Ende festzulegen, verwende^und$.- Manche Zeichen haben eine Sonderbedeutung. In einer Regex bilden
( )eine Gruppe und passen auf überhaupt keine Klammer;.passt auf jedes beliebige Zeichen; auch+,*,?,[,$haben jeweils eigene Aufgaben.
Der Fall mit . ist der unauffälligere, weil er in Richtung eines Bestehens fehlschlägt:
import re
print(re.search("100.00", "balance is 100.00"))
print(re.search("100.00", "balance is 100500"))
print(re.search(re.escape("100.00"), "balance is 100500"))<re.Match object; span=(11, 17), match='100.00'>
<re.Match object; span=(11, 17), match='100500'>
None"100.00" hat problemlos auf 100500 gepasst. re.escape verwandelt jedes Sonderzeichen in ein literales Zeichen. Wenn du also „genau diesen Text“ meinst, leite den Text durch diese Funktion — genau wie pytests Hinweis vorgeschlagen hat:
import re
import pytest
from wallet import withdraw
def test_whole_message():
message = "amount must be positive (got -5.0)"
with pytest.raises(ValueError, match=re.escape(message)):
withdraw(100.0, -5.0). [100%]
1 passed in 0.01sWie viel von der Meldung abgeglichen werden soll, ist Ermessenssache. Ein kurzes, prägnantes Fragment — der Wert, das Schlüsselwort — übersteht harmlose Umformulierungen; der ganze Satz bricht in dem Moment, in dem jemand einen Tippfehler korrigiert.
excinfo — die abgefangene Exception
with pytest.raises(...) as excinfo gibt dir einen Zugriff auf das, was abgefangen wurde. Schau mit pytest -q -s einmal hinein (-s lässt print durch):
import pytest
from wallet import InsufficientFunds, withdraw
def test_look_inside():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
print()
print("type :", excinfo.type)
print("value :", repr(excinfo.value))
print("str :", str(excinfo.value))
print("fields:", excinfo.value.balance, excinfo.value.amount)type : <class 'wallet.InsufficientFunds'>
value : InsufficientFunds('cannot withdraw 150.00; balance is 100.00')
str : cannot withdraw 150.00; balance is 100.00
fields: 100.0 150.0
.
1 passed in 0.01sexcinfo.typeist die Klasse, die tatsächlich ausgelöst wurde — nützlich, wenn du eine Elternklasse abgefangen hast und wissen willst, welche Kindklasse ankam.excinfo.valueist das Exception-Objekt selbst mit allen Attributen, die es trägt.str(excinfo.value)ist die Meldung — derselbe String, denmatch=durchsucht.
Die print-Aufrufe dienten nur zur Veranschaulichung. In einem echten Test prüfst du mit assert:
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_reports_both_numbers():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.balance == 100.0
assert excinfo.value.amount == 150.0
assert str(excinfo.value) == "cannot withdraw 150.00; balance is 100.00". [100%]
1 passed in 0.01sWenn eine Exception Daten als Attribute mitbringt, ist eine Prüfung der Attribute mit assert stabiler als das Parsen der Meldung. Beachte, dass die Asserts nach dem with-Block stehen, nicht darin. Das ist keine Stilfrage; der nächste Abschnitt erklärt den Grund.
Nur die auslösende Zeile gehört hinein
In dem Moment, in dem eine Exception ausgelöst wird, verlässt Python den Block. Jede Zeile nach der auslösenden wird übersprungen — auch Asserts:
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 999.0 # never runs
def test_text_amount_is_rejected():
with pytest.raises(ValueError):
amount = float("ten") # raises ValueError itself
withdraw(100.0, amount).. [100%]
2 passed in 0.01sBeide Tests sind grün, und beide sind falsch. Im ersten ist assert balance == 999.0 eine falsche Behauptung, die nie ausgeführt wurde. Im zweiten hat float("ten") in der Setup-Zeile selbst einen ValueError ausgelöst, sodass withdraw nicht einmal aufgerufen wurde — der Test besteht unabhängig davon, ob withdraw überhaupt etwas prüft.
Die Regel: Setup vor dem Block, ein einziger auslösender Aufruf darin, Prüfungen danach.
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 100.0. [100%]
1 passed in 0.01sJetzt wird das Assert ausgeführt und prüft etwas Reales: Eine abgelehnte Abhebung hat balance unberührt gelassen, weil die Zuweisung nie stattgefunden hat.
Mehr als eine akzeptable Exception
pytest.raises nimmt auch ein Tupel entgegen, wiederum genau wie except. Jede der aufgeführten Klassen genügt, und excinfo.type sagt dir, welche ausgelöst wurde:
import pytest
from wallet import InsufficientFunds, withdraw
def test_bad_withdrawal_is_refused():
with pytest.raises((ValueError, InsufficientFunds)) as excinfo:
withdraw(100.0, 150.0)
print()
print(excinfo.type.__name__)InsufficientFunds
.
1 passed in 0.01sVerwende das, wenn der Vertrag wirklich „eine von diesen“ lautet — etwa Code, der je nach Plattform berechtigterweise die eine oder die andere auslösen kann. Für deine eigenen Funktionen ist eine einzige präzise Klasse fast immer das bessere Versprechen.
Testen, dass etwas keine Exception auslöst
Es gibt kein pytest.does_not_raise(), nach dem man greifen müsste, und du brauchst auch keins. Rufe den Code einfach auf. Wenn er eine Exception auslöst, schlägt der Test mit dieser Exception und ihrem Traceback fehl:
from wallet import withdraw
def test_whole_balance_can_be_withdrawn():
assert withdraw(100.0, 100.0) == 0.0. [100%]
1 passed in 0.01sÄndere amount > balance zu amount >= balance in withdraw — ein Off-by-one-Fehler, den viele machen — und pytest -q --tb=short meldet:
F [100%]
=================================== FAILURES ===================================
_____________________ test_whole_balance_can_be_withdrawn ______________________
test_wallet.py:5: in test_whole_balance_can_be_withdrawn
assert withdraw(100.0, 100.0) == 0.0
^^^^^^^^^^^^^^^^^^^^^^
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 100.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_balance_can_be_withdrawn - wallet.Insuffici...
1 failed in 0.01sEine unerwartete Exception ist bereits ein Fehlschlag. Einen Aufruf in try/except: pytest.fail() zu packen, wirft diesen Traceback nur weg.
Mehrere Fehler auf einmal: ExceptionGroup
Seit Python 3.11 kann Code mehrere Exceptions zusammen als ExceptionGroup auslösen. Füge eine Batch-Version zu wallet.py hinzu, die jede Abhebung versucht und alle Fehlschläge zusammen meldet:
def withdraw_all(balance: float, amounts: list[float]) -> float:
errors = []
for amount in amounts:
try:
balance = withdraw(balance, amount)
except (ValueError, InsufficientFunds) as error:
errors.append(error)
if errors:
raise ExceptionGroup(f"{len(errors)} withdrawals failed", errors)
return balanceEine Gruppe ist eine eigenständige Exception-Klasse. pytest.raises(ValueError) schaut nicht in sie hinein. Um den Inhalt zu prüfen, fange die Gruppe ab und frage excinfo.group_contains, oder beschreibe die gesamte Gruppe mit pytest.RaisesGroup:
import pytest
from wallet import InsufficientFunds, withdraw_all
def test_plain_raises_does_not_look_inside():
with pytest.raises(ValueError):
withdraw_all(100.0, [-5.0, 30.0, 500.0])
def test_group_contains():
with pytest.raises(ExceptionGroup) as excinfo:
withdraw_all(100.0, [-5.0, 30.0, 500.0])
assert excinfo.group_contains(ValueError, match="must be positive")
assert excinfo.group_contains(InsufficientFunds)
assert not excinfo.group_contains(KeyError)
def test_raises_group():
with pytest.RaisesGroup(ValueError, InsufficientFunds):
withdraw_all(100.0, [-5.0, 30.0, 500.0])Führe den Befehl mit pytest -q --tb=no aus, um nur das Ergebnis zu sehen:
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01sDer erste Test schlägt fehl: Die Gruppe ist pytest.raises(ValueError) unberührt entkommen. group_contains beantwortet die Frage „befindet sich davon irgendwo drinnen eine?“ und akzeptiert ebenfalls match=. pytest.RaisesGroup ist strenger: Die Gruppe muss genau die aufgeführten Exceptions enthalten — nicht mehr, nicht weniger, in beliebiger Reihenfolge. Beide kamen in pytest 8 hinzu und stehen in pytest 9 zur Verfügung. Du wirst sie nicht oft brauchen; du wirst sie an dem Tag brauchen, an dem dir eine Bibliothek eine Gruppe übergibt.
Ein vollständiges Beispiel
Eine kleine Bank mit drei Verweigerungen: ein unbekanntes Konto, ein nicht positiver Betrag und eine Überziehung. Die Betragsprüfung funktioniert genau wie die von withdraw, daher konzentrieren sich die folgenden Tests auf die anderen beiden. AccountNotFound ist absichtlich eine Unterklasse von LookupError, damit Aufrufer sie so abfangen können, wie sie einen fehlenden Dictionary-Schlüssel abfangen würden.
bank.py:
class AccountNotFound(LookupError):
def __init__(self, account_id: str):
self.account_id = account_id
super().__init__(f"no account with id {account_id!r}")
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
class Bank:
def __init__(self) -> None:
self._balances: dict[str, float] = {}
def open(self, account_id: str, deposit: float) -> None:
self._balances[account_id] = deposit
def balance(self, account_id: str) -> float:
try:
return self._balances[account_id]
except KeyError:
raise AccountNotFound(account_id) from None
def transfer(self, source: str, target: str, amount: float) -> None:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
# Look both accounts up first, so nothing moves if either is missing.
available = self.balance(source)
self.balance(target)
if amount > available:
raise InsufficientFunds(available, amount)
self._balances[source] -= amount
self._balances[target] += amounttest_bank.py:
import pytest
from bank import AccountNotFound, Bank, InsufficientFunds
def make_bank() -> Bank:
bank = Bank()
bank.open("alice", 100.0)
bank.open("bob", 20.0)
return bank
def test_transfer_moves_money():
bank = make_bank()
bank.transfer("alice", "bob", 30.0) # must not raise
assert bank.balance("alice") == 70.0
assert bank.balance("bob") == 50.0
def test_unknown_account_is_a_lookup_error():
bank = make_bank()
with pytest.raises(LookupError) as excinfo:
bank.balance("carol")
assert excinfo.type is AccountNotFound
assert excinfo.value.account_id == "carol"
def test_unknown_target_moves_nothing():
bank = make_bank()
with pytest.raises(AccountNotFound, match="'carol'"):
bank.transfer("alice", "carol", 30.0)
assert bank.balance("alice") == 100.0
def test_overdraft_reports_both_numbers():
bank = make_bank()
with pytest.raises(InsufficientFunds) as excinfo:
bank.transfer("bob", "alice", 25.0)
assert excinfo.value.balance == 20.0
assert excinfo.value.amount == 25.0
assert bank.balance("bob") == 20.0pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/bank
collecting ... collected 4 items
test_bank.py::test_transfer_moves_money PASSED [ 25%]
test_bank.py::test_unknown_account_is_a_lookup_error PASSED [ 50%]
test_bank.py::test_unknown_target_moves_nothing PASSED [ 75%]
test_bank.py::test_overdraft_reports_both_numbers PASSED [100%]
============================== 4 passed in 0.01s ===============================Drei Dinge sind beachtenswert.
Erstens beschränkt jeder Test seinen with-Block auf einen einzigen Aufruf und prüft den Zustand danach. test_unknown_target_moves_nothing ist der besonders wertvolle: Wenn transfer das Geld von Alice abbuchen würde, bevor festgestellt wird, dass Carol gar nicht existiert, würde es trotzdem AccountNotFound auslösen — und das Assert nach dem Block würde die halbfertige Überweisung mit assert 70.0 == 100.0 aufdecken.
Zweitens fängt test_unknown_account_is_a_lookup_error bewusst die Elternklasse ab, weil das das Versprechen von bank.py ist, und nutzt dann excinfo.type, um genau festzustellen, welche Kindklasse ankam.
Drittens prüft match="'carol'", dass die Meldung das angeforderte Konto nennt — das eine Detail, das eine Person beim Lesen des Fehlers am meisten braucht.
Wenn etwas schiefgeht
Failed: DID NOT RAISE ValueError Der Block wurde beendet, ohne eine Exception auszulösen. Entweder hat der Code seine Prüfung wirklich verloren (der Test hat seine Aufgabe erfüllt), oder der Aufruf im Block ist nicht der, für den du ihn hältst — falsche Argumente, die zufällig gültig sind, oder eine Funktion, die einen Fehlerwert zurückgibt, statt eine Exception auszulösen.
AssertionError: Regex pattern did not match. gefolgt von Did you mean to re.escape() the regex? Die Meldung stimmt, enthält aber Regex-Zeichen, meist (, ), ., [ oder +. Packe den Text in re.escape(...). Fehlt der Hinweis, unterscheidet sich die Meldung tatsächlich: Vergleiche die Zeilen Expected regex und Actual message Zeichen für Zeichen.
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 Das Muster ist nicht einmal eine gültige Regex — hier eine öffnende ( ohne schließende Klammer. Dieselbe Lösung: re.escape.
Der Test schlägt mit einer anderen Exception fehl, etwa wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 Es wurde eine Exception ausgelöst, aber nicht die benannte oder eine Unterklasse davon, daher hat pytest.raises sie durchgelassen. Entscheide, welche der Code auslösen soll, und korrigiere entweder den Test oder den Code.
AssertionError: .value can only be used after the context manager exits Du hast excinfo.value innerhalb des with-Blocks verwendet. Verschiebe die Zeile unter den Block.
TypeError: Expected a BaseException type, but got 'str' Du hast pytest.raises("ValueError") geschrieben. Übergib die Klasse selbst, ohne Anführungszeichen.
FAILED ... - ExceptionGroup... aus einem pytest.raises(ValueError)-Block Der Code hat eine ExceptionGroup ausgelöst, die einen ValueError enthält. Eine Gruppe ist kein ValueError; verwende pytest.raises(ExceptionGroup) mit excinfo.group_contains(ValueError) oder pytest.RaisesGroup(ValueError).
Der Test besteht, aber du bist sicher, dass er das nicht sollte Suche nach Zeilen nach der auslösenden Zeile innerhalb des with-Blocks — sie werden nie ausgeführt — und nach pytest.raises(Exception), das fast alles akzeptiert. Mache den Code dann absichtlich kaputt und vergewissere dich, dass der Test rot wird.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Was ist die letzte Zeile, die pytest -q ausgibt?
import pytest
def test_missing_key():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_missing_index():
prices = [15.0, 60.0]
with pytest.raises(KeyError):
prices[5]- A2 passed in 0.01s
- B2 failed in 0.01s
- C1 failed, 1 passed in 0.01s
- D1 passed, 1 error in 0.01s
Dieser Test besteht. Was ist daran falsch?
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_names_the_amount():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.amount == 999.0- A`pytest.raises` lässt den Test fehlschlagen, weil das Assert `AssertionError` ausgelöst hat
- BDas Assert steht im Block hinter der auslösenden Zeile, wird also nie ausgeführt
- CNichts — `excinfo.value.amount` ist wirklich 999.0
- D`excinfo` kann in einer `with`-Anweisung nicht benannt werden
withdraw(100.0, -5.0) löst einen ValueError mit genau dieser Meldung aus: amount must be positive (got -5.0). Was passiert mit dem Test?
import pytest
from wallet import withdraw
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0)- AEr besteht — die beiden Texte sind identisch
- BEr schlägt mit `Invalid regex pattern` fehl
- CEr schlägt mit `DID NOT RAISE ValueError` fehl
- DEr schlägt mit `Regex pattern did not match` fehl, weil die Klammern eine Regex-Gruppe bilden
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 inventory.py mit einer Funktion, remove_stock(stock: dict[str, int], item: str, quantity: int) -> int, und zwei Exception-Klassen:
UnknownItem, eine Unterklasse vonKeyError, ausgelöst, wennitemnicht instockenthalten ist, mit dem Artikelnamen als AttributOutOfStock, ausgelöst, wennquantitygrößer ist als der Restbestand, mit den Attributenavailableundrequested- ein einfacher
ValueErrormit dem Wert in der Meldung, wennquantitynicht positiv ist
Die Funktion gibt den Restbestand zurück und ändert das Dictionary nur bei Erfolg.
Schreibe dann test_inventory.py mit mindestens sechs Tests:
- Eine erfolgreiche Entnahme — kein
pytest.raises, rufe sie einfach auf und prüfe das Ergebnis mitassert - Ein unbekannter Artikel, abgefangen als
KeyError, wobeiexcinfo.typezeigt, dass esUnknownItemwar - Eine Menge von null, wobei
match=prüft, dass die Meldung den Wert nennt — nutzere.escape, falls die Meldung Klammern oder Punkte enthält - Eine Überziehung mit Prüfung von
excinfo.value.availableundexcinfo.value.requestedperassert - Dieselbe Überziehung mit Prüfung nach dem Block per
assert, dass das Dictionary unverändert ist - Die Entnahme exakt des Restbestands, was keine Exception auslösen darf
Mache den Code danach dreimal kaputt, jeweils einzeln, und lies jeden Fehlschlag: Lösche die Prüfung auf die Null-Menge (suche nach DID NOT RAISE), ändere > zu >= bei der Bestandsprüfung und verschiebe schließlich eines deiner Asserts in einen with-Block unter die auslösende Zeile und gib ihm einen falschen Wert. Die ersten beiden Tests sollten rot werden. Der dritte bleibt grün — und das ist die Lektion aus diesem Kapitel, an die man sich erinnern sollte.
Lösung
Beginne beim Plan, wie im Rest des Kapitels: ein Regelfall, die Grenze, die erlaubt sein muss (alles entnehmen), und jede Verweigerung geprüft auf Klasse, Meldung oder Attribute sowie den zurückbleibenden Zustand.
inventory.py:
class UnknownItem(KeyError):
def __init__(self, item: str):
self.item = item
super().__init__(item)
class OutOfStock(Exception):
def __init__(self, item: str, available: int, requested: int):
self.item = item
self.available = available
self.requested = requested
super().__init__(f"only {available} {item} left, {requested} requested")
def remove_stock(stock: dict[str, int], item: str, quantity: int) -> int:
if quantity <= 0:
raise ValueError(f"quantity must be positive (got {quantity})")
if item not in stock:
raise UnknownItem(item)
available = stock[item]
if quantity > available:
raise OutOfStock(item, available, quantity)
stock[item] = available - quantity
return stock[item]test_inventory.py:
import re
import pytest
from inventory import OutOfStock, UnknownItem, remove_stock
def test_removal_returns_what_is_left():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 3) == 7
assert stock == {"pen": 7}
def test_unknown_item_is_a_key_error():
stock = {"pen": 10}
with pytest.raises(KeyError) as excinfo:
remove_stock(stock, "eraser", 1)
assert excinfo.type is UnknownItem
assert excinfo.value.item == "eraser"
def test_zero_quantity_names_the_value():
stock = {"pen": 10}
with pytest.raises(ValueError, match=re.escape("(got 0)")):
remove_stock(stock, "pen", 0)
def test_overdraw_reports_both_numbers():
stock = {"pen": 10}
with pytest.raises(OutOfStock) as excinfo:
remove_stock(stock, "pen", 11)
assert excinfo.value.available == 10
assert excinfo.value.requested == 11
def test_overdraw_changes_nothing():
stock = {"pen": 10}
with pytest.raises(OutOfStock):
remove_stock(stock, "pen", 11)
assert stock == {"pen": 10}
def test_removing_everything_is_allowed():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 10) == 0
assert stock == {"pen": 0}pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/inventory
collecting ... collected 6 items
test_inventory.py::test_removal_returns_what_is_left PASSED [ 16%]
test_inventory.py::test_unknown_item_is_a_key_error PASSED [ 33%]
test_inventory.py::test_zero_quantity_names_the_value PASSED [ 50%]
test_inventory.py::test_overdraw_reports_both_numbers PASSED [ 66%]
test_inventory.py::test_overdraw_changes_nothing PASSED [ 83%]
test_inventory.py::test_removing_everything_is_allowed PASSED [100%]
============================== 6 passed in 0.01s ===============================Nun die drei Fehlerfälle. Löschen der Prüfung quantity <= 0, pytest -q --tb=no:
..F... [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_zero_quantity_names_the_value - Failed: DID NO...
1 failed, 5 passed in 0.01sWiederherstellen und Ändern von quantity > available zu quantity >= available:
.....F [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_removing_everything_is_allowed - inventory.Out...
1 failed, 5 passed in 0.01sWiederherstellen und das letzte Assert aus test_overdraw_changes_nothing in den Block unter die auslösende Zeile verschieben als assert stock == {"pen": 999}:
...... [100%]
6 passed in 0.01sWarum jede Entscheidung so getroffen wurde:
UnknownItemist eine Unterklasse vonKeyError. Ein fehlender Artikel ist wirklich ein fehlender Schlüssel, sodass bestehender Code, derKeyErrorabfängt, weiter funktioniert. Der Test fängtKeyErrorab — das öffentliche Versprechen — und legt dann mitexcinfo.typedie genaue Klasse fest.- Die Mengenprüfung kommt zuerst. Sie benötigt nichts aus
stock, schlägt also schnell fehl, und die Meldung bezieht sich auf das Argument, das der Aufrufer falsch übergeben hat. re.escape("(got 0)"). Das Bruchstück enthält den Wert, den ein Mensch beim Lesen des Fehlers braucht, undre.escapestellt sicher, dass die Klammern literal bleiben. Die Prüfung des gesamten Satzes würde in dem Moment fehlschlagen, in dem jemand den Text umformuliert.- Attribute statt Meldungen parsen.
availableundrequestedsind Zahlen, die du direkt vergleichen kannst; sie aus einem String herauszulesen würde die Formatierung testen, nicht die Daten. - Zwei Tests für die Überziehung. Einer prüft, was die Exception meldet, der andere, was der Code hinterlassen hat. Wenn einer fehlschlägt, sagt sein Name dir, welches Versprechen gebrochen wurde.
- Der Grenztest hat den
>=-Fehler aufgedeckt. Keiner der Fehlertests konnte das: Sie haben alle weiterhin eine Exception ausgelöst. Nur der Test, der an der Grenze keinen Fehler erwartet, hat es bemerkt. - Der dritte Fehlerfall blieb grün. Das ist die Zeile-nach-dem-Auslösen-Falle von vorhin im Kapitel: Das falsche Assert wurde nie ausgeführt. Der einzige Schutz ist die Gewohnheit — ein Aufruf im Block, die Prüfungen danach.
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