Configuratie en waarschuwingen — de regels in één bestand
Stop met dezelfde vlaggen bij elke run intypen: zet de regels van het project in pyproject.toml of pytest.ini — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — en test en stuur waarschuwingen met pytest.warns, deprecated_call en filterwarnings.
Hoe wil je leren?
Het probleem dat we oplossen
Het project shop heeft nu de vorm die de meeste echte projecten hebben: code in src/, tests in tests/.
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.pyJe hebt geleerd om het goed uit te voeren, en "goed" is een lange regel geworden:
PYTHONPATH=src pytest -ra --strict-markers testsEen teamgenoot kloont de repository en typt wat iedereen zou typen:
$ 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 ===============================Er is niets mis met de code. Wat mis is, is dat de manier waarop je de tests uitvoert in je hoofd en in je shell-geschiedenis staat in plaats van in het project. CI voert een derde variant uit, en drie mensen zien drie resultaten van één commit.
Die vlaggen zijn niet je voorkeur; het zijn de regels van het project. Dit hoofdstuk verplaatst ze naar een bestand dat pytest elke keer leest — en gebruikt datzelfde bestand om een tweede, stiller probleem op te lossen: waarschuwingen die voorbijscrollen en nooit gelezen worden.
Aan het einde van dit hoofdstuk kun je
- Zet pytest-instellingen in
pyproject.toml(ofpytest.ini) en bevestig aan de header welk bestand is gebruikt - Kies
testpaths,addopts,pythonpath,minversion,markersenxfail_strictbewust, en leg uit waarom - Zet live logging aan met
log_cliwanneer je een run wilt volgen - Controleer dat code waarschuwt met
pytest.warnsenpytest.deprecated_call, met behulp vanmatchen de vastgelegde lijst van waarschuwingen - Filter waarschuwingen met
filterwarningsin de config, als marker en met-W— en maak ze tot fouten in CI
Voorkennis: markers, skip en xfail.
Voordat je de test schrijft
Dit hoofdstuk schrijft minder tests dan gebruikelijk en meer regels over hoe tests draaien. Het denken dat voorafgaat is hetzelfde: beslis wat er beloofd wordt, plan dan de gevallen die die belofte controleren.
Wat er beloofd wordt. Er spelen twee soorten belofte.
- Hoe de suite draait. Iedereen die gewoon
pytesttypt krijgt dezelfde tests, hetzelfde importpad en dezelfde strengheid als CI. Een verkeerd gespelde marker stopt de run. Eenxfaildie slaagt wordt gerapporteerd, niet verborgen. - Wat de code zegt naast zijn retourwaarde.
apply_discount(total, percent)geeft een getal terug, en voorpercent > 50geeft het bovendien eenUserWarningdie zegt dat de korting "unusually large" lijkt.total_with_vat()werkt nog steeds, maar geeft eenDeprecationWarningdie naarline_total()verwijst. Een waarschuwing is output; wanneer ze deel uitmaakt van het contract, wordt ze getest als output.
Wat er klaar moet staan. Een virtuele omgeving met pytest 9 (pytest --version), de code importeerbaar vanaf de tests (dat is pythonpath hieronder), en één configuratiebestand in de projectroot. Geen extra plugins.
Het plan voor het waarschuwinggedrag:
| geval | invoer | verwacht | | --- | --- | --- | | gewone pad | apply_discount(200.0, 10) | 180.0, geen waarschuwing | | grens, nog stil | apply_discount(200.0, 50) | 100.0, geen waarschuwing | | net voorbij de grens | apply_discount(200.0, 60) | 80.0 en één UserWarning met "unusually large" | | verouderde naam | total_with_vat(100.0, 1) | 115.0 en een DeprecationWarning met line_total | | de eigen deprecatie van een bibliotheek | alles wat hem triggert | faalt niet onze suite |
Voor de configuratie zelf is de "test" een run: gewoon pytest vanaf de root én vanuit tests/ moet beide alleen tests/ verzamelen en slagen, en een verkeerd gespelde marker moet de run stoppen.
Wat je niet test. Niet of Pythons warnings-module werkt, en niet of pytest zijn config leest — dat is geteste code van iemand anders. Niet de volledige tekst van een waarschuwing: match de zinsnede waar een lezer op reageert ("unusually large"), zodat het omschrijven van de rest niets kapot maakt. En niet de deprecaties van een bibliotheek van derden — die worden gefilterd, en dat is een configuratiekeuze, geen test.
De grensrij is de rij die mensen vergeten, want een ontbrekende waarschuwing maakt geen geluid. Aan het einde van het hoofdstuk verandert één configregel dat in een controle die je gratis krijgt.
Eén bestand, gelezen vóór alles
pytest leest zijn instellingen voordat het ook maar één test verzamelt. De gebruikelijke plek is de pyproject.toml die het project al heeft, in een tabel die [tool.pytest.ini_options] heet:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]pythonpath noemt mappen, relatief ten opzichte van de projectroot, die pytest aan sys.path toevoegt voordat het de code onder test importeert. De geteste code is gewoon:
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 ===============================Lees de header. configfile: pyproject.toml is pytest dat je zegt welk bestand het gebruikt heeft. Wanneer instellingen genegeerd lijken te worden, controleer je eerst deze regel.
Waarom niet de import in het testbestand repareren? Het is de voor de hand liggende verkeerde manier, en het werkt — soms:
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" is daar relatief ten opzichte van de huidige map, dus de test slaagt of breekt afhankelijk van waar je staat. pythonpath in de config is relatief ten opzichte van de projectroot, die niet verplaatst wordt. De import hoort bij het project, dus die gaat één keer in het bestand van het project, niet bovenaan elke testmodule.
Welk bestand wint, en waar de root ligt
pytest accepteert verschillende bestanden. In elke map controleert het, in deze volgorde: pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg. Het begint bij de paden op de opdrachtregel (of de huidige map) en loopt omhoog; het eerste bestand dat echt een pytest-sectie bevat wint. Een pyproject.toml zonder pytest-tabel telt niet.
Die map wordt de rootdir — de rootdir:-regel in de header — en relatieve paden in de config zijn relatief ten opzichte daarvan. Dus vanuit tests/ vind je de root nog steeds:
$ 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 itemsDezelfde instellingen in pytest.ini gebruiken INI-syntaxis, een [pytest]-sectie, en geen aanhalingstekens of haakjes:
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'Kies één bestand. Als beide bestaan, wint pytest.ini volledig — er wordt niets samengevoegd — en de header zegt dat. Hier is een pytest.ini van twee regels (minversion = 9.0) naast de pyproject.toml toegevoegd:
$ 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…en de ModuleNotFoundError is terug, want pythonpath stond in het bestand dat nu genegeerd wordt.
testpaths — waar te kijken wanneer je niets zegt
Iemand heeft een kladbestand achtergelaten, notes/test_scratch.py, om afronden te testen. Gewoon pytest verzamelt alles onder de root, dus dat draait ook mee:
$ 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 zegt waar de echte suite staat:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01sIn de volledige header zou je nu ook testpaths: tests zien. Het is een standaard, geen hek: het geldt alleen wanneer je vanaf de root draait zonder paden te noemen, dus pytest notes voert het kladbestand nog steeds uit. In een grote repository versnelt het ook het verzamelen, omdat pytest stopt met het doorlopen van docs/, build/ of een map met een virtuele omgeving.
addopts en markers — regels die iedereen krijgt
addopts is tekst die pytest vóór wat je ook typt plaatst. De twee regels van het project uit de opening horen hier:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"-ra drukt een korte samenvattingsregel af voor elke test die niet gewoon slaagde — overgeslagen, xfailed, xpassed, gefaald — zodat een skip nooit onopgemerkt blijft. --strict-markers maakt van een niet-geregistreerde marker een error. Een nieuw bestand, tests/test_discounts.py, heeft een test gemarkeerd met @pytest.mark.slow (en één overgeslagen op Windows), en de marker is nog niet geregistreerd:
$ 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.01sDat is het punt van die vlag. Zonder hem levert een typfout als @pytest.mark.slwo alleen een PytestUnknownMarkWarning in de samenvatting op, de run blijft groen, en -m "not slow" blijft juist de test draaien die je wilde uitsluiten. Registreer de marker, één regel per marker, met een beschrijving die pytest --markers toont:
[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'De SKIPPED-regel is -ra aan het werk: een skip verklaart zichzelf nu bij elke run.
Wat niet in addopts hoort. De verkeerde manier is addopts = "-v -x --pdb". Dat zijn jouw gewoontes voor het debuggen van vandaag; in het gedeelde bestand worden ze iedereen opgelegd, CI inbegrepen — waar --pdb eeuwig wacht op een toetsenbord waar niemand aan zit. Zet alleen de regels van het project in addopts. Wat jij typt wordt erachter toegevoegd, dus pytest -x werkt nog steeds alleen voor jou, en -o overschrijft elke instelling voor één run:
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01sDeze keer geen skip-regel, omdat -ra voor die run werd weggelaten.
minversion en xfail_strict
minversion stopt de run meteen wanneer iemands pytest te oud is voor het bestand. Vragen om een versie die niet bestaat toont het bericht:
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'Een duidelijke error bij het starten is beter dan een oudere pytest die de instellingen half begrijpt.
xfail_strict pakt het losse eind van het vorige hoofdstuk op: een xfail die slaagt. Hier is een bug gemarkeerd als verwacht te falen, en sindsdien heeft iemand hem gerepareerd:
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.01sEen groene run, een X die niemand leest, en een marker die nu liegt. Met xfail_strict = true in de config:
$ 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.01sHet goede nieuws komt nu binnen als een mislukking die je zegt de marker te verwijderen — en daarom loont het om het voor het hele project in te stellen in plaats van test voor test. pytest 9.1 accepteert ook de nieuwere naam strict_xfail, en één strict = true dat meteen elke strengheidsoptie aanzet (markers en xfail inbegrepen); xfail_strict blijft werken.
Live logs: log_cli
Standaard vangt pytest logrecords op en toont ze alleen voor falende tests. Om een slagen run zijn verhaal te zien vertellen, zet je live logging aan:
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"De INFO-records en hoger van elke test worden dan onder een kop live log call afgedrukt terwijl hij draait; het complete voorbeeld hieronder laat het zien. Live logs zijn lawaaierig, dus veel projecten bewaren alleen log_cli_level in het bestand en zetten de output aan wanneer nodig met pytest -o log_cli=true.
De native tabel van pytest 9: [tool.pytest]
[tool.pytest.ini_options] heeft met een reden een vreemde naam: het zijn INI-instellingen die in een TOML-bestand staan, dus elke waarde wordt omgezet in tekst. pytest 9 voegt een native tabel toe, [tool.pytest], waar waarden hun TOML-typen behouden — echte lijsten, echte booleaanse waarden:
[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 is nu een lijst. Twee regels: gebruik één van die twee tabellen, nooit allebei in één bestand (pytest weigert te starten), en onthoud dat pytest vóór 9 [tool.pytest] helemaal niet leest. Dit hoofdstuk houdt [tool.pytest.ini_options] vast, want dat werkt op elke pytest die je waarschijnlijk tegenkomt; in een project dat alleen pytest 9 draait, is de native tabel de aanbevolen vorm. pytest 9 leest ook een los pytest.toml met een [pytest]-tabel in dezelfde native stijl.
De cache, kort
Na een run staat er een map .pytest_cache/ in de root. Hij onthoudt welke tests de vorige keer faalden, en dat is wat --lf (last failed) en --ff (failed first) aandrijft. Twee vlaggen zijn het weten waard. pytest --cache-clear leegt de cache vóór de run — handig wanneer --lf steeds tests kiest die niet meer bestaan. pytest -p no:cacheprovider schakelt de cache-plugin helemaal uit, dus er wordt niets geschreven, wat bij een read-only checkout past. De prijs is dat de opties van de plugin verdwijnen:
$ 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/mvWaarschuwingen: de sectie onderaan de run
Een waarschuwing is een bericht dat iets nog niet mis is. Python drukt het af en gaat door:
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 zorgt dat de deprecatie naar de regel van de aanroeper wijst, de regel die iemand moet aanpassen. pytest vangt waarschuwingen die tijdens tests worden opgeworpen en verzamelt ze aan het eind in één sectie:
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.01sDe test slaagt, en de laatste regel zegt 1 passed, 1 warning. In een grote suite groeit deze sectie tot tientallen items, iedereen leert ernaartoe te scrollen, en op een dag wordt de verouderde functie verwijderd en breekt de "slagende" suite. Er zijn twee dingen die je met een waarschuwing kunt doen: hem verwachten, wanneer hij deel uitmaakt van het gedrag, of hem weigeren, wanneer dat niet zo is.
pytest.warns — een waarschuwing verwachten
Wanneer een waarschuwing deel uitmaakt van het contract — de rij "net voorbij de grens" uit het plan — test je hem zoals je een exception test met pytest.raises:
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.01sHet blok slaagt wanneer er minstens één UserWarning (of subklasse) in wordt afgegeven en het bericht overeenkomt met match, een reguliere expressie die in de tekst wordt gezocht, net als bij pytest.raises. Beide helften falen luid. Hier is de code stil bij 50, en de tweede test zoekt naar de verkeerde woorden:
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 rapporten tonen de waarschuwingen die wel zijn afgegeven, meestal alles wat je nodig hebt om de test te repareren. Een waarschuwing die niet paste wordt ook niet doorgeslikt; hij gaat door naar de waarschuwingssamenvatting.
Waarom je altijd match geeft. Alleen pytest.warns(UserWarning) is de zwakke vorm: elke UserWarning ergens in het blok voldoet eraan — ook één uit een bibliotheek die je toevallig aanroept, om een niet-gerelateerde reden. De test zou kunnen blijven slaan nadat de waarschuwing waar je om geeft verdwenen is. Een passende zinsnede verankert de test aan deze waarschuwing.
Om te controleren hoeveel waarschuwingen afgingen, of wat ze zeiden, bewaar je de lijst met as:
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 noemt elke waarschuwing die in het blok is opgevangen — twee, want de aanroep met 10% is stil. Elke entry heeft .message, .category, .filename en .lineno.
pytest.deprecated_call — een belofte aan je gebruikers
Een functie depreceren is een belofte: "dit werkt nog, en je bent gewaarschuwd". pytest.deprecated_call() controleert beide helften:
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.01sHet is pytest.warns met de categorieën ingevuld — DeprecationWarning, PendingDeprecationWarning en FutureWarning — en het accepteert ook match. De entry in de waarschuwingssamenvatting is weg: de waarschuwing werd verwacht, dus hij is verbruikt.
filterwarnings — waarschuwingen weigeren
Verwachte waarschuwingen zijn nu tests. De rest is ruis of vroege alarmeringen, en de CI-gewoonte die het waard is om over te nemen is: elke onverwachte waarschuwing is een error. Eén regel:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]Hier is een suite met één schone test, één die de verouderde naam nog aanroept, en één die currency.rate() aanroept uit een bibliotheek die je niet kunt aanpassen, en die zijn eigen DeprecationWarning afgeeft:
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.01sElke waarschuwing wordt nu opgeworpen als een exception, met een traceback naar de regel die hem veroorzaakte. Elke mislukking krijgt een beslissing in plaats van een blinde stilte.
De verkeerde manier is "ignore::DeprecationWarning": één regel, alles groen, en je eigen deprecatie verborgen samen met die van de bibliotheek. De juiste manier is elke uitzondering te versmallen. Een filter heeft de vorm action:message:category:module — dezelfde als Pythons eigen -W-optie — waarbij message overeenkomt met het begin van de waarschuwingstekst en module de module is waaraan de waarschuwing wordt toegeschreven:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]De volgorde telt: latere regels hebben voorrang, dus error komt eerst en de uitzonderingen erna. De waarschuwing van de bibliotheek wordt nu alleen genegeerd wanneer hij uit currency komt. Voor één test kan dezelfde filter als marker worden toegevoegd — hier op test_old_name, de rest van het bestand ongewijzigd:
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.01sDie marker staat de waarschuwing alleen toe. Als je bedoelt "dit moet waarschuwen", is pytest.deprecated_call() de sterkere keuze, want hij faalt ook wanneer de waarschuwing verdwijnt. Gebruik de marker voor waarschuwingen die je tolereert en warns voor waarschuwingen die je belooft.
Dezelfde filters kun je voor één run geven met -W, handig om een beleid uit te proberen voordat je het opschrijft. Op het eerder genoemde test_old_api.py zonder enige config:
$ 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.01sWanneer bronnen het niet eens zijn, wint de meest specifieke: een marker op de test verslaat -W, en -W verslaat het configuratiebestand. In de suite van drie tests overschrijft -W de ignore van de config voor currency, terwijl de gemarkeerde test zijn uitzondering behoudt:
$ 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.01sNog iets wat error je oplevert. Kijk terug naar de grensrij uit het plan, "geen waarschuwing bij 50". Met error in de config controleert een gewone test van apply_discount(200.0, 50) dat al: als die aanroep ooit zou waarschuwen, zou de waarschuwing een exception zijn en zou de test falen. Stille gevallen hebben geen speciale code nodig.
Een compleet voorbeeld
Het project shop met elke regel op één plek. 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 — het plan van het begin van het hoofdstuk, rij voor rij:
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 ==================Drie dingen zijn het vermelden waard.
Ten eerste is er in geen van beide opdrachten een projectregel. De regels — waar de tests staan, wat importeerbaar is, strikte markers, strikte xfail, waarschuwingen als errors — staan allemaal in het bestand, dus de teamgenoot die alleen pytest typt krijgt precies de eerste run, en CI ook.
Ten tweede is er geen waarschuwingssamenvatting. Beide waarschuwingen die de code afgeeft zijn verwacht, dus warns en deprecated_call hebben ze verbruikt; elke andere waarschuwing zou een test hebben laten falen. Een schone run is nu echt schoon.
Ten derde bevat test_fifty_percent_is_quiet geen code voor waarschuwingen, en toch bewaakt hij de grens: onder error zou een waarschuwing bij 50% hem laten falen.
Wanneer het misgaat
ModuleNotFoundError: No module named 'shop' tijdens het verzamelen De code is niet importeerbaar vanaf de tests. Voeg pythonpath = ["src"] toe aan de config, en controleer de configfile:-regel in de header om zeker te zijn dat dat bestand het is dat gelezen wordt.
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) Twee configuratiebestanden, en dat je hebt bewerkt verloor. Er wordt niets samengevoegd. Verwijder er één.
'slow' not found in markers configuration option --strict-markers (of strict = true) staat aan, en de marker is niet geregistreerd of verkeerd gespeld. Registreer hem onder markers, of verbeter de spelling.
PytestConfigWarning: Unknown config option: testpath Een verkeerd gespelde instelling — testpath in plaats van testpaths. pytest geeft alleen een waarschuwing, dus de instelling doet stilletjes niets. Met strict_config = true (of strict = true) wordt het ERROR: Unknown config option: testpath en stopt de run, en dat is wat je wilt.
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml heeft beide tabellen. Verhuis alles naar één.
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' In de native [tool.pytest]-tabel is addopts een lijst: addopts = ["-ra", "--strict-markers"]. De stringvorm hoort bij [tool.pytest.ini_options].
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' De geïnstalleerde pytest is ouder dan het project nodig heeft. Upgrade hem binnen de virtuele omgeving.
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. Er is niets van die categorie in het blok afgegeven. Lees Emitted warnings: eronder — vaak waarschuwt de code met een andere categorie, of niet bij deze invoer.
Failed: Regex pattern did not match any of the 1 warnings emitted. Juiste categorie, verkeerde tekst. match is een reguliere expressie, dus (, . en ? moeten escaped worden — of wikkel de tekst in re.escape().
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? Een typfout in de categorie van een filterwarnings-regel. pytest weigert te starten en drukt de regel die het niet kon parsen net erboven af.
Stap 4 van 6 — Voorspellen
Test je begrip
Dit draait met pytest -q -s. Welk getal drukt de test af?
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
De projectroot heeft zowel een pytest.ini als een pyproject.toml met [tool.pytest.ini_options]. Wat gebeurt er wanneer je pytest uitvoert?
- Apytest stopt met een error over twee configuratiebestanden
- B`pytest.ini` wordt gebruikt, de instellingen van `pyproject.toml` worden genegeerd, en de header waarschuwt daarvoor
- C`pyproject.toml` wint omdat het de nieuwere standaard is
- DDe twee bestanden worden samengevoegd, waarbij `pyproject.toml` de gaten vult
De config heeft addopts = -ra --strict-markers en registreert alleen slow onder markers. Wat gebeurt er bij 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- ADe test draait, met een waarschuwing in de samenvatting
- BDe test wordt gedeselecteerd, zoals bedoeld
- CDe verzameling stopt: 'slwo' not found in `markers` configuration option
- DDe test wordt overgeslagen
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Het bibliotheekproject rekent een boete. 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- Schrijf de
[tool.pytest.ini_options]-tabel zodat gewoonpytest, vanaf de root of vanuittests/,libraryimporteert, alleentests/verzamelt,-ra-samenvattingen afdrukt, onbekende markers afwijst, faalt op een onverwachtexfail-slaag en elke onverwachte waarschuwing in een error verandert. Registreer eenslow-marker. - Schrijf eerst het testplan — geval, invoer, verwacht — dat op tijd inleveren, de grens van 60 dagen, 61 dagen, een negatief getal en de verouderde
fine()dekt. - Schrijf
tests/test_fees.pyop basis van het plan, met éénslow-test die elke dag van een jaar doorloopt. - Voer
pytestuit, daarnapytest -m "not slow", daarnacd tests && pytest.
Daarna twee experimenten, waarbij je elk resultaat eerst voorspelt: vervang de deprecated_call-test door een gewone aanroep van fine(3), en spel slow verkeerd als slwo.
Oplossing
Het plan:
| geval | invoer | verwacht | | --- | --- | --- | | op tijd ingeleverd | late_fee(0) | 0, geen waarschuwing | | grens, nog stil | late_fee(60) | 300, geen waarschuwing | | net voorbij de grens | late_fee(61) | 305 en één UserWarning met "lost book" | | ongeldige invoer | late_fee(-1) | ValueError met "negative" | | verouderde naam | fine(3) | 15 en een DeprecationWarning met late_fee | | een heel jaar (slow) | late_fee(0) … late_fee(365) | laatste boete 1825, waarschuwingen verwacht |
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.01sDe twee experimenten:
$ 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.01sWaarom elke beslissing:
pythonpathentestpathsin het bestand, niet op de opdrachtregel. Daarom geeftcd tests && pytestdezelfde zes succesvolle tests: beide paden zijn relatief ten opzichte van derootdir, die pytest vindt door omhoog te lopen totpyproject.toml.- Alleen regels in
addopts.-raen--strict-markerszijn beleid dat iedereen deelt;-vof-xzijn persoonlijke gewoontes en blijven op je eigen opdrachtregel. filterwarnings = ["error"]zonder uitzonderingen. Het project heeft nog geen waarschuwingen van derden, dus er valt niets uit te sluiten. De dag dat er één verschijnt, voeg je een smalleignore::...:module-regel toe, nooit een blindeignore.- De test op 60 dagen bevat geen code voor waarschuwingen. Onder
errorzou elke waarschuwing bij 60 dagen hem laten falen, dus de config bewaakt de grens. matchbij elke controle die op één bericht mikt. "lost book" en "use late_fee" zijn de woorden waar een lezer op reageert; de rest kan vrij worden omgeschreven. De test van een jaar laatmatcher met opzet uit: hij gaat over de boetes, en hoeft alleen te zeggen dat daar waarschuwingen worden verwacht.recordin de test op 61 dagen. Het bewijst dat één aanroep precies één keer waarschuwt, niet bij elke interne stap.- De experimenten. Zonder
deprecated_callis de deprecatie onverwacht, duserrorverandert hem in een mislukking — precies wat CI moet doen wanneer iemand een oude API aanroept. De verkeerd gespelde marker stopt de verzameling omdat--strict-markersinaddoptsstaat. (Hier zou deerror-filter hem ook gevangen hebben, want een onbekende marker geeft eenPytestUnknownMarkWarning— maar de markerregel zou niet van het waarschuwingbeleid mogen afhangen, dus beide blijven.)
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken