অধ্যায় 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

যাচাই উধাও, অথচ টেস্ট এখনো পাস। কোনো ত্রুটি না উঠলে try ব্লকটি চুপচাপ শেষ হয়, except বাদ পড়ে, আর ফাংশনটি কোনো ব্যর্থ assert ছাড়াই শেষ হয়ে যায়। টেস্টটি কেবল এটুকুই যাচাই করেছিল যে যদি ValueError ঘটে, তবে সেটি উপেক্ষা করা হবে। ত্রুটিটি যে ঘটতেই হবে, সে কথা সে কখনো বলেনি।

এই অধ্যায় সেই কথাটি ঠিকভাবে বলার বিষয়ে — আর সেই কয়েকটি পথ নিয়ে, যেগুলো দিয়ে ত্রুটির একটি টেস্ট পাস করে যায় যখন তার পাস করার কথা নয়।

এই অধ্যায় শেষে আপনি পারবেন

  • pytest.raises দিয়ে টেস্ট করতে যে কোড ত্রুটি তোলে, আর DID NOT RAISE পড়ে বুঝতে
  • বলতে কেন pytest.raises(Exception) প্রায় সবসময় অতিরিক্ত চওড়া
  • match= দিয়ে বার্তা যাচাই করতে, জেনে যে এটি একটি regular expression
  • excinfo দিয়ে ধরা পড়া ত্রুটিটি খুঁটিয়ে দেখতে
  • with ব্লকের ভেতরে কেবল ত্রুটি তোলা লাইনটি রাখতে, আর তার কারণ বলতে
  • একটি ExceptionGroup চিনতে এবং তার ভেতরে কী আছে যাচাই করতে

আগে যা জানা লাগবে: টেস্ট খুঁজে পাওয়া ও চালানো।


টেস্ট লেখার আগে

ত্রুটির একটি টেস্ট ঠিক ততটাই ভালো, যতটা ভালো তার পেছনের সিদ্ধান্ত। pytest.raises টাইপ করার আগে কাগজে তিনটি জিনিস ঠিক করে নিন।

১. ঠিক কী প্রতিশ্রুতি দেওয়া হয়েছে? withdraw-কে কোড হিসেবে নয়, একটি চুক্তি হিসেবে পড়ুন:

  • ইনপুট: একটি ব্যালান্স আর একটি পরিমাণ। আউটপুট: নতুন ব্যালান্স, balance - amount।
  • প্রথম প্রত্যাখ্যান: শূন্য বা ঋণাত্মক পরিমাণ ValueError তোলে, আর বার্তায় পরিমাণটির উল্লেখ থাকে।
  • দ্বিতীয় প্রত্যাখ্যান: ব্যালান্সের চেয়ে বড় পরিমাণ InsufficientFunds তোলে, যা balance আর amount অ্যাট্রিবিউট হিসেবে বহন করে।
  • পার্শ্বপ্রতিক্রিয়া: নেই। withdraw নিজের বাইরে কিছুই বদলায় না, তাই "প্রত্যাখ্যানের পরে কিছু বদলায়নি" এখানে আপনাআপনি সত্য — শেষের ব্যাংকের উদাহরণে সেটি আর আপনাআপনি থাকবে না।

প্রতিটি প্রত্যাখ্যান একটি আলাদা প্রতিশ্রুতি, যার তিনটি অংশ যাচাই করা যায়: কোন ক্লাস, কী বার্তা, আর পেছনে কোন অবস্থা রয়ে গেল।

২. আগে থেকে কী প্রস্তুত থাকা চাই? গত অধ্যায়ের বাইরে কিছু নয়। 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')

৩. কোন কোন কেস? ত্রুটি থাকে সীমানায়, তাই পরিকল্পনাও সীমানা ঘিরে — সাধারণ কেস, প্রতিটি প্রত্যাখ্যানের দুই পাশের প্রান্ত, আর স্পষ্টভাবে অবৈধ ইনপুট:

| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | সাধারণ উত্তোলন | 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 — "এটিকে ত্রুটি তুলতেই হবে"

pytest.raises একটি context manager। with ব্লকের ভেতরের সবকিছুর কাছে প্রত্যাশা, নাম দেওয়া ত্রুটিটি তুলবে:

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 আসলে এটুকুই: প্রত্যাশিত ত্রুটিটি ধরা, আর সেটি না এলে জোরালোভাবে ফেল করা।

কোন কোন ত্রুটি মেলে বলে গণ্য

pytest.raises(X) ঠিক except X:-এর মতো মেলায়: X নিজে অথবা তার যেকোনো সাবক্লাস। পাইথনের ত্রুটিগুলো একটি বংশতালিকা গড়ে, আর তার একটি শাখা দেখা যায়:

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 তুলেছে, আর প্রতিটি সাধারণ ত্রুটি Exception-এর সাবক্লাস বলে pytest.raises(Exception) খুশিমনে সেটি মেনে নিয়েছে। একটি টাইপো, একটি ভুল import, একটি AttributeError — এর যেকোনোটিই এই টেস্টকে সবুজ করে দিত।

নিয়ম: কোড যে সবচেয়ে নির্দিষ্ট ত্রুটি তোলার প্রতিশ্রুতি দেয়, তার নাম লিখুন। ক্লাস যত চওড়া, তত বেশি অসম্পর্কিত ব্যর্থতা সে লুকিয়ে ফেলে।

উল্টো দিকটি কড়া। যে ত্রুটি নাম দেওয়া ক্লাস বা তার সাবক্লাস নয়, সেটি ধরা পড়ে না; সে ব্লক থেকে বেরিয়ে যায় আর সাধারণ ত্রুটি হিসেবেই টেস্ট ফেল করায়। (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 নয় — কিছু একটা ওঠানো হয়েছে, শুধু আপনি যার নাম দিয়েছিলেন সেটি নয়, আর 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 — যে ত্রুটিটি আপনি ধরলেন

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 হলো আসলে যে ক্লাসটি ওঠানো হয়েছে — প্যারেন্ট ধরে থাকলে কোন সন্তানটি এল তা জানতে কাজে লাগে।
  • excinfo.value হলো ত্রুটি অবজেক্টটি নিজে, তার সব অ্যাট্রিবিউটসহ।
  • 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

ত্রুটি যখন ডেটা অ্যাট্রিবিউট হিসেবে বহন করে, তখন বার্তা বিশ্লেষণের চেয়ে অ্যাট্রিবিউটের ওপর assert করা বেশি মজবুত। লক্ষ করুন, assert-গুলো with ব্লকের পরে, ভেতরে নয়। এটি রুচির ব্যাপার নয়; কেন, তা পরের অংশে।

ভেতরে থাকবে কেবল ত্রুটি তোলা লাইনটি

যে মুহূর্তে ত্রুটি ওঠে, পাইথন ব্লক ছেড়ে বেরিয়ে যায়। ত্রুটি তোলা লাইনের পরের প্রতিটি লাইন বাদ পড়ে — 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 তুলেছে, তাই withdraw ডাকাই হয়নি — withdraw কিছু যাচাই করুক বা না করুক, টেস্ট পাস করে।

নিয়ম: প্রস্তুতি ব্লকের আগে, ভেতরে ত্রুটি তোলা একটিমাত্র কল, যাচাই তার পরে।

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-কে অক্ষত রেখেছে, কারণ নতুন মান বসানো কখনো ঘটেনি।

একাধিক গ্রহণযোগ্য ত্রুটি

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

এটি ব্যবহার করুন যখন চুক্তিটি সত্যিই "এদের যেকোনো একটি" — যেমন এমন কোড, যা প্ল্যাটফর্মভেদে বৈধভাবেই যেকোনোটি তুলতে পারে। নিজের ফাংশনের ক্ষেত্রে একটি নির্দিষ্ট ক্লাসই প্রায় সবসময় ভালো প্রতিশ্রুতি।

কিছু যে ত্রুটি তোলে না, তার টেস্ট

হাতের কাছে কোনো pytest.does_not_raise() নেই, আর তার দরকারও নেই। কোডটি ডাকুন। ত্রুটি উঠলে টেস্ট সেই ত্রুটি আর তার 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

অপ্রত্যাশিত ত্রুটি নিজেই একটি ব্যর্থতা। কলটিকে try/except: pytest.fail() দিয়ে মুড়ে দিলে কেবল ওই traceback-টি হারিয়ে যায়।

একসাথে কয়েকটি ত্রুটি: ExceptionGroup

পাইথন 3.11 থেকে কোড কয়েকটি ত্রুটি একসাথে ExceptionGroup হিসেবে তুলতে পারে। 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

একটি গ্রুপ নিজেই একটি আলাদা ত্রুটি-ক্লাস। 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 বেশি কড়া: গ্রুপে ঠিক তালিকাভুক্ত ত্রুটিগুলোই থাকতে হবে — বেশিও নয়, কমও নয়, যেকোনো ক্রমে। দুটোই pytest 8-এ এসেছে এবং pytest 9-এ আছে। এগুলো খুব বেশি লাগবে না; লাগবে সেদিন, যেদিন কোনো লাইব্রেরি আপনার হাতে একটি গ্রুপ ধরিয়ে দেবে।


একটা সম্পূর্ণ উদাহরণ

তিনটি প্রত্যাখ্যানসহ একটি ছোট ব্যাংক: অজানা অ্যাকাউন্ট, ধনাত্মক নয় এমন পরিমাণ, আর ওভারড্রাফট। পরিমাণের যাচাই ঠিক withdraw-এর মতোই কাজ করে, তাই নিচের টেস্টগুলো বাকি দুটির পেছনে মনোযোগ দেয়। AccountNotFound ইচ্ছে করেই LookupError-এর সাবক্লাস, যাতে যিনি ডাকেন তিনি এটিকে অভিধানে না-থাকা কী-র মতো করেই ধরতে পারেন।

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 সবচেয়ে মূল্যবান: Carol যে নেই তা আবিষ্কারের আগেই যদি transfer Alice-এর টাকা কেটে নিত, তবু সে AccountNotFound তুলত — আর ব্লকের পরের assert আধখেঁচড়া স্থানান্তরটি assert 70.0 == 100.0 দিয়ে ধরে ফেলত।

দ্বিতীয়ত, test_unknown_account_is_a_lookup_error জেনেশুনে প্যারেন্ট ক্লাস ধরে, কারণ bank.py এই প্রতিশ্রুতিই দেয়, আর তারপর excinfo.type দিয়ে নিশ্চিত করে কোন সন্তানটি এল।

তৃতীয়ত, match="'carol'" যাচাই করে যে বার্তায় সেই অ্যাকাউন্টের নাম আছে যা চাওয়া হয়েছিল — ত্রুটি পড়া মানুষটির কাছে এটিই সবচেয়ে দরকারি তথ্য।


কিছু ভাঙা অবস্থা ও তার সমাধান

Failed: DID NOT RAISE ValueError ব্লকটি কোনো ত্রুটি না তুলেই শেষ হয়েছে। হয় কোড সত্যিই তার যাচাই হারিয়েছে (টেস্ট তার কাজ করেছে), নয়তো ব্লকের ভেতরের কলটি আপনি যা ভাবছেন তা নয় — ভুল আর্গুমেন্ট যা ঘটনাচক্রে বৈধ, বা এমন ফাংশন যা ত্রুটি না তুলে একটি ত্রুটি-মান ফেরত দেয়।

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।

টেস্ট অন্য কোনো ত্রুটি দিয়ে ফেল করে, যেমন wallet.InsufficientFunds: cannot withdraw 150.00; balance is 100.00 একটি ত্রুটি উঠেছে, কিন্তু নাম দেওয়া ক্লাস বা তার সাবক্লাস নয়, তাই pytest.raises সেটিকে যেতে দিয়েছে। ঠিক করুন কোডের কোনটি তোলার কথা, তারপর টেস্ট বা কোড — যেটি ভুল, সেটি ঠিক করুন।

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 তুলেছে যার ভেতরে একটি ValueError আছে। গ্রুপ নিজে ValueError নয়; pytest.raises(ExceptionGroup)-এর সাথে excinfo.group_contains(ValueError) ব্যবহার করুন, অথবা pytest.RaisesGroup(ValueError)।

টেস্ট পাস করে, অথচ আপনি নিশ্চিত যে করার কথা নয় with ব্লকের ভেতরে ত্রুটি তোলা লাইনের পরের লাইন খুঁজুন — সেগুলো কখনো চলে না — আর খুঁজুন pytest.raises(Exception), যা প্রায় সবকিছুই মেনে নেয়। তারপর ইচ্ছে করে কোড ভেঙে নিশ্চিত হোন যে টেস্ট লাল হয়।