Konfiguration und Warnungen — die Regeln in einer Datei
Hör auf, bei jedem Durchlauf dieselben Flags zu tippen: Lege die Regeln des Projekts in pyproject.toml oder pytest.ini ab — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — und teste und steuere Warnungen mit pytest.warns, deprecated_call und filterwarnings.
Wie möchtest du lernen?
Das Problem, das wir lösen
Das Shop-Projekt hat jetzt die Form, die die meisten echten Projekte teilen: Code in src/, Tests in tests/.
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.pyDu hast gelernt, es richtig auszuführen, und „richtig“ ist zu einer langen Zeile geworden:
PYTHONPATH=src pytest -ra --strict-markers testsEin Teammitglied klonnt das Repository und tippt, was jeder tippen würde:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error
==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_pricing.py:1: in <module>
from shop.pricing import line_total
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================Mit dem Code stimmt nichts. Was nicht stimmt, ist, dass die Art, die Tests auszuführen, in deinem Kopf und in deiner Shell-History lebt statt im Projekt. CI wird eine dritte Variante ausführen, und drei Leute werden drei Ergebnisse aus einem einzigen Commit sehen.
Diese Flags sind nicht deine Vorliebe; sie sind die Regeln des Projekts. Dieses Kapitel verschiebt sie in eine Datei, die pytest bei jedem Durchlauf liest — und benutzt dieselbe Datei, um ein zweites, leiseres Problem zu lösen: Warnungen, die vorbeiscrollen und nie gelesen werden.
Am Ende dieses Kapitels kannst du
- pytest-Einstellungen in
pyproject.toml(oderpytest.ini) ablegen und an der Kopfzeile sehen, welche Datei verwendet wurde testpaths,addopts,pythonpath,minversion,markersundxfail_strictbewusst wählen und begründen, warum- Live-Logging mit
log_clieinschalten, wenn du einem Durchlauf zusehen willst - mit
pytest.warnsundpytest.deprecated_callprüfen, ob Code warnt, dabeimatchund die aufgezeichnete Liste nutzen - Warnungen mit
filterwarningsin der Konfiguration, als Marker und mit-Wsteuern — und sie in CI zu Fehlern machen
Voraussetzungen: Marker, skip und xfail.
Bevor du den Test schreibst
Dieses Kapitel schreibt weniger Tests als üblich und mehr Regeln darüber, wie Tests laufen. Das Denken davor ist dasselbe: Entscheide, was versprochen wird, und plane dann die Fälle, die dem Versprechen nachgehen.
Was versprochen wird. Zwei Arten von Versprechen sind im Spiel.
- Wie die Suite läuft. Wer reines
pytesttippt, bekommt dieselben Tests, denselben Importpfad und dieselbe Strenge wie CI. Ein falsch geschriebener Marker stoppt den Durchlauf. Einxfail, das anfängt zu bestehen, wird gemeldet, nicht versteckt. - Was der Code außer seinem Rückgabewert sagt.
apply_discount(total, percent)gibt eine Zahl zurück, und beipercent > 50sendet es zusätzlich eineUserWarning, die sagt, der Rabatt „looks unusually large“ wirkt.total_with_vat()funktioniert weiter, sendet aber eineDeprecationWarning, die aufline_total()zeigt. Eine Warnung ist Ausgabe; wenn sie Teil des Vertrags ist, wird sie wie Ausgabe getestet.
Was vorbereitet sein muss. Eine virtuelle Umgebung mit pytest 9 (pytest --version), der Code, der aus den Tests importierbar ist (das ist das pythonpath unten), und eine Konfigurationsdatei im Projektstamm. Keine zusätzlichen Plugins.
Der Plan für das Warnverhalten:
| Fall | Eingabe | Erwartet | | --- | --- | --- | | Normalfall | apply_discount(200.0, 10) | 180.0, keine Warnung | | Grenze, noch ruhig | apply_discount(200.0, 50) | 100.0, keine Warnung | | kurz hinter der Grenze | apply_discount(200.0, 60) | 80.0 und eine UserWarning mit „unusually large“ | | veralteter Name | total_with_vat(100.0, 1) | 115.0 und eine DeprecationWarning mit line_total | | eigene Abkündigung einer Bibliothek | alles, was sie auslöst | lässt unsere Suite nicht fehlschlagen |
Bei der Konfiguration selbst ist der „Test“ ein Durchlauf: Reines pytest vom Stamm aus und aus tests/ heraus müssen beide nur tests/ einsammeln und bestehen, und ein falsch geschriebener Marker muss den Durchlauf stoppen.
Was nicht zu testen ist. Nicht, dass Pythons warnings-Modul funktioniert, und nicht, dass pytest seine Konfiguration liest — das ist getesteter Code von anderen. Und nicht der volle Text einer Warnung: Prüfe die Phrase, nach der ein Leser handelt („unusually large“), sodass eine Umformulierung des Rests nichts kaputt macht. Und nicht die Abkündigungen einer Drittanbieter-Bibliothek — die werden gefiltert, und das ist eine Konfigurationsentscheidung, kein Test.
Die Grenzzeile ist die, die Leute vergessen, weil eine fehlende Warnung keinen Lärm macht. Am Ende des Kapitels macht eine einzige Konfigurationszeile daraus eine Prüfung, die du gratis bekommst.
Eine Datei, die vor allem anderen gelesen wird
pytest liest seine Einstellungen, bevor es einen einzigen Test einsammelt. Das übliche Zuhause ist die pyproject.toml, die das Projekt schon hat, in einer Tabelle namens [tool.pytest.ini_options]:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]pythonpath listet Verzeichnisse auf, relativ zum Projektstamm, die pytest zu sys.path hinzufügt, bevor es Tests importiert. Der getestete Code ist normal:
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================Lies die Kopfzeile. configfile: pyproject.toml heißt, dass pytest dir sagt, welche Datei es verwendet hat. Wenn Einstellungen ignoriert zu werden scheinen, prüfe zuerst diese Zeile.
Warum den Import nicht in der Testdatei reparieren? Das ist der offensichtlich falsche Weg, und er funktioniert — manchmal:
import sys
sys.path.insert(0, "src") # relative to wherever pytest was started
from shop.pricing import line_total
def test_three_items():
assert line_total(15.0, 3) == 51.75$ pytest -q
. [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" ist dort relativ zum aktuellen Verzeichnis, also besteht der Test oder scheitert, je nachdem, wo du stehst. pythonpath in der Konfiguration ist relativ zum Projektstamm, und der verschiebt sich nicht. Die Einrichtung des Imports gehört zum Projekt, also kommt sie einmal in die Datei des Projekts, nicht an den Anfang jedes Testmoduls.
Welche Datei gewinnt, und wo der Stamm liegt
pytest akzeptiert mehrere Dateien. In jedem Verzeichnis prüft es in dieser Reihenfolge: pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg. Es beginnt mit den Pfaden auf der Kommandozeile (oder dem aktuellen Verzeichnis) und wandert nach oben; die erste Datei, die tatsächlich einen pytest-Abschnitt enthält, gewinnt. Ein pyproject.toml ohne pytest-Tabelle zählt nicht.
Dieses Verzeichnis wird der rootdir — die rootdir:-Zeile der Kopfzeile —, und relative Pfade in der Konfiguration sind relativ dazu. Deshalb findet man auch von tests/ aus den Stamm:
$ cd tests && pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 itemsDieselben Einstellungen in pytest.ini verwenden INI-Syntax, einen [pytest]-Abschnitt und keine Anführungszeichen oder Klammern:
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'Wähle eine Datei. Wenn beide existieren, gewinnt pytest.ini vollständig — nichts wird zusammengeführt —, und die Kopfzeile sagt das. Hier wurde eine zweizeilige pytest.ini (minversion = 9.0) neben die pyproject.toml gelegt:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error…und der ModuleNotFoundError ist zurück, weil pythonpath in der Datei lebte, die jetzt ignoriert wird.
testpaths — wo suchen, wenn du nichts sagst
Jemand hat eine Skizzendatei hinterlassen, notes/test_scratch.py, in der Rundungen ausprobiert werden. Reines pytest sammelt alles unter dem Stamm ein, also läuft sie auch:
$ pytest -q --tb=no
F.. [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01stestpaths sagt, wo die echte Suite liegt:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01sIn der vollen Kopfzeile würdest du jetzt auch testpaths: tests sehen. Das ist ein Standard, kein Zaun: Er gilt nur, wenn du vom Stamm aus ohne Angabe von Pfaden ausführst, also läuft pytest notes die Skizzendatei weiterhin. In einem großen Repository beschleunigt er außerdem das Einsammeln, weil pytest aufhört, docs/, build/ oder einen Ordner einer virtuellen Umgebung abzulaufen.
addopts und markers — Regeln für alle
addopts ist Text, den pytest vor das stellt, was auch immer du tippst. Die beiden Regeln des Projekts aus dem Anfang kommen hierhin:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"-ra druckt eine kurze Zusammenfassungszeile für jeden Test, der nicht klar bestanden hat — übersprungen, xfailed, xpassed, fehlgeschlagen —, sodass ein Skip nie unbemerkt bleibt. --strict-markers macht aus einem unregistrierten Marker einen Fehler. Eine neue Datei, tests/test_discounts.py, hat einen Test mit @pytest.mark.slow (und einen, der außerhalb von Windows übersprungen wird), und der Marker ist noch nicht registriert:
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sDas ist der Zweck des Flags. Ohne ihn bringt ein Tippfehler wie @pytest.mark.slwo nur eine PytestUnknownMarkWarning in der Zusammenfassung, der Durchlauf bleibt grün, und -m "not slow" führt weiter den Test aus, den du ausblenden wolltest. Registriere den Marker, eine Zeile pro Marker, mit einer Beschreibung, die pytest --markers anzeigen wird:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'Die SKIPPED-Zeile ist -ra in Aktion: Ein Skip erklärt sich jetzt bei jedem Durchlauf selbst.
Was nicht in addopts gehört. Der falsche Weg ist addopts = "-v -x --pdb". Das sind deine Gewohnheiten für das heutige Debugging; in der gemeinsamen Datei werden sie allen aufgezwungen, CI eingeschlossen — wo --pdb auf eine Tastatur wartet, an der niemand sitzt. Tue nur die Regeln des Projekts in addopts. Was du tippst, kommt danach dazu, also funktioniert pytest -x weiter nur für dich allein, und -o überschreibt jede Einstellung für einen Durchlauf:
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01sDiesmal keine Skip-Zeile, weil -ra für diesen Durchlauf weggelassen wurde.
minversion und xfail_strict
minversion stoppt den Durchlauf sofort, wenn jemandes pytest für die Datei zu alt ist. Eine Version zu verlangen, die es nicht gibt, zeigt die Meldung:
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'Ein klarer Fehler beim Start ist besser als ein älteres pytest, das die Einstellungen nur halb versteht.
xfail_strict kümmert sich um das lose Ende des letzten Kapitels: ein xfail, das anfängt zu bestehen. Hier wurde ein Fehler als erwarteter Fehlschlag markiert, und seitdem hat jemand ihn behoben:
import pytest
def refund(amount):
return round(amount * 0.9, 2)
@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
assert refund(10.0) == 9.0$ pytest -q
X [100%]
1 xpassed in 0.01sEin grüner Durchlauf, ein X, das niemand liest, und ein Marker, der jetzt lügt. Mit xfail_strict = true in der Konfiguration:
$ pytest -q
F [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01sDie gute Nachricht kommt jetzt als Fehlschlag an, der dich auffordert, den Marker zu entfernen — deshalb lohnt es sich, das für das ganze Projekt zu setzen, statt Test für Test. pytest 9.1 akzeptiert außerdem den neueren Namen strict_xfail und ein einziges strict = true, das alle Optionen zur Strenge auf einmal einschaltet (Marker und xfail eingeschlossen); xfail_strict funktioniert weiter.
Live-Logs: log_cli
Standardmäßig fängt pytest Log-Einträge ein und zeigt sie nur für fehlgeschlagene Tests. Um einem grünen Durchlauf dabei zuzusehen, wie er seine Geschichte erzählt, schalte Live-Logging ein:
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"Die INFO- und höheren Einträge jedes Tests werden dann gedruckt, während er läuft, unter der Überschrift live log call; das vollständige Beispiel unten zeigt es. Live-Logs sind laut, also behalten viele Projekte nur log_cli_level in der Datei und schalten die Ausgabe bei Bedarf mit pytest -o log_cli=true ein.
Die native Tabelle von pytest 9: [tool.pytest]
[tool.pytest.ini_options] hat aus einem Grund einen seltsamen Namen: Das sind INI-Einstellungen, die in einer TOML-Datei sitzen, also wird jeder Wert zu Text gemacht. pytest 9 fügt eine native Tabelle hinzu, [tool.pytest], in der Werte ihre TOML-Typen behalten — echte Listen, echte Booleans:
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01saddopts ist jetzt eine Liste. Zwei Regeln: Verwende die eine oder die andere Tabelle, nie beide in einer Datei (pytest weigert sich zu starten), und denke daran, dass pytest vor 9 [tool.pytest] überhaupt nicht liest. Dieses Kapitel behält [tool.pytest.ini_options], weil es mit jedem pytest funktioniert, dem du begegnen wirst; in einem Projekt, das nur pytest 9 ausführt, ist die native Tabelle die empfohlene Form. pytest 9 liest außerdem eine eigenständige pytest.toml mit einer [pytest]-Tabelle im selben nativen Stil.
Der Cache, kurz
Nach einem Durchlauf gibt es einen Ordner .pytest_cache/ im Stamm. Er merkt sich, welche Tests zuletzt fehlgeschlagen sind, und das ist es, was --lf (last failed) und --ff (failed first) antreibt. Zwei Flags lohnen das Kennenlernen. pytest --cache-clear leert ihn vor dem Durchlauf — nützlich, wenn --lf immer Tests wählt, die es nicht mehr gibt. pytest -p no:cacheprovider schaltet das Cache-Plugin ganz ab, sodass nichts geschrieben wird, was zu einem Read-only-Checkout passt. Der Preis ist, dass die Optionen des Plugins mitverschwinden:
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
inifile: /home/you/mv/pytest.ini
rootdir: /home/you/mvWarnungen: der Abschnitt am Ende des Durchlaufs
Eine Warnung ist eine Meldung, dass etwas noch nicht falsch ist. Python druckt sie und macht weiter:
import warnings
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
return round(total * (100 - percent) / 100, 2)$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0stacklevel=2 sorgt dafür, dass die Abkündigung auf die Zeile des Aufrufers zeigt, die Zeile, die jemand ändern muss. pytest fängt Warnungen, die während der Tests ausgelöst werden, und sammelt sie am Ende in einem Abschnitt:
from pricing import total_with_vat
def test_old_name_still_works():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
. [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
/home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
assert total_with_vat(100.0, 1) == 115.0
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sDer Test ist bestanden, und die letzte Zeile sagt 1 passed, 1 warning. In einer großen Suite wächst dieser Abschnitt auf Dutzende von Einträgen an, jeder lernt, ihn zu überscrollen, und eines Tages wird die veraltete Funktion entfernt und die „bestehende“ Suite bricht. Mit einer Warnung kann man zwei Dinge tun: Sie erwarten, wenn sie Teil des Verhaltens ist, oder sie ablehnen, wenn sie es nicht ist.
pytest.warns — eine Warnung erwarten
Wenn eine Warnung Teil des Vertrags ist — die „kurz hinter der Grenze“-Zeile im Plan —, teste sie so, wie du eine Exception mit pytest.raises testest:
import pytest
from pricing import apply_discount
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large"):
result = apply_discount(200.0, 60)
assert result == 80.0
def test_small_discount_is_quiet():
assert apply_discount(200.0, 10) == 180.0$ pytest -q test_discount.py
.. [100%]
2 passed in 0.01sDer Block besteht, wenn mindestens eine UserWarning (oder Unterklasse) darin ausgelöst wird und ihre Meldung auf match passt, ein regulärer Ausdruck, der im Text gesucht wird, wie bei pytest.raises. Fehlt eine der beiden Hälften, schlägt der Test deutlich fehl. Hier ist der Code bei 50 ruhig, und der zweite Test sucht nach den falschen Wörtern:
import pytest
from pricing import apply_discount
def test_fifty_percent_warns():
with pytest.warns(UserWarning):
apply_discount(200.0, 50)
def test_wrong_message():
with pytest.warns(UserWarning, match="too large"):
apply_discount(200.0, 60)$ pytest -q test_discount_bad.py
FF [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________
def test_fifty_percent_warns():
> with pytest.warns(UserWarning):
E Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E Emitted warnings: [].
test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________
def test_wrong_message():
> with pytest.warns(UserWarning, match="too large"):
E Failed: Regex pattern did not match any of the 1 warnings emitted.
E Regex: 'too large'
E Emitted warnings: [UserWarning('discount of 60% looks unusually large')].
test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01sBeide Berichte listen die Warnungen auf, die ausgelöst wurden, und meist brauchst du nur das, um den Test zu reparieren. Eine Warnung, die nicht passte, wird auch nicht verschluckt; sie geht in die Warnungszusammenfassung weiter.
Warum immer match angeben. pytest.warns(UserWarning) allein ist die schwache Form: Jede UserWarning von irgendwoher im Block erfüllt sie — auch eine aus einer Bibliothek, die du zufällig aus einem anderen Grund aufrufst. Der Test könnte weiterbestehen, nachdem die Warnung, die dich interessiert, weg ist. Eine passende Phrase nagelt den Test an diese Warnung.
Um zu prüfen, wie viele Warnungen ausgelöst wurden oder was sie sagten, bewahre die Aufzeichnung mit as auf:
import pytest
from pricing import apply_discount
def test_each_large_discount_warns_once():
with pytest.warns(UserWarning) as record:
apply_discount(100.0, 60)
apply_discount(100.0, 10)
apply_discount(100.0, 75)
assert len(record) == 2
print(record[0].message)
print(record[1].category.__name__, record[1].lineno)$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01srecord listet jede im Block gefangene Warnung auf — zwei, denn der Aufruf mit 10 % ist ruhig. Jeder Eintrag hat .message, .category, .filename und .lineno.
pytest.deprecated_call — ein Versprechen an deine Nutzer
Eine Funktion abzukündigen ist ein Versprechen: „Das funktioniert immer noch, und du wurdest informiert.“ pytest.deprecated_call() prüft beide Hälften:
import pytest
from pricing import total_with_vat
def test_old_name_still_works():
with pytest.deprecated_call():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q test_old_api.py
. [100%]
1 passed in 0.01sDas ist pytest.warns mit ausgefüllten Kategorien — DeprecationWarning, PendingDeprecationWarning und FutureWarning —, und es akzeptiert auch match. Der Eintrag in der Warnungszusammenfassung ist weg: Die Warnung wurde erwartet, also wurde sie verbraucht.
filterwarnings — Warnungen ablehnen
Erwartete Warnungen sind jetzt Tests. Der Rest ist Lärm oder frühe Alarme, und die CI-Gewohnheit, die sich lohnt, ist: Jede unerwartete Warnung ist ein Fehler. Eine Zeile:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]Hier ist eine Suite mit einem sauberen Test, einem, der immer noch den veralteten Namen aufruft, und einem, der currency.rate() aus einer Bibliothek aufruft, die du nicht ändern kannst und die ihre eigene DeprecationWarning auslöst:
from currency import rate
from pricing import line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0
def test_rate():
assert rate("USD") == 1.0$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
assert total_with_vat(100.0, 1) == 115.0
^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
warnings.warn(
E DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
assert rate("USD") == 1.0
^^^^^^^^^^^
currency.py:6: in rate
warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01sJede Warnung wird jetzt als Exception ausgelöst, mit einem Traceback bis zur Zeile, die sie verursacht hat. Jeder Fehlschlag bekommt eine Entscheidung statt einer pauschalen Stille.
Der falsche Weg ist "ignore::DeprecationWarning": eine Zeile, alles grün, und deine eigene Abkündigung wird zusammen mit der der Bibliothek versteckt. Der richtige Weg ist, jede Ausnahme einzuschränken. Ein Filter hat die Form action:message:category:module — dieselbe wie Pythons eigene -W-Option —, wobei message auf den Anfang des Warnungstextes passt und module das Modul ist, dem die Warnung zugeschrieben wird:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]Die Reihenfolge zählt: Spätere Zeilen haben Vorrang, also kommt error zuerst und die Ausnahmen danach. Die Warnung der Bibliothek wird jetzt nur ignoriert, wenn sie aus currency kommt. Für einen Test kann derselbe Filter als Marker angehängt werden — hier an test_old_name, der Rest der Datei unverändert:
import pytest
from pricing import total_with_vat
@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
... [100%]
3 passed in 0.01sDieser Marker erlaubt die Warnung nur. Wenn du „das muss warnen“ meinst, ist pytest.deprecated_call() die stärkere Wahl, weil es auch fehlschlägt, wenn die Warnung verschwindet. Benutze den Marker für Warnungen, die du duldest, und warns für Warnungen, die du versprichst.
Dieselben Filter lassen sich für einen Durchlauf mit -W angeben, praktisch, um eine Richtlinie auszuprobieren, bevor du sie aufschreibst. Auf dem früheren test_old_api.py ohne jegliche Konfiguration:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01sWenn Quellen widersprechen, gewinnt die spezifischste: Ein Marker auf dem Test schlägt -W, und -W schlägt die Konfigurationsdatei. In der Drei-Test-Suite überschreibt -W das ignore der Konfiguration für currency, während der markierte Test seine Befreiung behält:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01sNoch etwas, das error dir bringt. Sieh dir die Grenzzeile des Plans an, „keine Warnung bei 50“. Mit error in der Konfiguration prüft ein ganz normaler Test von apply_discount(200.0, 50) das bereits: Wenn dieser Aufruf je warnen würde, wäre die Warnung eine Exception und der Test würde fehlschlagen. Ruhige Fälle brauchen keinen besonderen Code.
Ein vollständiges Beispiel
Das Shop-Projekt mit jeder Regel an einem Ort. pyproject.toml:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]
log_cli_level = "INFO"src/shop/pricing.py:
import logging
import warnings
log = logging.getLogger(__name__)
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
log.info("discount %d%% on %.2f", percent, total)
return round(total * (100 - percent) / 100, 2)$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0tests/test_pricing.py — der Plan vom Anfang des Kapitels, Zeile für Zeile:
import pytest
from shop.pricing import apply_discount, line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name_is_deprecated():
with pytest.deprecated_call(match="use line_total"):
assert total_with_vat(100.0, 1) == 115.0
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large") as record:
assert apply_discount(200.0, 60) == 80.0
assert len(record) == 1
def test_fifty_percent_is_quiet():
assert apply_discount(200.0, 50) == 100.0
@pytest.mark.slow
def test_many_lines():
total = sum(line_total(1.0, 1) for _ in range(10_000))
assert round(total, 2) == 11500.0
@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
assert line_total(15.0, 0) == 0.0$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_pricing.py .....x [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected
tests/test_pricing.py::test_line_total PASSED [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED [ 40%]
tests/test_pricing.py::test_large_discount_warns
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================Drei Dinge fallen auf.
Erstens: Keines der beiden Kommandos enthält eine Projektregel. Die Regeln — wo die Tests sind, was importierbar ist, strenge Marker, strenges xfail, Warnungen als Fehler — stehen alle in der Datei, sodass der Kollege, der reines pytest tippt, genau den ersten Durchlauf bekommt, und genauso CI.
Zweitens: Es gibt keine Warnungszusammenfassung. Beide Warnungen, die der Code auslöst, sind erwartet, also haben warns und deprecated_call sie verbraucht; jede andere Warnung hätte einen Test fehlschlagen lassen. Ein sauberer Durchlauf ist jetzt tatsächlich sauber.
Drittens: test_fifty_percent_is_quiet enthält keinen Warnungscode, bewacht aber die Grenze: Unter error würde eine Warnung bei 50 % ihn fehlschlagen lassen.
Wenn etwas schiefgeht
ModuleNotFoundError: No module named 'shop' beim Einsammeln Der Code ist aus den Tests nicht importierbar. Füge pythonpath = ["src"] zur Konfiguration hinzu und prüfe die configfile:-Zeile der Kopfzeile, um sicher zu sein, dass diese Datei die gelesene ist.
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) Zwei Konfigurationsdateien, und die, die du bearbeitet hast, hat verloren. Nichts wird zusammengeführt. Lösche eine.
'slow' not found in markers configuration option --strict-markers (oder strict = true) ist an, und der Marker ist unregistriert oder falsch geschrieben. Registriere ihn unter markers, oder korrigiere die Schreibweise.
PytestConfigWarning: Unknown config option: testpath Eine falsch geschriebene Einstellung — testpath statt testpaths. pytest warnt nur, also bewirkt die Einstellung stillschweigend nichts. Mit strict_config = true (oder strict = true) wird daraus ERROR: Unknown config option: testpath, und der Durchlauf stoppt — so wie du es willst.
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml hat beide Tabellen. Verschiebe alles in eine.
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' In der nativen Tabelle [tool.pytest] ist addopts eine Liste: addopts = ["-ra", "--strict-markers"]. Die String-Form gehört zu [tool.pytest.ini_options].
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' Das installierte pytest ist älter als das Projekt es braucht. Aktualisiere es in der virtuellen Umgebung.
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. In dem Block wurde nichts von dieser Kategorie ausgelöst. Lies Emitted warnings: darunter — meistens warnt der Code mit einer anderen Kategorie oder bei dieser Eingabe gar nicht.
Failed: Regex pattern did not match any of the 1 warnings emitted. Richtige Kategorie, falscher Text. match ist ein regulärer Ausdruck, also brauchen (, . und ? ein Escape — oder wickele den Text in re.escape() ein.
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? Ein Tippfehler in der Kategorie einer filterwarnings-Zeile. pytest weigert sich zu starten und druckt die Zeile, die es nicht parsen konnte, direkt darüber.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Das wird mit pytest -q -s ausgeführt. Welche Zahl druckt der Test?
import warnings
import pytest
def check(n):
if n < 0:
warnings.warn("negative value", UserWarning)
return abs(n)
def test_record():
with pytest.warns(UserWarning) as record:
check(-1)
check(2)
check(-3)
print(len(record))- A3
- B0
- C2
- D1
Im Projektstamm gibt es sowohl eine pytest.ini als auch eine pyproject.toml mit [tool.pytest.ini_options]. Was passiert, wenn du pytest ausführst?
- Apytest stoppt mit einem Fehler, weil es zwei Konfigurationsdateien gibt
- B`pytest.ini` wird verwendet, die Einstellungen aus `pyproject.toml` werden ignoriert, und die Kopfzeile warnt davor
- C`pyproject.toml` gewinnt, weil es der neuere Standard ist
- DDie beiden Dateien werden zusammengeführt, wobei `pyproject.toml` die Lücken füllt
Die Konfiguration hat addopts = -ra --strict-markers und registriert unter markers nur slow. Was passiert bei pytest -m "not slow"?
import pytest
@pytest.mark.slwo
def test_every_day_of_a_year():
fees = [day * 5 for day in range(366)]
assert fees[-1] == 1825- ADer Test läuft, mit einer Warnung in der Zusammenfassung
- BDer Test wird abgewählt, wie beabsichtigt
- CDas Einsammeln stoppt: 'slwo' not found in `markers` configuration option
- DDer Test wird übersprungen
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
Das Bibliotheksprojekt erhebt eine Verspätungsgebühr. src/library/fees.py:
import warnings
PER_DAY = 5
def late_fee(days):
if days < 0:
raise ValueError("days cannot be negative")
if days > 60:
warnings.warn(f"{days} days late looks like a lost book", UserWarning)
return days * PER_DAY
def fine(days):
warnings.warn(
"fine() is deprecated; use late_fee()",
DeprecationWarning,
stacklevel=2,
)
return late_fee(days)$ PYTHONPATH=src python -c "from library.fees import late_fee; print(late_fee(4))"
20- Schreibe die Tabelle
[tool.pytest.ini_options]so, dass reinespytest, vom Stamm oder austests/heraus,libraryimportiert, nurtests/einsammelt,-ra-Zusammenfassungen druckt, unbekannte Marker ablehnt, ein unerwartetesxfail-Bestehen zum Fehlschlag macht und jede unerwartete Warnung in einen Fehler verwandelt. Registriere einenslow-Marker. - Schreibe zuerst den Testplan — Fall, Eingabe, Erwartet — und decke pünktliche Rückgabe, die 60-Tage-Grenze, 61 Tage, eine negative Zahl und die veraltete
fine()ab. - Schreibe
tests/test_fees.pynach dem Plan, mit einemslow-Test, der jeden Tag eines Jahres durchgeht. - Führe
pytestaus, dannpytest -m "not slow", danncd tests && pytest.
Dann zwei Experimente, bei denen du das Ergebnis zuerst vorhersagst: Ersetze den deprecated_call-Test durch einen einfachen Aufruf von fine(3), und schreibe slow falsch als slwo.
Lösung
Der Plan:
| Fall | Eingabe | Erwartet | | --- | --- | --- | | pünktlich zurückgegeben | late_fee(0) | 0, keine Warnung | | Grenze, noch ruhig | late_fee(60) | 300, keine Warnung | | kurz hinter der Grenze | late_fee(61) | 305 und eine UserWarning mit „lost book“ | | ungültige Eingabe | late_fee(-1) | ValueError mit „negative“ | | veralteter Name | fine(3) | 15 und eine DeprecationWarning mit late_fee | | ein ganzes Jahr (slow) | late_fee(0) … late_fee(365) | letzte Gebühr 1825, Warnungen erwartet |
pyproject.toml:
[project]
name = "library"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]tests/test_fees.py:
import pytest
from library.fees import fine, late_fee
def test_returned_on_time():
assert late_fee(0) == 0
def test_sixty_days_is_quiet():
# filterwarnings = error: any warning here would fail this test
assert late_fee(60) == 300
def test_sixty_one_days_warns():
with pytest.warns(UserWarning, match="lost book") as record:
assert late_fee(61) == 305
assert len(record) == 1
def test_negative_days_rejected():
with pytest.raises(ValueError, match="negative"):
late_fee(-1)
def test_fine_is_deprecated():
with pytest.deprecated_call(match="use late_fee"):
assert fine(3) == 15
@pytest.mark.slow
def test_every_day_of_a_year():
with pytest.warns(UserWarning):
fees = [late_fee(day) for day in range(366)]
assert fees[-1] == 1825$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/library
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_fees.py ...... [100%]
============================== 6 passed in 0.01s ===============================
$ pytest -m "not slow" -q
..... [100%]
5 passed, 1 deselected in 0.01s
$ cd tests && pytest -q
...... [100%]
6 passed in 0.01sDie zwei Experimente:
$ pytest -q # test_fine_still_works calls fine(3) with no deprecated_call
=========================== short test summary info ============================
FAILED tests/test_fees.py::test_fine_still_works - DeprecationWarning: fine()...
1 failed, 5 passed in 0.01s
$ pytest -q # @pytest.mark.slwo
ERROR tests/test_fees.py - Failed: 'slwo' not found in `markers` configuratio...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sWarum jede Entscheidung:
pythonpathundtestpathsin der Datei, nicht auf der Kommandozeile. Deshalb liefertcd tests && pytestdieselben sechs bestandenen Tests: Beide sind relativ zum rootdir, den pytest findet, indem es nach oben bis zumpyproject.tomlwandert.- Nur Regeln in
addopts.-raund--strict-markerssind Richtlinien, die alle teilen;-voder-xsind persönliche Gewohnheiten und bleiben auf deiner eigenen Kommandozeile. filterwarnings = ["error"]ohne Ausnahmen. Das Projekt hat noch keine Warnungen von Drittanbietern, also gibt es nichts zu entschuldigen. Am Tag, an dem eine auftaucht, füge eine schmaleignore::...:module-Zeile hinzu, nie ein pauschalesignore.- Der 60-Tage-Test hat keinen Warnungscode. Unter
errorwürde jede Warnung bei 60 Tagen ihn fehlschlagen lassen, also wacht die Konfiguration über die Grenze. matchbei jeder Prüfung, die auf eine einzelne Meldung zielt. „lost book“ und „use late_fee“ sind die Wörter, nach denen ein Leser handelt; den Rest kannst du frei umformulieren. Der Jahres-Test lässtmatchabsichtlich weg: Es geht um die Gebühren, und er muss nur sagen, dass dort Warnungen erwartet werden.recordim 61-Tage-Test. Es beweist, dass ein Aufruf einmal warnt und nicht bei jedem internen Schritt.- Die Experimente. Ohne
deprecated_callist die Abkündigung unerwartet, also machterrordaraus einen Fehlschlag — genau das, was CI tun sollte, wenn jemand eine alte API aufruft. Der falsch geschriebene Marker stoppt das Einsammeln, weil--strict-markersinaddoptssteht. (Hier hätte dererror-Filter ihn auch erwischt, denn ein unbekannter Marker löst einePytestUnknownMarkWarningaus — aber die Marker-Regel sollte nicht von der Warnungs-Richtlinie abhängen, also bleiben beide.)
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