अध्याय 04

एरर की टेस्टिंग — pytest.raises

एरर भी व्यवहार है, इसलिए उसका भी टेस्ट चाहिए। pytest.raises, DID NOT RAISE, सबक्लास मिलान, match= असल में regex क्यों है, excinfo से पकड़े गए एरर की जाँच, और with ब्लॉक में सिर्फ़ आख़िरी लाइन ही क्यों।

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

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

यह एक छोटा फ़ंक्शन है जो एक बैलेंस की रखवाली करता है। इसमें दो इनकार पहले से बने हुए हैं: ऐसी रकम जो पॉज़िटिव नहीं है, और ऐसी रकम जो जमा से ज़्यादा है।

wallet.py:

python
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) मना कर देगा, और अगर किसी दिन यह इनकार गायब हो गया, तो पैसा ग़लत दिशा में चला जाएगा। इसलिए इसका भी टेस्ट होना चाहिए। ज़्यादातर लोग पहला टेस्ट कुछ ऐसा लिखते हैं:

python
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 चलाइए:

text
.                                                                        [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।
  • पहला इनकार: शून्य या नेगेटिव रकम ValueError raise करती है, और मैसेज में वह रकम लिखी होती है।
  • दूसरा इनकार: बैलेंस से बड़ी रकम InsufficientFunds raise करती है, जो balance और amount को attributes के रूप में साथ रखता है।
  • साइड इफ़ेक्ट: कोई नहीं। withdraw अपने बाहर कुछ नहीं बदलता, इसलिए "इनकार के बाद कुछ नहीं बदला" यहाँ अपने-आप सच है — अंत के बैंक वाले उदाहरण में ऐसा अपने-आप नहीं होगा।

हर इनकार एक अलग वादा है, जिसके तीन हिस्से जाँचे जा सकते हैं: कौन सी क्लास, कौन सा मैसेज, और पीछे कौन सी स्थिति बची।

2. पहले से क्या तैयार होना चाहिए? पिछले अध्याय से ज़्यादा कुछ नहीं। pytest.raises pytest का ही हिस्सा है, और re स्टैंडर्ड लाइब्रेरी में है। आपको चाहिए एक्टिव वर्चुअल एनवायरनमेंट, इंस्टॉल किया हुआ pytest, और टेस्ट फ़ाइल से import हो सकने वाली wallet.py — इस अध्याय में वह test_wallet.py वाले फ़ोल्डर में ही है। पहली दो बातें एक बार जाँच लीजिए:

text
$ python -m pytest --version
pytest 9.1.1

फिर कोड के बारे में एक भी दावा लिखने से पहले उसका असली व्यवहार देख लीजिए। एक छोटी सी जाँच उन सवालों का जवाब देती है जिनका जवाब अंदाज़ा नहीं दे सकता — 0.0 लौटता है या मना हो जाता है? मैसेज दिखता कैसा है?

python
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))
text
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 करे:

python
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)
text
..                                                                       [100%]
2 passed in 0.01s

अगर ब्लॉक से ValueError बाहर आता है, तो pytest.raises उसे पकड़ लेता है और टेस्ट ब्लॉक के बाद से चलता रहता है। यह आधा हिस्सा try/except में भी था। बाकी आधा यह है कि जब कुछ भी बाहर न आए तो क्या हो। if amount <= 0 वाली जाँच फिर से हटाइए और वही फ़ाइल चलाइए:

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

DID NOT RAISE ValueError — यही वह वाक्य है जो try/except वाला संस्करण कभी नहीं कह सकता था। pytest.raises बस इतना ही है: अपेक्षित एरर को पकड़ना, और उसके न आने पर ज़ोर से फ़ेल होना।

कौन से exception मैच माने जाते हैं

pytest.raises(X) ठीक वैसे ही मिलान करता है जैसे except X: करता है: X खुद या उसकी कोई भी सबक्लास। पायथन के exceptions एक वंश-वृक्ष बनाते हैं, और उसकी एक शाखा देखी जा सकती है:

python
print(KeyError.__mro__)
print(issubclass(KeyError, LookupError))
print(issubclass(IndexError, LookupError))
text
(<class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
True
True

यानी KeyError एक LookupError है। यह काम की बात है, और यही जाल भी है। यहाँ दो टेस्ट हैं, दोनों पास होते हैं:

python
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
text
..                                                                       [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 को प्रति फ़्रेम एक लाइन तक छोटा कर देता है।)

python
import pytest

from wallet import withdraw


def test_overdraft_is_rejected():
    with pytest.raises(ValueError):
        withdraw(100.0, 150.0)
text
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.01s

DID NOT RAISE नहीं — कुछ raise हुआ है, बस वह नहीं जिसका आपने नाम लिया, और pytest दिखाता है कि वह कहाँ से आया।

match= — मैसेज की जाँच

टाइप बताता है कि इनकार किस तरह का था। मैसेज बताता है क्यों, और अक्सर उस ग़लत वैल्यू को भी साथ रखता है। match= इसे जाँचता है:

python
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)
text
.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 में ( ) एक ग्रुप बनाते हैं और कोई कोष्ठक मैच नहीं करते; . कोई भी अक्षर मैच करता है; +, *, ?, [, $ के भी अपने काम हैं।

. वाला मामला ज़्यादा चुपचाप है, क्योंकि वह ग़लती से पास होने की दिशा में चूकता है:

python
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"))
text
<re.Match object; span=(11, 17), match='100.00'>
<re.Match object; span=(11, 17), match='100500'>
None

"100.00" आराम से 100500 से मैच हो गया। re.escape हर ख़ास अक्षर को साधारण अक्षर में बदल देता है, इसलिए जब आपका मतलब "ठीक यही टेक्स्ट" हो, तो टेक्स्ट को इसमें से गुज़ारिए — ठीक जैसा pytest के संकेत ने सुझाया था:

python
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)
text
.                                                                        [100%]
1 passed in 0.01s

मैसेज का कितना हिस्सा मैच करें, यह विवेक की बात है। एक छोटा, अलग पहचान वाला टुकड़ा — वैल्यू, मुख्य शब्द — मामूली दोबारा-लेखन में भी टिका रहता है; पूरा वाक्य उसी पल टूट जाता है जब कोई एक टाइपो ठीक करता है।

excinfo — वह exception जो आपने पकड़ा

with pytest.raises(...) as excinfo आपको पकड़ी गई चीज़ का एक हैंडल देता है। pytest -q -s से एक बार अंदर झाँकिए (-s से print दिखता है):

python
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)
text
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.01s
  • excinfo.type वह क्लास है जो असल में raise हुई — काम आता है जब आपने पैरेंट पकड़ा हो और जानना हो कि कौन सी चाइल्ड आई।
  • excinfo.value खुद exception ऑब्जेक्ट है, अपने सारे attributes के साथ।
  • str(excinfo.value) मैसेज है — वही स्ट्रिंग जिसमें match= खोजता है।

print सिर्फ़ देखने के लिए थे। असली टेस्ट में आप assert करते हैं:

python
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"
text
.                                                                        [100%]
1 passed in 0.01s

जब exception डेटा को attributes के रूप में रखता है, तो मैसेज को पार्स करने के बजाय attributes पर assert करना ज़्यादा मज़बूत है। ध्यान दीजिए, assert with ब्लॉक के बाद हैं, अंदर नहीं। यह शैली की बात नहीं है; क्यों, यह अगले हिस्से में।

अंदर सिर्फ़ raise करने वाली लाइन

जिस पल exception raise होता है, पायथन ब्लॉक छोड़ देता है। raise करने वाली लाइन के बाद की हर लाइन छूट जाती है — assert भी:

python
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)
text
..                                                                       [100%]
2 passed in 0.01s

दोनों टेस्ट हरे हैं, और दोनों ग़लत हैं। पहले में assert balance == 999.0 एक झूठा दावा है जो कभी चला ही नहीं। दूसरे में float("ten") ने सेटअप वाली लाइन पर ही ValueError raise कर दिया, इसलिए withdraw कभी कॉल ही नहीं हुआ — withdraw कुछ जाँचे या न जाँचे, टेस्ट पास होता है।

नियम: सेटअप ब्लॉक से पहले, अंदर raise करने वाली एक कॉल, जाँच उसके बाद।

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

अब assert चलता है, और कुछ असली चीज़ जाँचता है: मना की गई निकासी ने balance को नहीं छुआ, क्योंकि नई वैल्यू कभी असाइन ही नहीं हुई।

एक से ज़्यादा स्वीकार्य exception

pytest.raises एक टपल भी लेता है, फिर से बिल्कुल except की तरह। सूची की कोई भी क्लास चलेगी, और excinfo.type बताता है कि कौन सी आई:

python
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__)
text
InsufficientFunds
.
1 passed in 0.01s

इसका इस्तेमाल तब कीजिए जब अनुबंध सच में "इनमें से कोई एक" हो — जैसे ऐसा कोड जो प्लेटफ़ॉर्म के हिसाब से जायज़ तौर पर कोई भी raise कर सकता है। अपने फ़ंक्शनों के लिए एक सटीक क्लास लगभग हमेशा बेहतर वादा है।

यह टेस्ट करना कि कुछ raise नहीं करता

कोई pytest.does_not_raise() मौजूद नहीं है, और उसकी ज़रूरत भी नहीं। कोड को कॉल कीजिए। अगर वह raise करता है, तो टेस्ट उसी exception और उसके traceback के साथ फ़ेल होता है:

python
from wallet import withdraw


def test_whole_balance_can_be_withdrawn():
    assert withdraw(100.0, 100.0) == 0.0
text
.                                                                        [100%]
1 passed in 0.01s

withdraw में amount > balance को amount >= balance कर दीजिए — एक-का-फ़र्क़ वाली वह ग़लती जो बहुत लोग करते हैं — और pytest -q --tb=short बताता है:

text
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 में एक बैच संस्करण जोड़िए जो हर निकासी आज़माता है और सारी विफलताएँ एक साथ बताता है:

python
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 से पूरे ग्रुप का वर्णन कीजिए:

python
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 से चलाइए:

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

python
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] += amount

test_bank.py:

python
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.0

pytest -v:

text
============================= 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) खोजिए, जो लगभग कुछ भी मान लेता है। फिर जानबूझकर कोड तोड़िए और पक्का कीजिए कि टेस्ट लाल हो जाता है।