अध्याय 13

कॉन्फ़िगरेशन और चेतावनियाँ — नियम एक फ़ाइल में

हर रन पर वही फ़्लैग टाइप करना बंद कीजिए: प्रोजेक्ट के नियम pyproject.toml या pytest.ini में लिखिए — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — और pytest.warns, deprecated_call व filterwarnings से चेतावनियों को टेस्ट और नियंत्रित कीजिए।

55 मिनटPython 3.12
  1. 1समस्या
  2. 2समझें
  3. 3उदाहरण
  4. 4अनुमान
  5. 5स्वयं करें
  6. 6चुनौती

वह समस्या जिसे हम हल कर रहे हैं

shop प्रोजेक्ट अब उस आकार में आ गया है जो ज़्यादातर असली प्रोजेक्ट्स का होता है: कोड src/ में, टेस्ट tests/ में।

text
shop/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       └── pricing.py
└── tests/
    └── test_pricing.py

आपने इसे सही तरीके से चलाना सीख लिया है, और "सही तरीका" अब एक लंबी लाइन बन चुका है:

text
PYTHONPATH=src pytest -ra --strict-markers tests

एक साथी repository को clone करता है और वही टाइप करता है जो कोई भी करेगा:

text
$ 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 ===============================

कोड में कोई गड़बड़ी नहीं है। गड़बड़ी यह है कि टेस्ट चलाने का तरीका प्रोजेक्ट में नहीं, बल्कि आपके दिमाग़ और आपकी shell history में रहता है। CI एक तीसरा रूप चलाएगा, और एक ही commit से तीन लोगों को तीन अलग नतीजे दिखेंगे।

ये flags आपकी पसंद नहीं हैं; ये प्रोजेक्ट के नियम हैं। यह अध्याय इन्हें एक ऐसी फ़ाइल में ले जाता है जिसे pytest हर बार पढ़ता है — और फिर उसी फ़ाइल से एक दूसरी, ज़्यादा चुपचाप रहने वाली समस्या सुलझाता है: वे warnings जो स्क्रीन पर से गुज़र जाती हैं और कभी पढ़ी नहीं जातीं।

इस अध्याय के अंत में आप कर पाएंगे

  • pytest की settings को pyproject.toml (या pytest.ini) में रखना और header से पक्का करना कि कौन-सी फ़ाइल इस्तेमाल हुई
  • testpaths, addopts, pythonpath, minversion, markers और xfail_strict सोच-समझकर चुनना, और बता पाना कि क्यों
  • जब आपको किसी run को होते हुए देखना हो, तब log_cli से live logging चालू करना
  • pytest.warns और pytest.deprecated_call से assert करना कि कोड warning देता है, match और रिकॉर्ड की गई लिस्ट के साथ
  • config में filterwarnings से, mark के रूप में और -W से warnings को नियंत्रित करना — और CI में उन्हें error बनाना

ज़रूरी शर्तें: मार्कर, skip और xfail।


टेस्ट लिखने से पहले

यह अध्याय सामान्य से कम टेस्ट लिखता है और टेस्ट कैसे चलते हैं इसके बारे में ज़्यादा नियम। पहले होने वाली सोच वही है: तय करें कि क्या वादा किया गया है, फिर उन cases की योजना बनाएँ जो उस वादे को जाँचते हैं।

क्या वादा किया गया है। यहाँ दो तरह के वादे हैं।

  • suite कैसे चलता है। जो कोई भी सादा pytest टाइप करे, उसे वही टेस्ट, वही import path और वही सख़्ती मिले जो CI को मिलती है। गलत लिखा marker run को रोक दे। जो xfail पास होने लगे, उसकी रिपोर्ट हो, वह छिपा न रहे।
  • कोड अपनी return value के अलावा क्या कहता है। apply_discount(total, percent) एक संख्या लौटाता है, और percent > 50 होने पर यह एक UserWarning भी देता है कि discount "looks unusually large"। total_with_vat() अब भी काम करता है, लेकिन एक DeprecationWarning देता है जो line_total() की ओर इशारा करती है। warning भी output है; जब वह contract का हिस्सा हो, तो उसे output की तरह ही टेस्ट किया जाता है।

क्या पहले से तैयार होना चाहिए। pytest 9 वाला एक virtual environment (pytest --version), कोड जो टेस्ट्स से import हो सके (यही नीचे pythonpath है), और प्रोजेक्ट के root पर एक config फ़ाइल। कोई अतिरिक्त plugin नहीं।

warning वाले व्यवहार के लिए योजना:

| केस | इनपुट | अपेक्षित | | --- | --- | --- | | सामान्य रास्ता | apply_discount(200.0, 10) | 180.0, कोई warning नहीं | | सीमा, अब भी शांत | apply_discount(200.0, 50) | 100.0, कोई warning नहीं | | सीमा के ठीक पार | apply_discount(200.0, 60) | 80.0 और एक UserWarning जिसमें "unusually large" हो | | deprecated नाम | total_with_vat(100.0, 1) | 115.0 और एक DeprecationWarning जिसमें line_total हो | | किसी library का अपना deprecation | कुछ भी जो उसे ट्रिगर करे | हमारा suite fail न करे |

config के लिए "टेस्ट" खुद एक run है: root से और tests/ के अंदर से, दोनों जगह सादा pytest केवल tests/ को collect करे और पास हो, और गलत लिखा marker run को रोक दे।

क्या टेस्ट नहीं करना है। यह नहीं कि Python का warnings module काम करता है, न यह कि pytest अपना config पढ़ता है — वह किसी और का, पहले से टेस्ट किया हुआ कोड है। warning का पूरा टेक्स्ट भी नहीं: वह वाक्यांश match करें जिस पर पढ़ने वाला कार्रवाई करता है ("unusually large"), ताकि बाकी शब्द बदलने से कुछ न टूटे। और किसी third-party library के deprecations भी नहीं — उन्हें filter किया जाता है, जो configuration का फ़ैसला है, टेस्ट नहीं।

सीमा वाली पंक्ति वही है जिसे लोग भूल जाते हैं, क्योंकि गायब warning कोई आवाज़ नहीं करती। अध्याय के अंत तक config की एक लाइन इसे एक ऐसी जाँच बना देगी जो आपको मुफ़्त में मिलती है।

एक फ़ाइल, जो सबसे पहले पढ़ी जाती है

pytest एक भी टेस्ट collect करने से पहले अपनी settings पढ़ता है। आमतौर पर इनकी जगह वह pyproject.toml होती है जो प्रोजेक्ट में पहले से है, [tool.pytest.ini_options] नाम की एक table में:

toml
[project]
name = "shop"
version = "0.1.0"

[tool.pytest.ini_options]
pythonpath = ["src"]

pythonpath उन directories की सूची है, प्रोजेक्ट root के सापेक्ष, जिन्हें pytest टेस्ट import करने से पहले sys.path में जोड़ता है। जिस कोड को टेस्ट किया जा रहा है वह साधारण है:

python
def line_total(price, quantity):
    if quantity < 1:
        raise ValueError("quantity must be at least 1")
    return round(price * quantity * 1.15, 2)
text
$ 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 ===============================

header पढ़ें। configfile: pyproject.toml के ज़रिए pytest आपको बताता है कि उसने कौन-सी फ़ाइल इस्तेमाल की। जब भी लगे कि settings अनदेखी हो रही हैं, सबसे पहले यही लाइन देखें।

import को टेस्ट फ़ाइल के अंदर ही ठीक क्यों न कर दें? यह स्पष्ट रूप से गलत तरीका है, और यह काम करता है — कभी-कभी:

python
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
text
$ 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" मौजूदा directory के सापेक्ष है, इसलिए टेस्ट पास होगा या टूटेगा, यह इस पर निर्भर करता है कि आप कहाँ खड़े हैं। config में pythonpath प्रोजेक्ट root के सापेक्ष है, जो अपनी जगह से नहीं हिलता। import की व्यवस्था प्रोजेक्ट की ज़िम्मेदारी है, इसलिए वह एक बार प्रोजेक्ट की फ़ाइल में जाती है, हर टेस्ट module के ऊपर नहीं।

कौन-सी फ़ाइल जीतती है, और root कहाँ है

pytest कई फ़ाइलें स्वीकार करता है। हर directory में वह इस क्रम में देखता है: pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg। वह command line पर दिए गए paths (या मौजूदा directory) से शुरू करता है और ऊपर की ओर चलता है; पहली फ़ाइल जिसमें सचमुच pytest section हो, वही जीतती है। बिना pytest table वाली pyproject.toml गिनी नहीं जाती।

वह directory rootdir बन जाती है — header की rootdir: लाइन — और config में दिए relative paths उसी के सापेक्ष होते हैं। इसलिए tests/ के अंदर खड़े होकर भी root मिल जाता है:

text
$ 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 items

pytest.ini में यही settings INI syntax, एक [pytest] section, और बिना quotes या brackets के लिखी जाती हैं:

ini
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
    slow: takes more than a second; deselect with -m 'not slow'

एक फ़ाइल चुनें। अगर दोनों मौजूद हों, तो pytest.ini पूरी तरह जीतती है — कुछ भी merge नहीं होता — और header यह बता देता है। यहाँ pyproject.toml के बगल में दो लाइनों वाली एक pytest.ini (minversion = 9.0) जोड़ी गई थी:

text
$ 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

…और ModuleNotFoundError वापस आ गया, क्योंकि pythonpath उस फ़ाइल में था जिसे अब अनदेखा किया जा रहा है।

testpaths — जब आप कुछ न बताएँ तब कहाँ देखना है

किसी ने rounding आज़माते हुए एक scratch फ़ाइल, notes/test_scratch.py, छोड़ दी। सादा pytest root के नीचे सब कुछ collect करता है, इसलिए वह भी चल जाती है:

text
$ 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.01s

testpaths बताता है कि असली suite कहाँ रहता है:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
text
$ pytest -q
..                                                                       [100%]
2 passed in 0.01s

पूरे header में अब आपको testpaths: tests भी दिखेगा। यह एक default है, कोई बाड़ नहीं: यह तभी लागू होता है जब आप root से paths का नाम दिए बिना चलाते हैं, इसलिए pytest notes अब भी scratch फ़ाइल चलाता है। बड़े repository में यह collection को तेज़ भी करता है, क्योंकि pytest docs/, build/ या virtual environment वाले folder में घूमना बंद कर देता है।

addopts और markers — नियम जो सबको मिलते हैं

addopts वह टेक्स्ट है जिसे pytest आपके टाइप किए हुए से पहले जोड़ देता है। शुरुआत वाले प्रोजेक्ट के दोनों नियम यहाँ जाते हैं:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"

-ra हर उस टेस्ट के लिए एक छोटी summary लाइन प्रिंट करता है जो सीधे-सीधे पास नहीं हुआ — skipped, xfailed, xpassed, failed — ताकि कोई skip कभी अनदेखा न रह जाए। --strict-markers किसी unregistered marker को error बना देता है। एक नई फ़ाइल, tests/test_discounts.py, में एक टेस्ट @pytest.mark.slow से mark किया गया है (और एक Windows के अलावा बाकी जगह skip होता है), और marker अभी register नहीं हुआ है:

text
$ 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.01s

यही इस flag का मकसद है। इसके बिना, @pytest.mark.slwo जैसी typo पर summary में बस एक PytestUnknownMarkWarning आती है, run हरा रहता है, और -m "not slow" उस टेस्ट को चलाता रहता है जिसे आप बाहर रखना चाहते थे। marker को register करें, हर एक के लिए एक लाइन, ऐसे विवरण के साथ जिसे pytest --markers दिखाएगा:

toml
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
]
text
$ 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'

SKIPPED वाली लाइन -ra का काम है: अब हर run पर skip खुद अपनी वजह बताता है।

addopts में क्या नहीं होना चाहिए। गलत तरीका है addopts = "-v -x --pdb"। ये आज की debugging के लिए आपकी आदतें हैं; साझा फ़ाइल में ये सब पर थोप दी जाती हैं, CI पर भी — जहाँ --pdb एक ऐसे keyboard का हमेशा इंतज़ार करता रहता है जिस पर कोई नहीं बैठा। addopts में केवल प्रोजेक्ट के नियम रखें। आप जो टाइप करते हैं वह उसके बाद जुड़ता है, इसलिए pytest -x अब भी सिर्फ़ आपके लिए काम करता है, और -o किसी एक run के लिए कोई भी setting override कर देता है:

text
$ pytest -q -o addopts=""
.s..                                                                     [100%]
3 passed, 1 skipped in 0.01s

इस बार कोई skip लाइन नहीं, क्योंकि उस run के लिए -ra हटा दिया गया था।

minversion और xfail_strict

अगर किसी का pytest फ़ाइल के लिए बहुत पुराना है, तो minversion run को तुरंत रोक देता है। ऐसा version माँगने पर जो मौजूद ही नहीं, यह संदेश दिखता है:

text
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'

शुरुआत में ही साफ़ error, उस पुराने pytest से बेहतर है जो settings को आधा-अधूरा समझे।

xfail_strict पिछले अध्याय का अधूरा छूटा सिरा संभालता है: एक xfail जो पास होने लगे। यहाँ एक bug को fail होने की उम्मीद के साथ mark किया गया था, और तब से किसी ने उसे ठीक कर दिया:

python
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
text
$ pytest -q
X                                                                        [100%]
1 xpassed in 0.01s

एक हरा run, एक X जिसे कोई नहीं पढ़ता, और एक marker जो अब झूठ बोल रहा है। config में xfail_strict = true के साथ:

text
$ 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.01s

अच्छी ख़बर अब एक failure के रूप में आती है जो आपसे marker हटाने को कहती है — इसीलिए इसे टेस्ट-दर-टेस्ट नहीं, पूरे प्रोजेक्ट के लिए सेट करना सार्थक है। pytest 9.1 नया नाम strict_xfail भी स्वीकार करता है, और एक अकेला strict = true भी, जो सख़्ती के सारे विकल्प एक साथ चालू कर देता है (markers और xfail सहित); xfail_strict काम करता रहता है।

Live logs: log_cli

default रूप से pytest log records को capture करता है और उन्हें केवल fail होने वाले टेस्ट्स के लिए दिखाता है। किसी पास होते run की पूरी कहानी देखने के लिए live logging चालू करें:

toml
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"

तब हर टेस्ट के INFO और उससे ऊपर के records, टेस्ट चलते समय ही, live log call शीर्षक के नीचे प्रिंट होते हैं; नीचे का पूर्ण उदाहरण यह दिखाता है। Live logs शोर भरे होते हैं, इसलिए कई प्रोजेक्ट फ़ाइल में केवल log_cli_level रखते हैं और ज़रूरत पड़ने पर pytest -o log_cli=true से output चालू करते हैं।

pytest 9 की native table: [tool.pytest]

[tool.pytest.ini_options] का नाम अजीब है, और उसकी वजह है: ये TOML फ़ाइल में बैठी INI settings हैं, इसलिए हर value टेक्स्ट में बदल जाती है। pytest 9 एक native table, [tool.pytest], जोड़ता है, जहाँ values अपने TOML types बनाए रखती हैं — असली lists, असली booleans:

toml
[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
text
$ 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

addopts अब एक list है। दो नियम: एक फ़ाइल में एक ही table इस्तेमाल करें, दोनों कभी नहीं (pytest शुरू होने से मना कर देता है), और याद रखें कि pytest 9 से पहले के versions [tool.pytest] को बिल्कुल नहीं पढ़ते। यह अध्याय [tool.pytest.ini_options] रखता है क्योंकि यह हर उस pytest पर काम करता है जिससे आपका सामना होने की संभावना है; जो प्रोजेक्ट केवल pytest 9 चलाता है, उसके लिए native table ही अनुशंसित रूप है। pytest 9 एक अलग pytest.toml भी पढ़ता है, जिसमें इसी native शैली की [pytest] table होती है।

Cache, संक्षेप में

run के बाद root पर एक .pytest_cache/ folder होता है। यह याद रखता है कि पिछली बार कौन-से टेस्ट fail हुए थे, और इसी से --lf (last failed) और --ff (failed first) चलते हैं। दो flags जानने लायक हैं। pytest --cache-clear run से पहले इसे खाली कर देता है — तब उपयोगी जब --lf ऐसे टेस्ट चुनता रहे जो अब मौजूद ही नहीं हैं। pytest -p no:cacheprovider cache plugin को पूरी तरह बंद कर देता है, ताकि कुछ भी लिखा न जाए, जो read-only checkout के लिए ठीक है। कीमत यह है कि plugin के options भी उसके साथ ग़ायब हो जाते हैं:

text
$ 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/mv

Warnings: run के अंत वाला section

warning एक संदेश है कि कुछ अभी गलत नहीं है। Python उसे प्रिंट करता है और आगे बढ़ जाता है:

python
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)
text
$ 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.0

stacklevel=2 deprecation को caller की लाइन की ओर इशारा करवाता है, यानी उस लाइन की ओर जिसे किसी को बदलना है। pytest टेस्ट्स के दौरान उठी warnings को पकड़ता है और उन्हें अंत में एक section में इकट्ठा कर देता है:

python
from pricing import total_with_vat


def test_old_name_still_works():
    assert total_with_vat(100.0, 1) == 115.0
text
$ 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.01s

टेस्ट पास हुआ, और आख़िरी लाइन कहती है 1 passed, 1 warning। बड़े suite में यह section दर्जनों entries तक बढ़ जाता है, हर कोई इसे स्क्रॉल करके निकल जाना सीख लेता है, और एक दिन deprecated फ़ंक्शन हटा दिया जाता है और "पास होने वाला" suite टूट जाता है। warning के साथ आप दो काम कर सकते हैं: जब वह व्यवहार का हिस्सा हो, तो उसकी अपेक्षा करें, या जब न हो, तो उसे अस्वीकार करें।

pytest.warns — warning की अपेक्षा करना

जब warning contract का हिस्सा हो — योजना की "सीमा के ठीक पार" वाली पंक्ति — तो उसे वैसे ही टेस्ट करें जैसे आप pytest.raises से exception टेस्ट करते हैं:

python
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
text
$ pytest -q test_discount.py
..                                                                       [100%]
2 passed in 0.01s

block तब पास होता है जब उसके अंदर कम से कम एक UserWarning (या उसकी subclass) निकले और उसका संदेश match से मेल खाए, जो एक regular expression है और टेक्स्ट में खोजा जाता है, ठीक pytest.raises की तरह। दोनों में से कोई भी हिस्सा टूटे तो साफ़ failure आता है। यहाँ कोड 50 पर शांत रहता है, और दूसरा टेस्ट गलत शब्द ढूँढता है:

python
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)
text
$ 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.01s

दोनों रिपोर्ट्स उन warnings की सूची देती हैं जो सच में निकलीं, और टेस्ट ठीक करने के लिए आमतौर पर इतना ही चाहिए। जो warning match नहीं हुई, वह निगली भी नहीं जाती; वह आगे warnings summary में पहुँच जाती है।

हमेशा match क्यों दें। अकेला pytest.warns(UserWarning) कमज़ोर रूप है: block में कहीं से भी आई कोई भी UserWarning इसे संतुष्ट कर देती है — उस library की भी, जिसे आप संयोग से किसी असंबंधित कारण से call करते हैं। जिस warning की आपको परवाह है, उसके चले जाने के बाद भी टेस्ट पास होता रह सकता है। match किया गया वाक्यांश टेस्ट को इसी warning से बाँध देता है।

कितनी warnings निकलीं, या उन्होंने क्या कहा, यह जाँचने के लिए as से रिकॉर्ड रखें:

python
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)
text
$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01s

record block में पकड़ी गई हर warning की सूची है — दो, क्योंकि 10% वाली call शांत है। हर entry में .message, .category, .filename और .lineno होते हैं।

pytest.deprecated_call — आपके users से एक वादा

किसी फ़ंक्शन को deprecate करना एक वादा है: "यह अब भी काम करता है, और आपको बता दिया गया है"। pytest.deprecated_call() दोनों हिस्से जाँचता है:

python
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
text
$ pytest -q test_old_api.py
.                                                                        [100%]
1 passed in 0.01s

यह pytest.warns ही है जिसमें categories पहले से भरी हुई हैं — DeprecationWarning, PendingDeprecationWarning और FutureWarning — और यह भी match लेता है। warnings summary वाली entry ग़ायब हो गई: warning अपेक्षित थी, इसलिए वह consume हो गई।

filterwarnings — warnings को अस्वीकार करना

अपेक्षित warnings अब टेस्ट बन चुकी हैं। बाकी या तो शोर हैं या शुरुआती चेतावनियाँ, और CI के लिए अपनाने लायक आदत यह है: हर अनपेक्षित warning एक error है। एक लाइन:

toml
[tool.pytest.ini_options]
filterwarnings = [
    "error",
]

यहाँ एक suite है जिसमें एक साफ़ टेस्ट है, एक जो अब भी deprecated नाम call करता है, और एक जो ऐसी library का currency.rate() call करता है जिसे आप बदल नहीं सकते, और जो अपनी DeprecationWarning देती है:

python
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
text
$ 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.01s

हर warning अब एक exception के रूप में उठती है, उस लाइन तक के traceback के साथ जिसने उसे पैदा किया। हर failure पर अब एक फ़ैसला होता है, सब पर एक साथ चुप्पी नहीं।

गलत तरीका है "ignore::DeprecationWarning": एक लाइन, सब कुछ हरा, और library के साथ-साथ आपका अपना deprecation भी छिप गया। सही तरीका है हर छूट को संकरा करना। filter का आकार action:message:category:module होता है — Python के अपने -W option जैसा ही — जहाँ message warning के टेक्स्ट की शुरुआत से मेल खाता है और module वह module है जिसके नाम warning दर्ज होती है:

toml
[tool.pytest.ini_options]
filterwarnings = [
    "error",
    "ignore::DeprecationWarning:currency",
]

क्रम मायने रखता है: बाद वाली लाइनों को प्राथमिकता मिलती है, इसलिए error पहले आता है और छूटें उसके बाद। library की warning अब केवल तब अनदेखी होती है जब वह currency से आए। किसी एक टेस्ट के लिए वही filter mark के रूप में लगाया जा सकता है — यहाँ test_old_name पर, फ़ाइल का बाकी हिस्सा जस का तस:

python
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
text
$ pytest -q
...                                                                      [100%]
3 passed in 0.01s

यह mark warning को केवल अनुमति देता है। अगर आपका मतलब है "यह warning ज़रूर देनी चाहिए", तो pytest.deprecated_call() बेहतर विकल्प है, क्योंकि warning ग़ायब होने पर वह fail भी होता है। जिन warnings को आप सहन करते हैं उनके लिए mark इस्तेमाल करें, और जिनका आप वादा करते हैं उनके लिए warns।

यही filters किसी एक run के लिए -W से भी दिए जा सकते हैं, जो किसी नीति को लिखकर तय करने से पहले आज़माने के लिए सुविधाजनक है। पहले वाली test_old_api.py पर, बिना किसी config के:

text
$ 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.01s

जब स्रोत आपस में असहमत हों, तो सबसे विशिष्ट जीतता है: टेस्ट पर लगा mark -W से ऊपर है, और -W config फ़ाइल से ऊपर। तीन टेस्ट वाले suite पर, -W config के currency वाले ignore को override कर देता है, जबकि mark वाला टेस्ट अपनी छूट बनाए रखता है:

text
$ 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.01s

error से आपको एक और चीज़ मिलती है। योजना की सीमा वाली पंक्ति, "50 पर कोई warning नहीं", को फिर से देखें। config में error होने पर, apply_discount(200.0, 50) का एक सादा टेस्ट इसे पहले से ही जाँचता है: अगर वह call कभी warning देती, तो warning एक exception बन जाती और टेस्ट fail हो जाता। शांत cases के लिए किसी ख़ास कोड की ज़रूरत नहीं।


पूर्ण उदाहरण

shop प्रोजेक्ट, हर नियम एक ही जगह। pyproject.toml:

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:

python
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)
text
$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0

tests/test_pricing.py — अध्याय की शुरुआत वाली योजना, पंक्ति-दर-पंक्ति:

python
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
text
$ 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 ==================

यहाँ तीन बातें ध्यान देने योग्य हैं।

पहली, दोनों में से किसी भी command में कोई प्रोजेक्ट नियम नहीं है। नियम — टेस्ट कहाँ हैं, क्या import हो सकता है, strict markers, strict xfail, warnings को error मानना — सब फ़ाइल में हैं, इसलिए जो साथी सिर्फ़ pytest टाइप करता है उसे ठीक पहला run मिलता है, और CI को भी।

दूसरी, कोई warnings summary नहीं है। कोड जो दोनों warnings देता है वे अपेक्षित हैं, इसलिए warns और deprecated_call ने उन्हें consume कर लिया; कोई भी दूसरी warning किसी टेस्ट को fail कर देती। साफ़ run अब सचमुच साफ़ है।

तीसरी, test_fifty_percent_is_quiet में warning से जुड़ा कोई कोड नहीं है, फिर भी यह सीमा की रखवाली करता है: error के तहत, 50% पर कोई warning इसे fail कर देती।


जब यह काम न करे

collection के दौरान ModuleNotFoundError: No module named 'shop' कोड टेस्ट्स से import नहीं हो पा रहा। config में pythonpath = ["src"] जोड़ें, और header की configfile: लाइन देखकर पक्का करें कि वही फ़ाइल पढ़ी जा रही है।

configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) दो config फ़ाइलें हैं, और जिसे आपने बदला वह हार गई। कुछ भी merge नहीं होता। एक को हटा दें।

'slow' not found in markers configuration option --strict-markers (या strict = true) चालू है, और marker या तो register नहीं है या उसकी spelling गलत है। उसे markers में register करें, या spelling ठीक करें।

PytestConfigWarning: Unknown config option: testpath गलत लिखी setting — testpaths की जगह testpath। pytest केवल warning देता है, इसलिए setting चुपचाप कुछ नहीं करती। strict_config = true (या strict = true) के साथ यह ERROR: Unknown config option: testpath बन जाती है और run रुक जाता है, जो आप चाहते हैं।

ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml में दोनों tables हैं। सब कुछ एक में ले जाएँ।

TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' native [tool.pytest] table में addopts एक list है: addopts = ["-ra", "--strict-markers"]। string वाला रूप [tool.pytest.ini_options] का है।

ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' install किया हुआ pytest प्रोजेक्ट की ज़रूरत से पुराना है। virtual environment के अंदर उसे upgrade करें।

Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. block में उस category की कोई warning नहीं निकली। नीचे Emitted warnings: पढ़ें — अक्सर कोड किसी दूसरी category से warning देता है, या इस इनपुट पर देता ही नहीं।

Failed: Regex pattern did not match any of the 1 warnings emitted. category सही, टेक्स्ट गलत। match एक regular expression है, इसलिए (, . और ? को escape करना पड़ता है — या टेक्स्ट को re.escape() में लपेट दें।

AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? किसी filterwarnings लाइन की category में typo है। pytest शुरू होने से मना कर देता है और ठीक ऊपर वह लाइन प्रिंट करता है जिसे वह parse नहीं कर पाया।