एरर की टेस्टिंग — pytest.raises
एरर भी व्यवहार है, इसलिए उसका भी टेस्ट चाहिए। pytest.raises, DID NOT RAISE, सबक्लास मिलान, match= असल में regex क्यों है, excinfo से पकड़े गए एरर की जाँच, और with ब्लॉक में सिर्फ़ आख़िरी लाइन ही क्यों।
- 1समस्या
- 2समझें
- 3उदाहरण
- 4अनुमान
- 5स्वयं करें
- 6चुनौती
वह समस्या जिसे हम हल कर रहे हैं
यह एक छोटा फ़ंक्शन है जो एक बैलेंस की रखवाली करता है। इसमें दो इनकार पहले से बने हुए हैं: ऐसी रकम जो पॉज़िटिव नहीं है, और ऐसी रकम जो जमा से ज़्यादा है।
wallet.py:
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
if amount > balance:
raise InsufficientFunds(balance, amount)
return balance - amountवे दो raise लाइनें भी व्यवहार हैं, ठीक उतनी ही जितना return। कॉल करने वाला इस भरोसे पर चलता है कि withdraw(100.0, -5.0) मना कर देगा, और अगर किसी दिन यह इनकार गायब हो गया, तो पैसा ग़लत दिशा में चला जाएगा। इसलिए इसका भी टेस्ट होना चाहिए। ज़्यादातर लोग पहला टेस्ट कुछ ऐसा लिखते हैं:
from wallet import withdraw
def test_negative_amount_is_rejected():
try:
withdraw(100.0, -5.0)
except ValueError:
passअब मान लीजिए किसी ने withdraw से if amount <= 0 वाली जाँच हटा दी। pytest -q चलाइए:
. [100%]
1 passed in 0.01sजाँच गायब है और टेस्ट फिर भी पास है। कोई exception न आने पर try ब्लॉक बस पूरा हो जाता है, except छूट जाता है, और फ़ंक्शन बिना किसी फ़ेल हुए assert के खत्म हो जाता है। टेस्ट ने बस इतना जाँचा था कि अगर ValueError आए, तो उसे अनदेखा कर दिया जाए। उसने कभी नहीं कहा कि एरर आना ज़रूरी है।
यह अध्याय इसी बात को ठीक से कहने के बारे में है — और उन गिने-चुने तरीक़ों के बारे में, जिनसे एरर का एक टेस्ट तब भी पास हो जाता है जब उसे नहीं होना चाहिए।
इस अध्याय के अंत में आप कर पाएंगे
pytest.raisesसे टेस्ट करना कि कोड raise करता है, औरDID NOT RAISEको पढ़ना- समझाना कि
pytest.raises(Exception)लगभग हमेशा बहुत चौड़ा क्यों है match=से मैसेज जाँचना, यह जानते हुए कि यह एक regular expression हैexcinfoके ज़रिए पकड़े गए exception की जाँच करनाwithब्लॉक में सिर्फ़ raise करने वाली लाइन रखना, और इसका कारण बतानाExceptionGroupको पहचानना और उसके अंदर क्या है यह जाँचना
ज़रूरी शर्तें: टेस्ट की खोज और उन्हें चलाना।
टेस्ट लिखने से पहले
एरर का कोई टेस्ट उतना ही अच्छा होता है जितना उसके पीछे का फ़ैसला। pytest.raises टाइप करने से पहले काग़ज़ पर तीन बातें तय कर लीजिए।
1. ठीक-ठीक वादा क्या है? withdraw को कोड की तरह नहीं, एक अनुबंध (contract) की तरह पढ़िए:
- इनपुट: एक बैलेंस और एक रकम। आउटपुट: नया बैलेंस,
balance - amount। - पहला इनकार: शून्य या नेगेटिव रकम
ValueErrorraise करती है, और मैसेज में वह रकम लिखी होती है। - दूसरा इनकार: बैलेंस से बड़ी रकम
InsufficientFundsraise करती है, जोbalanceऔरamountको attributes के रूप में साथ रखता है। - साइड इफ़ेक्ट: कोई नहीं।
withdrawअपने बाहर कुछ नहीं बदलता, इसलिए "इनकार के बाद कुछ नहीं बदला" यहाँ अपने-आप सच है — अंत के बैंक वाले उदाहरण में ऐसा अपने-आप नहीं होगा।
हर इनकार एक अलग वादा है, जिसके तीन हिस्से जाँचे जा सकते हैं: कौन सी क्लास, कौन सा मैसेज, और पीछे कौन सी स्थिति बची।
2. पहले से क्या तैयार होना चाहिए? पिछले अध्याय से ज़्यादा कुछ नहीं। pytest.raises pytest का ही हिस्सा है, और re स्टैंडर्ड लाइब्रेरी में है। आपको चाहिए एक्टिव वर्चुअल एनवायरनमेंट, इंस्टॉल किया हुआ pytest, और टेस्ट फ़ाइल से import हो सकने वाली wallet.py — इस अध्याय में वह test_wallet.py वाले फ़ोल्डर में ही है। पहली दो बातें एक बार जाँच लीजिए:
$ python -m pytest --version
pytest 9.1.1फिर कोड के बारे में एक भी दावा लिखने से पहले उसका असली व्यवहार देख लीजिए। एक छोटी सी जाँच उन सवालों का जवाब देती है जिनका जवाब अंदाज़ा नहीं दे सकता — 0.0 लौटता है या मना हो जाता है? मैसेज दिखता कैसा है?
from wallet import InsufficientFunds, withdraw
print(withdraw(100.0, 30.0))
print(withdraw(100.0, 100.0))
try:
withdraw(100.0, 100.01)
except InsufficientFunds as error:
print(repr(error))70.0
0.0
InsufficientFunds('cannot withdraw 100.01; balance is 100.00')3. कौन से केस? एरर सीमाओं पर रहते हैं, इसलिए योजना भी उन्हीं के इर्द-गिर्द बनाइए — सामान्य केस, हर इनकार के दोनों तरफ़ के किनारे, और साफ़ तौर पर ग़लत इनपुट:
| केस | इनपुट | अपेक्षित | |---|---|---| | सामान्य निकासी | withdraw(100.0, 30.0) | 70.0 लौटाता है | | पूरा बैलेंस (सीमा, अनुमति है) | withdraw(100.0, 100.0) | 0.0 लौटाता है, कोई एरर नहीं | | ज़रा सा ज़्यादा (सीमा, मना) | withdraw(100.0, 100.01) | InsufficientFunds, amount == 100.01 | | शून्य (सीमा, मना) | withdraw(100.0, 0) | ValueError | | नेगेटिव | withdraw(100.0, -5.0) | ValueError, मैसेज में (got -5.0) है | | साफ़ ओवरड्राफ़्ट | withdraw(100.0, 150.0) | InsufficientFunds, balance == 100.0, amount == 150.0 |
"अनुमति है" वाली दोनों पंक्तियाँ इनकारों जितनी ही अहम हैं। > की जगह >= से लिखी जाँच पूरे बैलेंस वाले केस को भी मना कर देती है, और इसे सिर्फ़ वही टेस्ट पकड़ता है जो वहाँ कोई एरर नहीं चाहता।
क्या टेस्ट न करें। पायथन का अपना व्यवहार: आर्गुमेंट छोड़कर withdraw(100.0) कॉल करने पर TypeError आना पायथन का वादा है, आपका नहीं। हर मैसेज का पूरा शब्दांकन, जब तक कि शब्दांकन ही अनुबंध न हो — वैल्यू वाला एक टुकड़ा काफ़ी है। और वह सब जिसका फ़ंक्शन ने कभी वादा ही नहीं किया, जैसे "ten" स्ट्रिंग देने पर क्या होता है। इन्हें टेस्ट करने से ऐसे टेस्ट बनते हैं जो मामूली बदलावों पर टूटते हैं और किसी चीज़ की रक्षा नहीं करते।
अध्याय का बाकी हिस्सा इस टेबल को टेस्ट में बदलता है, एक बार में एक टूल के साथ।
pytest.raises — "इसे raise करना ही होगा"
pytest.raises एक context manager है। with ब्लॉक के अंदर की हर चीज़ से अपेक्षा है कि वह नाम दिया गया exception raise करे:
import pytest
from wallet import withdraw
def test_negative_amount_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, -5.0)
def test_zero_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 0).. [100%]
2 passed in 0.01sअगर ब्लॉक से ValueError बाहर आता है, तो pytest.raises उसे पकड़ लेता है और टेस्ट ब्लॉक के बाद से चलता रहता है। यह आधा हिस्सा try/except में भी था। बाकी आधा यह है कि जब कुछ भी बाहर न आए तो क्या हो। if amount <= 0 वाली जाँच फिर से हटाइए और वही फ़ाइल चलाइए:
FF [100%]
=================================== FAILURES ===================================
_______________________ test_negative_amount_is_rejected _______________________
def test_negative_amount_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:7: Failed
____________________________ test_zero_is_rejected _____________________________
def test_zero_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_wallet.py:12: Failed
=========================== short test summary info ============================
FAILED test_wallet.py::test_negative_amount_is_rejected - Failed: DID NOT RAI...
FAILED test_wallet.py::test_zero_is_rejected - Failed: DID NOT RAISE ValueError
2 failed in 0.01sDID NOT RAISE ValueError — यही वह वाक्य है जो try/except वाला संस्करण कभी नहीं कह सकता था। pytest.raises बस इतना ही है: अपेक्षित एरर को पकड़ना, और उसके न आने पर ज़ोर से फ़ेल होना।
कौन से exception मैच माने जाते हैं
pytest.raises(X) ठीक वैसे ही मिलान करता है जैसे except X: करता है: X खुद या उसकी कोई भी सबक्लास। पायथन के exceptions एक वंश-वृक्ष बनाते हैं, और उसकी एक शाखा देखी जा सकती है:
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
Trueयानी KeyError एक LookupError है। यह काम की बात है, और यही जाल भी है। यहाँ दो टेस्ट हैं, दोनों पास होते हैं:
import pytest
from wallet import withdraw
def test_missing_price_is_a_lookup_error():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_too_broad():
with pytest.raises(Exception):
withdraw(100.0) # forgot the amount.. [100%]
2 passed in 0.01sपहला ठीक है — जानबूझकर पैरेंट क्लास पकड़ना। दूसरा ऐसा टेस्ट है जो कुछ साबित नहीं करता। withdraw(100.0) फ़ंक्शन के अंदर पहुँचा ही नहीं: छूटे हुए आर्गुमेंट के लिए पायथन ने TypeError raise किया, और चूँकि हर सामान्य exception Exception की सबक्लास है, pytest.raises(Exception) ने उसे ख़ुशी से मान लिया। कोई टाइपो, ग़लत import, कोई AttributeError — इनमें से कोई भी इस टेस्ट को हरा कर देता।
नियम: कोड जिस सबसे ख़ास exception को raise करने का वादा करता है, उसी का नाम लिखिए। क्लास जितनी चौड़ी, उतनी ज़्यादा असंबंधित विफलताएँ वह छिपा लेती है।
उल्टी दिशा सख़्त है। जो exception नाम दी गई क्लास या उसकी सबक्लास नहीं है, वह पकड़ा नहीं जाता; वह ब्लॉक से बाहर निकल जाता है और एक सामान्य एरर की तरह टेस्ट को फ़ेल कर देता है। (pytest -q --tb=short हर traceback को प्रति फ़्रेम एक लाइन तक छोटा कर देता है।)
import pytest
from wallet import withdraw
def test_overdraft_is_rejected():
with pytest.raises(ValueError):
withdraw(100.0, 150.0)F [100%]
=================================== FAILURES ===================================
__________________________ test_overdraft_is_rejected __________________________
test_wallet.py:8: in test_overdraft_is_rejected
withdraw(100.0, 150.0)
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_overdraft_is_rejected - wallet.InsufficientFunds:...
1 failed in 0.01sDID NOT RAISE नहीं — कुछ raise हुआ है, बस वह नहीं जिसका आपने नाम लिया, और pytest दिखाता है कि वह कहाँ से आया।
match= — मैसेज की जाँच
टाइप बताता है कि इनकार किस तरह का था। मैसेज बताता है क्यों, और अक्सर उस ग़लत वैल्यू को भी साथ रखता है। match= इसे जाँचता है:
import pytest
from wallet import withdraw
def test_message_says_positive():
with pytest.raises(ValueError, match="must be positive"):
withdraw(100.0, -5.0)
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0).F [100%]
=================================== FAILURES ===================================
______________________________ test_whole_message ______________________________
def test_whole_message():
> with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
E AssertionError: Regex pattern did not match.
E Expected regex: 'amount must be positive (got -5.0)'
E Actual message: 'amount must be positive (got -5.0)'
E Did you mean to `re.escape()` the regex?
test_wallet.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_message - AssertionError: Regex pattern did...
1 failed, 1 passed in 0.01sअपेक्षित टेक्स्ट और असली मैसेज बिल्कुल एक जैसे हैं, फिर भी टेस्ट फ़ेल हुआ। कारण पहली लाइन में ही है: match= सादी स्ट्रिंग नहीं है। यह एक regular expression है, और pytest re.search(pattern, str(exception)) चलाता है। इसके दो नतीजे हैं:
re.searchमैसेज में कहीं भी खोजता है।"must be positive"बीच में मैच हो गया। शुरुआत या अंत बाँधने के लिए^और$का इस्तेमाल कीजिए।- कुछ अक्षरों का ख़ास मतलब होता है। regex में
( )एक ग्रुप बनाते हैं और कोई कोष्ठक मैच नहीं करते;.कोई भी अक्षर मैच करता है;+,*,?,[,$के भी अपने काम हैं।
. वाला मामला ज़्यादा चुपचाप है, क्योंकि वह ग़लती से पास होने की दिशा में चूकता है:
import re
print(re.search("100.00", "balance is 100.00"))
print(re.search("100.00", "balance is 100500"))
print(re.search(re.escape("100.00"), "balance is 100500"))<re.Match object; span=(11, 17), match='100.00'>
<re.Match object; span=(11, 17), match='100500'>
None"100.00" आराम से 100500 से मैच हो गया। re.escape हर ख़ास अक्षर को साधारण अक्षर में बदल देता है, इसलिए जब आपका मतलब "ठीक यही टेक्स्ट" हो, तो टेक्स्ट को इसमें से गुज़ारिए — ठीक जैसा pytest के संकेत ने सुझाया था:
import re
import pytest
from wallet import withdraw
def test_whole_message():
message = "amount must be positive (got -5.0)"
with pytest.raises(ValueError, match=re.escape(message)):
withdraw(100.0, -5.0). [100%]
1 passed in 0.01sमैसेज का कितना हिस्सा मैच करें, यह विवेक की बात है। एक छोटा, अलग पहचान वाला टुकड़ा — वैल्यू, मुख्य शब्द — मामूली दोबारा-लेखन में भी टिका रहता है; पूरा वाक्य उसी पल टूट जाता है जब कोई एक टाइपो ठीक करता है।
excinfo — वह exception जो आपने पकड़ा
with pytest.raises(...) as excinfo आपको पकड़ी गई चीज़ का एक हैंडल देता है। pytest -q -s से एक बार अंदर झाँकिए (-s से print दिखता है):
import pytest
from wallet import InsufficientFunds, withdraw
def test_look_inside():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
print()
print("type :", excinfo.type)
print("value :", repr(excinfo.value))
print("str :", str(excinfo.value))
print("fields:", excinfo.value.balance, excinfo.value.amount)type : <class 'wallet.InsufficientFunds'>
value : InsufficientFunds('cannot withdraw 150.00; balance is 100.00')
str : cannot withdraw 150.00; balance is 100.00
fields: 100.0 150.0
.
1 passed in 0.01sexcinfo.typeवह क्लास है जो असल में raise हुई — काम आता है जब आपने पैरेंट पकड़ा हो और जानना हो कि कौन सी चाइल्ड आई।excinfo.valueखुद exception ऑब्जेक्ट है, अपने सारे attributes के साथ।str(excinfo.value)मैसेज है — वही स्ट्रिंग जिसमेंmatch=खोजता है।
print सिर्फ़ देखने के लिए थे। असली टेस्ट में आप assert करते हैं:
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_reports_both_numbers():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.balance == 100.0
assert excinfo.value.amount == 150.0
assert str(excinfo.value) == "cannot withdraw 150.00; balance is 100.00". [100%]
1 passed in 0.01sजब exception डेटा को attributes के रूप में रखता है, तो मैसेज को पार्स करने के बजाय attributes पर assert करना ज़्यादा मज़बूत है। ध्यान दीजिए, assert with ब्लॉक के बाद हैं, अंदर नहीं। यह शैली की बात नहीं है; क्यों, यह अगले हिस्से में।
अंदर सिर्फ़ raise करने वाली लाइन
जिस पल exception raise होता है, पायथन ब्लॉक छोड़ देता है। raise करने वाली लाइन के बाद की हर लाइन छूट जाती है — assert भी:
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 999.0 # never runs
def test_text_amount_is_rejected():
with pytest.raises(ValueError):
amount = float("ten") # raises ValueError itself
withdraw(100.0, amount).. [100%]
2 passed in 0.01sदोनों टेस्ट हरे हैं, और दोनों ग़लत हैं। पहले में assert balance == 999.0 एक झूठा दावा है जो कभी चला ही नहीं। दूसरे में float("ten") ने सेटअप वाली लाइन पर ही ValueError raise कर दिया, इसलिए withdraw कभी कॉल ही नहीं हुआ — withdraw कुछ जाँचे या न जाँचे, टेस्ट पास होता है।
नियम: सेटअप ब्लॉक से पहले, अंदर raise करने वाली एक कॉल, जाँच उसके बाद।
import pytest
from wallet import InsufficientFunds, withdraw
def test_failed_withdrawal_keeps_balance():
balance = 100.0
with pytest.raises(InsufficientFunds):
balance = withdraw(balance, 150.0)
assert balance == 100.0. [100%]
1 passed in 0.01sअब assert चलता है, और कुछ असली चीज़ जाँचता है: मना की गई निकासी ने balance को नहीं छुआ, क्योंकि नई वैल्यू कभी असाइन ही नहीं हुई।
एक से ज़्यादा स्वीकार्य exception
pytest.raises एक टपल भी लेता है, फिर से बिल्कुल except की तरह। सूची की कोई भी क्लास चलेगी, और excinfo.type बताता है कि कौन सी आई:
import pytest
from wallet import InsufficientFunds, withdraw
def test_bad_withdrawal_is_refused():
with pytest.raises((ValueError, InsufficientFunds)) as excinfo:
withdraw(100.0, 150.0)
print()
print(excinfo.type.__name__)InsufficientFunds
.
1 passed in 0.01sइसका इस्तेमाल तब कीजिए जब अनुबंध सच में "इनमें से कोई एक" हो — जैसे ऐसा कोड जो प्लेटफ़ॉर्म के हिसाब से जायज़ तौर पर कोई भी raise कर सकता है। अपने फ़ंक्शनों के लिए एक सटीक क्लास लगभग हमेशा बेहतर वादा है।
यह टेस्ट करना कि कुछ raise नहीं करता
कोई pytest.does_not_raise() मौजूद नहीं है, और उसकी ज़रूरत भी नहीं। कोड को कॉल कीजिए। अगर वह raise करता है, तो टेस्ट उसी exception और उसके traceback के साथ फ़ेल होता है:
from wallet import withdraw
def test_whole_balance_can_be_withdrawn():
assert withdraw(100.0, 100.0) == 0.0. [100%]
1 passed in 0.01swithdraw में amount > balance को amount >= balance कर दीजिए — एक-का-फ़र्क़ वाली वह ग़लती जो बहुत लोग करते हैं — और pytest -q --tb=short बताता है:
F [100%]
=================================== FAILURES ===================================
_____________________ test_whole_balance_can_be_withdrawn ______________________
test_wallet.py:5: in test_whole_balance_can_be_withdrawn
assert withdraw(100.0, 100.0) == 0.0
^^^^^^^^^^^^^^^^^^^^^^
wallet.py:12: in withdraw
raise InsufficientFunds(balance, amount)
E wallet.InsufficientFunds: cannot withdraw 100.00; balance is 100.00
=========================== short test summary info ============================
FAILED test_wallet.py::test_whole_balance_can_be_withdrawn - wallet.Insuffici...
1 failed in 0.01sअनपेक्षित exception अपने-आप में एक विफलता है। कॉल को try/except: pytest.fail() में लपेटने से बस वह traceback खो जाता है।
एक साथ कई एरर: ExceptionGroup
पायथन 3.11 से कोड कई exceptions को एक साथ ExceptionGroup के रूप में raise कर सकता है। wallet.py में एक बैच संस्करण जोड़िए जो हर निकासी आज़माता है और सारी विफलताएँ एक साथ बताता है:
def withdraw_all(balance: float, amounts: list[float]) -> float:
errors = []
for amount in amounts:
try:
balance = withdraw(balance, amount)
except (ValueError, InsufficientFunds) as error:
errors.append(error)
if errors:
raise ExceptionGroup(f"{len(errors)} withdrawals failed", errors)
return balanceग्रुप अपने आप में एक अलग exception क्लास है। pytest.raises(ValueError) उसके अंदर नहीं देखता। अंदर की चीज़ें जाँचने के लिए ग्रुप को पकड़कर excinfo.group_contains से पूछिए, या pytest.RaisesGroup से पूरे ग्रुप का वर्णन कीजिए:
import pytest
from wallet import InsufficientFunds, withdraw_all
def test_plain_raises_does_not_look_inside():
with pytest.raises(ValueError):
withdraw_all(100.0, [-5.0, 30.0, 500.0])
def test_group_contains():
with pytest.raises(ExceptionGroup) as excinfo:
withdraw_all(100.0, [-5.0, 30.0, 500.0])
assert excinfo.group_contains(ValueError, match="must be positive")
assert excinfo.group_contains(InsufficientFunds)
assert not excinfo.group_contains(KeyError)
def test_raises_group():
with pytest.RaisesGroup(ValueError, InsufficientFunds):
withdraw_all(100.0, [-5.0, 30.0, 500.0])सिर्फ़ नतीजा देखने के लिए pytest -q --tb=no से चलाइए:
F.. [100%]
=========================== short test summary info ============================
FAILED test_wallet.py::test_plain_raises_does_not_look_inside - ExceptionGrou...
1 failed, 2 passed in 0.01sपहला टेस्ट फ़ेल होता है: ग्रुप जस का तस pytest.raises(ValueError) से बाहर निकल गया। group_contains इस सवाल का जवाब देता है कि "क्या इनमें से कोई अंदर कहीं है?", और match= भी लेता है। pytest.RaisesGroup ज़्यादा सख़्त है: ग्रुप में ठीक वही exceptions होने चाहिए जो सूची में हैं — न ज़्यादा, न कम, किसी भी क्रम में। दोनों pytest 8 में आए और pytest 9 में मौजूद हैं। इनकी ज़रूरत अक्सर नहीं पड़ेगी; पड़ेगी उस दिन, जब कोई लाइब्रेरी आपके हाथ में एक ग्रुप थमा देगी।
पूर्ण उदाहरण
तीन इनकारों वाला एक छोटा बैंक: अनजान खाता, ऐसी रकम जो पॉज़िटिव नहीं, और ओवरड्राफ़्ट। रकम की जाँच बिल्कुल withdraw जैसी ही काम करती है, इसलिए नीचे के टेस्ट अपनी मेहनत बाकी दो पर लगाते हैं। AccountNotFound जानबूझकर LookupError की सबक्लास है, ताकि कॉल करने वाले इसे वैसे ही पकड़ सकें जैसे डिक्शनरी में न मिली key को पकड़ते हैं।
bank.py:
class AccountNotFound(LookupError):
def __init__(self, account_id: str):
self.account_id = account_id
super().__init__(f"no account with id {account_id!r}")
class InsufficientFunds(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(f"cannot withdraw {amount:.2f}; balance is {balance:.2f}")
class Bank:
def __init__(self) -> None:
self._balances: dict[str, float] = {}
def open(self, account_id: str, deposit: float) -> None:
self._balances[account_id] = deposit
def balance(self, account_id: str) -> float:
try:
return self._balances[account_id]
except KeyError:
raise AccountNotFound(account_id) from None
def transfer(self, source: str, target: str, amount: float) -> None:
if amount <= 0:
raise ValueError(f"amount must be positive (got {amount})")
# Look both accounts up first, so nothing moves if either is missing.
available = self.balance(source)
self.balance(target)
if amount > available:
raise InsufficientFunds(available, amount)
self._balances[source] -= amount
self._balances[target] += amounttest_bank.py:
import pytest
from bank import AccountNotFound, Bank, InsufficientFunds
def make_bank() -> Bank:
bank = Bank()
bank.open("alice", 100.0)
bank.open("bob", 20.0)
return bank
def test_transfer_moves_money():
bank = make_bank()
bank.transfer("alice", "bob", 30.0) # must not raise
assert bank.balance("alice") == 70.0
assert bank.balance("bob") == 50.0
def test_unknown_account_is_a_lookup_error():
bank = make_bank()
with pytest.raises(LookupError) as excinfo:
bank.balance("carol")
assert excinfo.type is AccountNotFound
assert excinfo.value.account_id == "carol"
def test_unknown_target_moves_nothing():
bank = make_bank()
with pytest.raises(AccountNotFound, match="'carol'"):
bank.transfer("alice", "carol", 30.0)
assert bank.balance("alice") == 100.0
def test_overdraft_reports_both_numbers():
bank = make_bank()
with pytest.raises(InsufficientFunds) as excinfo:
bank.transfer("bob", "alice", 25.0)
assert excinfo.value.balance == 20.0
assert excinfo.value.amount == 25.0
assert bank.balance("bob") == 20.0pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/bank
collecting ... collected 4 items
test_bank.py::test_transfer_moves_money PASSED [ 25%]
test_bank.py::test_unknown_account_is_a_lookup_error PASSED [ 50%]
test_bank.py::test_unknown_target_moves_nothing PASSED [ 75%]
test_bank.py::test_overdraft_reports_both_numbers PASSED [100%]
============================== 4 passed in 0.01s ===============================तीन बातें ध्यान देने लायक़ हैं।
पहली, हर टेस्ट अपने with ब्लॉक में सिर्फ़ एक कॉल रखता है, और स्थिति की जाँच उसके बाद करता है। test_unknown_target_moves_nothing सबसे क़ीमती है: अगर transfer यह पता चलने से पहले ही कि Carol मौजूद नहीं है, Alice के पैसे काट लेता, तब भी वह AccountNotFound raise करता — और ब्लॉक के बाद वाला assert उस अधूरे ट्रांसफ़र को assert 70.0 == 100.0 के साथ पकड़ लेता।
दूसरी, test_unknown_account_is_a_lookup_error जानबूझकर पैरेंट क्लास पकड़ता है, क्योंकि bank.py यही वादा करता है, और फिर excinfo.type से तय करता है कि कौन सी चाइल्ड आई।
तीसरी, match="'carol'" जाँचता है कि मैसेज में वही खाता लिखा है जो माँगा गया था — एरर पढ़ने वाले व्यक्ति को सबसे ज़्यादा इसी जानकारी की ज़रूरत होती है।
जब यह काम न करे
Failed: DID NOT RAISE ValueError ब्लॉक बिना raise किए खत्म हो गया। या तो कोड ने सच में अपनी जाँच खो दी (टेस्ट ने अपना काम किया), या ब्लॉक के अंदर की कॉल वह नहीं है जो आप सोच रहे हैं — ग़लत आर्गुमेंट जो संयोग से वैध हैं, या ऐसा फ़ंक्शन जो raise करने के बजाय एक एरर-वैल्यू लौटाता है।
AssertionError: Regex pattern did not match. और उसके बाद Did you mean to re.escape() the regex? मैसेज सही है, लेकिन उसमें regex के अक्षर हैं, आमतौर पर (, ), ., [ या +। टेक्स्ट को re.escape(...) में लपेटिए। संकेत न हो तो मैसेज सच में अलग है: Expected regex और Actual message वाली लाइनों को अक्षर-दर-अक्षर मिलाइए।
Failed: Invalid regex pattern provided to 'match': missing ), unterminated subpattern at position 0 पैटर्न वैध regex ही नहीं है — यहाँ एक खुलने वाला ( है जिसका कोई बंद होने वाला नहीं। इलाज वही: re.escape।
टेस्ट किसी और exception के साथ फ़ेल होता है, जैसे wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 एक exception raise हुआ, लेकिन नाम दी गई क्लास या उसकी सबक्लास नहीं, इसलिए pytest.raises ने उसे जाने दिया। तय कीजिए कि कोड को कौन सा raise करना चाहिए, और फिर टेस्ट या कोड में से जो ग़लत है उसे ठीक कीजिए।
AssertionError: .value can only be used after the context manager exits आपने with ब्लॉक के अंदर excinfo.value को छुआ। उस लाइन को ब्लॉक के नीचे ले जाइए।
TypeError: Expected a BaseException type, but got 'str' आपने pytest.raises("ValueError") लिखा है। उद्धरण चिह्नों के बिना क्लास को ही दीजिए।
pytest.raises(ValueError) ब्लॉक से FAILED ... - ExceptionGroup... कोड ने एक ExceptionGroup raise किया है जिसमें एक ValueError है। ग्रुप ValueError नहीं है; pytest.raises(ExceptionGroup) के साथ excinfo.group_contains(ValueError) इस्तेमाल कीजिए, या pytest.RaisesGroup(ValueError)।
टेस्ट पास होता है, लेकिन आपको पक्का यक़ीन है कि नहीं होना चाहिए with ब्लॉक में raise करने वाली लाइन के बाद की लाइनें खोजिए — वे कभी नहीं चलतीं — और pytest.raises(Exception) खोजिए, जो लगभग कुछ भी मान लेता है। फिर जानबूझकर कोड तोड़िए और पक्का कीजिए कि टेस्ट लाल हो जाता है।
चरण 4 / 6 — अनुमान
अपनी समझ की जाँच करें
pytest -q चलाने पर आख़िरी लाइन में क्या छपेगा?
import pytest
def test_missing_key():
prices = {"pen": 15.0}
with pytest.raises(LookupError):
prices["eraser"]
def test_missing_index():
prices = [15.0, 60.0]
with pytest.raises(KeyError):
prices[5]- A2 passed in 0.01s
- B2 failed in 0.01s
- C1 failed, 1 passed in 0.01s
- D1 passed, 1 error in 0.01s
यह टेस्ट पास होता है। फिर भी इसमें गड़बड़ी क्या है?
import pytest
from wallet import InsufficientFunds, withdraw
def test_overdraft_names_the_amount():
with pytest.raises(InsufficientFunds) as excinfo:
withdraw(100.0, 150.0)
assert excinfo.value.amount == 999.0- Aकुछ नहीं — `excinfo.value.amount` सच में 999.0 है
- Bassert ब्लॉक के अंदर raise करने वाली लाइन के बाद है, इसलिए वह कभी चलता ही नहीं
- C`with` स्टेटमेंट में `excinfo` नाम नहीं दिया जा सकता
- Dassert से `AssertionError` आने के कारण `pytest.raises` टेस्ट को फ़ेल कर देता है
withdraw(100.0, -5.0) ठीक इसी मैसेज के साथ ValueError raise करता है: amount must be positive (got -5.0)। टेस्ट का क्या होगा?
import pytest
from wallet import withdraw
def test_whole_message():
with pytest.raises(ValueError, match="amount must be positive (got -5.0)"):
withdraw(100.0, -5.0)- Aपास होता है — दोनों टेक्स्ट बिल्कुल एक जैसे हैं
- B`DID NOT RAISE ValueError` के साथ फ़ेल होता है
- C`Invalid regex pattern` के साथ फ़ेल होता है
- D`Regex pattern did not match` के साथ फ़ेल होता है, क्योंकि कोष्ठक regex में एक ग्रुप बनाते हैं
उत्तर देने के लिए अकाउंट आवश्यक है
अपने उत्तर जाँचने के लिए साइन इन करें
प्रश्न ऊपर दिए गए हैं, और मन में उत्तर सोचना ही मुख्य कार्य है। सही उत्तर, व्याख्या और तीन-स्तरीय संकेत देखने के लिए साइन इन करें।
आपकी बारी
inventory.py लिखिए, जिसमें एक फ़ंक्शन remove_stock(stock: dict[str, int], item: str, quantity: int) -> int हो, और दो exception क्लासें:
UnknownItem,KeyErrorकी सबक्लास, जो तब raise हो जबitemstockमें न हो, और आइटम का नाम attribute के रूप में रखेOutOfStock, जो तब raise हो जबquantityबचे हुए से ज़्यादा हो, औरavailableवrequestedattributes रखेquantityपॉज़िटिव न होने पर एक साधारणValueError, जिसके मैसेज में वैल्यू हो
फ़ंक्शन बची हुई मात्रा लौटाता है, और डिक्शनरी को सिर्फ़ सफल होने पर ही बदलता है।
फिर कम से कम छह टेस्ट वाली test_inventory.py लिखिए:
- एक सफल निकासी — कोई
pytest.raisesनहीं, बस कॉल कीजिए और नतीजे पर assert कीजिए - एक अनजान आइटम,
KeyErrorके रूप में पकड़ा गया, औरexcinfo.typeदिखाए कि वहUnknownItemथा - शून्य मात्रा,
match=से जाँच कि मैसेज में वैल्यू लिखी है — मैसेज में कोष्ठक या बिंदु हों तोre.escapeइस्तेमाल कीजिए - ज़रूरत से ज़्यादा निकासी,
excinfo.value.availableऔरexcinfo.value.requestedपर assert - वही ज़्यादा निकासी, ब्लॉक के बाद assert कि डिक्शनरी नहीं बदली
- ठीक उतना निकालना जितना बचा है, जो raise नहीं करना चाहिए
फिर कोड को तीन बार तोड़िए, एक बार में एक, और हर विफलता पढ़िए: शून्य-मात्रा वाली जाँच हटाइए (DID NOT RAISE देखिए), स्टॉक जाँच में > को >= कीजिए, और आख़िर में अपने किसी assert को with ब्लॉक के अंदर, raise करने वाली लाइन के नीचे ले जाइए और उसमें ग़लत वैल्यू डालिए। पहले दो लाल होने चाहिए। तीसरा हरा ही रहेगा — और इस अध्याय की याद रखने लायक़ सीख यही है।
समाधान
अध्याय के बाकी हिस्से की तरह योजना से शुरू कीजिए: एक सामान्य केस, वह सीमा जिसकी अनुमति होनी ही चाहिए (सब कुछ निकाल लेना), और हर इनकार के लिए क्लास, मैसेज या attributes, और पीछे बची स्थिति की जाँच।
inventory.py:
class UnknownItem(KeyError):
def __init__(self, item: str):
self.item = item
super().__init__(item)
class OutOfStock(Exception):
def __init__(self, item: str, available: int, requested: int):
self.item = item
self.available = available
self.requested = requested
super().__init__(f"only {available} {item} left, {requested} requested")
def remove_stock(stock: dict[str, int], item: str, quantity: int) -> int:
if quantity <= 0:
raise ValueError(f"quantity must be positive (got {quantity})")
if item not in stock:
raise UnknownItem(item)
available = stock[item]
if quantity > available:
raise OutOfStock(item, available, quantity)
stock[item] = available - quantity
return stock[item]test_inventory.py:
import re
import pytest
from inventory import OutOfStock, UnknownItem, remove_stock
def test_removal_returns_what_is_left():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 3) == 7
assert stock == {"pen": 7}
def test_unknown_item_is_a_key_error():
stock = {"pen": 10}
with pytest.raises(KeyError) as excinfo:
remove_stock(stock, "eraser", 1)
assert excinfo.type is UnknownItem
assert excinfo.value.item == "eraser"
def test_zero_quantity_names_the_value():
stock = {"pen": 10}
with pytest.raises(ValueError, match=re.escape("(got 0)")):
remove_stock(stock, "pen", 0)
def test_overdraw_reports_both_numbers():
stock = {"pen": 10}
with pytest.raises(OutOfStock) as excinfo:
remove_stock(stock, "pen", 11)
assert excinfo.value.available == 10
assert excinfo.value.requested == 11
def test_overdraw_changes_nothing():
stock = {"pen": 10}
with pytest.raises(OutOfStock):
remove_stock(stock, "pen", 11)
assert stock == {"pen": 10}
def test_removing_everything_is_allowed():
stock = {"pen": 10}
assert remove_stock(stock, "pen", 10) == 0
assert stock == {"pen": 0}pytest -v:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/inventory
collecting ... collected 6 items
test_inventory.py::test_removal_returns_what_is_left PASSED [ 16%]
test_inventory.py::test_unknown_item_is_a_key_error PASSED [ 33%]
test_inventory.py::test_zero_quantity_names_the_value PASSED [ 50%]
test_inventory.py::test_overdraw_reports_both_numbers PASSED [ 66%]
test_inventory.py::test_overdraw_changes_nothing PASSED [ 83%]
test_inventory.py::test_removing_everything_is_allowed PASSED [100%]
============================== 6 passed in 0.01s ===============================अब तीनों तोड़-फोड़। quantity <= 0 वाली जाँच हटाकर, pytest -q --tb=no:
..F... [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_zero_quantity_names_the_value - Failed: DID NO...
1 failed, 5 passed in 0.01sउसे वापस लाकर और quantity > available को quantity >= available करके:
.....F [100%]
=========================== short test summary info ============================
FAILED test_inventory.py::test_removing_everything_is_allowed - inventory.Out...
1 failed, 5 passed in 0.01sउसे भी वापस लाकर और test_overdraw_changes_nothing के आख़िरी assert को ब्लॉक के अंदर, raise करने वाली लाइन के नीचे, assert stock == {"pen": 999} के रूप में ले जाकर:
...... [100%]
6 passed in 0.01sहर फ़ैसले का कारण:
UnknownItem,KeyErrorकी सबक्लास है। न मिला आइटम सच में न मिली key ही है, इसलिए पहले सेKeyErrorपकड़ने वाला कोड चलता रहता है। टेस्टKeyErrorपकड़ता है — सार्वजनिक वादा — और फिरexcinfo.typeसे सटीक क्लास तय करता है।- मात्रा की जाँच सबसे पहले। इसे
stockसे कुछ नहीं चाहिए, इसलिए यह जल्दी फ़ेल होती है, और मैसेज उसी आर्गुमेंट के बारे में होता है जो कॉल करने वाले ने ग़लत दिया। re.escape("(got 0)")। टुकड़े में वैल्यू है, जो एरर पढ़ने वाले को चाहिए, औरre.escapeकोष्ठकों को अक्षरशः रखता है। पूरा वाक्य मैच करना उस दिन टूट जाता जिस दिन कोई उसे दोबारा लिखता।- मैसेज पार्स करने के बजाय attributes।
availableऔरrequestedऐसी संख्याएँ हैं जिनकी सीधी तुलना हो सकती है; उन्हें स्ट्रिंग से निकालना डेटा नहीं, फ़ॉर्मेटिंग को टेस्ट करना होता। - ज़्यादा निकासी के लिए दो टेस्ट। एक जाँचता है कि exception क्या कहता है, दूसरा कि कोड पीछे क्या छोड़ गया। एक फ़ेल हो, तो उसका नाम ही बता देता है कि कौन सा वादा टूटा।
- सीमा वाले टेस्ट ने
>=बग पकड़ा। एरर वाले टेस्टों में से कोई नहीं पकड़ सकता था: वे सब तब भी raise कर रहे थे। सिर्फ़ उस टेस्ट ने ध्यान दिया जो किनारे पर कोई एरर नहीं चाहता। - तीसरी तोड़-फोड़ हरी रही। यह अध्याय में पहले देखा गया raise-के-बाद-की-लाइन वाला जाल है: झूठा assert कभी चला ही नहीं। इसका इकलौता बचाव आदत है — ब्लॉक के अंदर एक कॉल, जाँच उसके बाद।
Step 6 of 6
चुनौती — the chapter quiz
सरल से कठिन — दस प्रश्न, अंतिम वाले जानबूझकर चुनौतीपूर्ण बनाए गए हैं।
Sign in to take the quiz