ত্রুটির টেস্ট — 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যাচাই উধাও, অথচ টেস্ট এখনো পাস। কোনো ত্রুটি না উঠলে try ব্লকটি চুপচাপ শেষ হয়, except বাদ পড়ে, আর ফাংশনটি কোনো ব্যর্থ assert ছাড়াই শেষ হয়ে যায়। টেস্টটি কেবল এটুকুই যাচাই করেছিল যে যদি ValueError ঘটে, তবে সেটি উপেক্ষা করা হবে। ত্রুটিটি যে ঘটতেই হবে, সে কথা সে কখনো বলেনি।
এই অধ্যায় সেই কথাটি ঠিকভাবে বলার বিষয়ে — আর সেই কয়েকটি পথ নিয়ে, যেগুলো দিয়ে ত্রুটির একটি টেস্ট পাস করে যায় যখন তার পাস করার কথা নয়।
এই অধ্যায় শেষে আপনি পারবেন
pytest.raisesদিয়ে টেস্ট করতে যে কোড ত্রুটি তোলে, আরDID NOT RAISEপড়ে বুঝতে- বলতে কেন
pytest.raises(Exception)প্রায় সবসময় অতিরিক্ত চওড়া match=দিয়ে বার্তা যাচাই করতে, জেনে যে এটি একটি regular expressionexcinfoদিয়ে ধরা পড়া ত্রুটিটি খুঁটিয়ে দেখতেwithব্লকের ভেতরে কেবল ত্রুটি তোলা লাইনটি রাখতে, আর তার কারণ বলতে- একটি
ExceptionGroupচিনতে এবং তার ভেতরে কী আছে যাচাই করতে
আগে যা জানা লাগবে: টেস্ট খুঁজে পাওয়া ও চালানো।
টেস্ট লেখার আগে
ত্রুটির একটি টেস্ট ঠিক ততটাই ভালো, যতটা ভালো তার পেছনের সিদ্ধান্ত। pytest.raises টাইপ করার আগে কাগজে তিনটি জিনিস ঠিক করে নিন।
১. ঠিক কী প্রতিশ্রুতি দেওয়া হয়েছে? withdraw-কে কোড হিসেবে নয়, একটি চুক্তি হিসেবে পড়ুন:
- ইনপুট: একটি ব্যালান্স আর একটি পরিমাণ। আউটপুট: নতুন ব্যালান্স,
balance - amount। - প্রথম প্রত্যাখ্যান: শূন্য বা ঋণাত্মক পরিমাণ
ValueErrorতোলে, আর বার্তায় পরিমাণটির উল্লেখ থাকে। - দ্বিতীয় প্রত্যাখ্যান: ব্যালান্সের চেয়ে বড় পরিমাণ
InsufficientFundsতোলে, যাbalanceআরamountঅ্যাট্রিবিউট হিসেবে বহন করে। - পার্শ্বপ্রতিক্রিয়া: নেই।
withdrawনিজের বাইরে কিছুই বদলায় না, তাই "প্রত্যাখ্যানের পরে কিছু বদলায়নি" এখানে আপনাআপনি সত্য — শেষের ব্যাংকের উদাহরণে সেটি আর আপনাআপনি থাকবে না।
প্রতিটি প্রত্যাখ্যান একটি আলাদা প্রতিশ্রুতি, যার তিনটি অংশ যাচাই করা যায়: কোন ক্লাস, কী বার্তা, আর পেছনে কোন অবস্থা রয়ে গেল।
২. আগে থেকে কী প্রস্তুত থাকা চাই? গত অধ্যায়ের বাইরে কিছু নয়। 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')৩. কোন কোন কেস? ত্রুটি থাকে সীমানায়, তাই পরিকল্পনাও সীমানা ঘিরে — সাধারণ কেস, প্রতিটি প্রত্যাখ্যানের দুই পাশের প্রান্ত, আর স্পষ্টভাবে অবৈধ ইনপুট:
| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | সাধারণ উত্তোলন | 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 ব্লকের ভেতরের সবকিছুর কাছে প্রত্যাশা, নাম দেওয়া ত্রুটিটি তুলবে:
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 আসলে এটুকুই: প্রত্যাশিত ত্রুটিটি ধরা, আর সেটি না এলে জোরালোভাবে ফেল করা।
কোন কোন ত্রুটি মেলে বলে গণ্য
pytest.raises(X) ঠিক except X:-এর মতো মেলায়: X নিজে অথবা তার যেকোনো সাবক্লাস। পাইথনের ত্রুটিগুলো একটি বংশতালিকা গড়ে, আর তার একটি শাখা দেখা যায়:
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 তুলেছে, আর প্রতিটি সাধারণ ত্রুটি Exception-এর সাবক্লাস বলে pytest.raises(Exception) খুশিমনে সেটি মেনে নিয়েছে। একটি টাইপো, একটি ভুল import, একটি AttributeError — এর যেকোনোটিই এই টেস্টকে সবুজ করে দিত।
নিয়ম: কোড যে সবচেয়ে নির্দিষ্ট ত্রুটি তোলার প্রতিশ্রুতি দেয়, তার নাম লিখুন। ক্লাস যত চওড়া, তত বেশি অসম্পর্কিত ব্যর্থতা সে লুকিয়ে ফেলে।
উল্টো দিকটি কড়া। যে ত্রুটি নাম দেওয়া ক্লাস বা তার সাবক্লাস নয়, সেটি ধরা পড়ে না; সে ব্লক থেকে বেরিয়ে যায় আর সাধারণ ত্রুটি হিসেবেই টেস্ট ফেল করায়। (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 নয় — কিছু একটা ওঠানো হয়েছে, শুধু আপনি যার নাম দিয়েছিলেন সেটি নয়, আর 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 — যে ত্রুটিটি আপনি ধরলেন
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হলো আসলে যে ক্লাসটি ওঠানো হয়েছে — প্যারেন্ট ধরে থাকলে কোন সন্তানটি এল তা জানতে কাজে লাগে।excinfo.valueহলো ত্রুটি অবজেক্টটি নিজে, তার সব অ্যাট্রিবিউটসহ।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ত্রুটি যখন ডেটা অ্যাট্রিবিউট হিসেবে বহন করে, তখন বার্তা বিশ্লেষণের চেয়ে অ্যাট্রিবিউটের ওপর assert করা বেশি মজবুত। লক্ষ করুন, assert-গুলো with ব্লকের পরে, ভেতরে নয়। এটি রুচির ব্যাপার নয়; কেন, তা পরের অংশে।
ভেতরে থাকবে কেবল ত্রুটি তোলা লাইনটি
যে মুহূর্তে ত্রুটি ওঠে, পাইথন ব্লক ছেড়ে বেরিয়ে যায়। ত্রুটি তোলা লাইনের পরের প্রতিটি লাইন বাদ পড়ে — 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 তুলেছে, তাই withdraw ডাকাই হয়নি — withdraw কিছু যাচাই করুক বা না করুক, টেস্ট পাস করে।
নিয়ম: প্রস্তুতি ব্লকের আগে, ভেতরে ত্রুটি তোলা একটিমাত্র কল, যাচাই তার পরে।
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-কে অক্ষত রেখেছে, কারণ নতুন মান বসানো কখনো ঘটেনি।
একাধিক গ্রহণযোগ্য ত্রুটি
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এটি ব্যবহার করুন যখন চুক্তিটি সত্যিই "এদের যেকোনো একটি" — যেমন এমন কোড, যা প্ল্যাটফর্মভেদে বৈধভাবেই যেকোনোটি তুলতে পারে। নিজের ফাংশনের ক্ষেত্রে একটি নির্দিষ্ট ক্লাসই প্রায় সবসময় ভালো প্রতিশ্রুতি।
কিছু যে ত্রুটি তোলে না, তার টেস্ট
হাতের কাছে কোনো pytest.does_not_raise() নেই, আর তার দরকারও নেই। কোডটি ডাকুন। ত্রুটি উঠলে টেস্ট সেই ত্রুটি আর তার 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অপ্রত্যাশিত ত্রুটি নিজেই একটি ব্যর্থতা। কলটিকে try/except: pytest.fail() দিয়ে মুড়ে দিলে কেবল ওই traceback-টি হারিয়ে যায়।
একসাথে কয়েকটি ত্রুটি: ExceptionGroup
পাইথন 3.11 থেকে কোড কয়েকটি ত্রুটি একসাথে ExceptionGroup হিসেবে তুলতে পারে। 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একটি গ্রুপ নিজেই একটি আলাদা ত্রুটি-ক্লাস। 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 বেশি কড়া: গ্রুপে ঠিক তালিকাভুক্ত ত্রুটিগুলোই থাকতে হবে — বেশিও নয়, কমও নয়, যেকোনো ক্রমে। দুটোই pytest 8-এ এসেছে এবং pytest 9-এ আছে। এগুলো খুব বেশি লাগবে না; লাগবে সেদিন, যেদিন কোনো লাইব্রেরি আপনার হাতে একটি গ্রুপ ধরিয়ে দেবে।
একটা সম্পূর্ণ উদাহরণ
তিনটি প্রত্যাখ্যানসহ একটি ছোট ব্যাংক: অজানা অ্যাকাউন্ট, ধনাত্মক নয় এমন পরিমাণ, আর ওভারড্রাফট। পরিমাণের যাচাই ঠিক withdraw-এর মতোই কাজ করে, তাই নিচের টেস্টগুলো বাকি দুটির পেছনে মনোযোগ দেয়। AccountNotFound ইচ্ছে করেই LookupError-এর সাবক্লাস, যাতে যিনি ডাকেন তিনি এটিকে অভিধানে না-থাকা কী-র মতো করেই ধরতে পারেন।
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 সবচেয়ে মূল্যবান: 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), যা প্রায় সবকিছুই মেনে নেয়। তারপর ইচ্ছে করে কোড ভেঙে নিশ্চিত হোন যে টেস্ট লাল হয়।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
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-টি ব্লকের ভেতরে ত্রুটি তোলা লাইনের পরে আছে, তাই সেটি কখনো চলে না
- C`with` স্টেটমেন্টে `excinfo` নাম দেওয়া যায় না
- Dassert থেকে `AssertionError` হওয়ায় `pytest.raises` টেস্টটিকে ফেল করায়
withdraw(100.0, -5.0) ঠিক এই বার্তাসহ ValueError তোলে: 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, আর দুটি ত্রুটি-ক্লাস:
UnknownItem,KeyError-এর সাবক্লাস, যা ওঠে যখনitemstock-এ নেই, আর পণ্যের নামটি অ্যাট্রিবিউট হিসেবে বহন করেOutOfStock, যা ওঠে যখনquantityঅবশিষ্টের চেয়ে বেশি, আরavailableওrequestedঅ্যাট্রিবিউট বহন করেquantityধনাত্মক না হলে একটি সাধারণValueError, যার বার্তায় মানটি থাকবে
ফাংশনটি অবশিষ্ট পরিমাণ ফেরত দেয়, আর কেবল সফল হলেই অভিধানটি বদলায়।
তারপর অন্তত ছয়টি টেস্টসহ test_inventory.py লিখুন:
- একটি সফল বাদ দেওয়া — কোনো
pytest.raisesনয়, শুধু ডাকুন আর ফলাফল assert করুন - একটি অজানা পণ্য,
KeyErrorহিসেবে ধরা, আরexcinfo.typeদেখাবে যে সেটিUnknownItem - শূন্য পরিমাণ,
match=দিয়ে যাচাই যে বার্তায় মানটি আছে — বার্তায় বন্ধনী বা বিন্দু থাকলেre.escapeব্যবহার করুন - অতিরিক্ত উত্তোলন,
excinfo.value.availableআরexcinfo.value.requested-এর ওপর assert - একই অতিরিক্ত উত্তোলন, ব্লকের পরে assert যে অভিধানটি অপরিবর্তিত
- ঠিক যতটুকু আছে ততটুকুই বাদ দেওয়া, যা কোনো ত্রুটি তুলবে না
তারপর কোডটি তিনবার ভাঙুন, একবারে একটি, আর প্রতিটি ব্যর্থতা পড়ুন: শূন্য-পরিমাণের যাচাই মুছে দিন (DID NOT RAISE খুঁজুন), স্টক যাচাইয়ে > বদলে >= করুন, আর শেষে আপনার একটি assert একটি with ব্লকের ভেতরে, ত্রুটি তোলা লাইনের নিচে সরিয়ে তাতে একটি মিথ্যা মান দিন। প্রথম দুটি লাল হওয়ার কথা। তৃতীয়টি সবুজই থাকবে — আর এই অধ্যায়ের মনে রাখার মতো শিক্ষা সেটিই।
সমাধান
অধ্যায়ের বাকি অংশের মতোই পরিকল্পনা থেকে শুরু করুন: একটি সাধারণ কেস, যে সীমানাটি অনুমোদিত হতেই হবে (সবটুকু বাদ দেওয়া), আর প্রতিটি প্রত্যাখ্যানের জন্য ক্লাস, বার্তা বা অ্যাট্রিবিউট, এবং পেছনে রয়ে যাওয়া অবস্থার যাচাই।
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-টি ব্লকের ভেতরে, ত্রুটি তোলা লাইনের নিচে, assert stock == {"pen": 999} হিসেবে সরিয়ে:
...... [100%]
6 passed in 0.01sপ্রতিটি সিদ্ধান্তের কারণ:
UnknownItemহলোKeyError-এর সাবক্লাস। না-থাকা পণ্য আসলেই না-থাকা একটি কী, তাই আগে থেকেKeyErrorধরে এমন কোড কাজ করতে থাকে। টেস্টKeyErrorধরে — প্রকাশ্য প্রতিশ্রুতিটি — তারপরexcinfo.typeদিয়ে নির্দিষ্ট ক্লাসটি নিশ্চিত করে।- পরিমাণের যাচাই সবার আগে। এর জন্য
stockথেকে কিছু লাগে না, তাই এটি দ্রুত ব্যর্থ হয়, আর বার্তাটি সেই আর্গুমেন্ট নিয়ে যেটি ডাকার সময় ভুল দেওয়া হয়েছে। re.escape("(got 0)")। টুকরোটি মান বহন করে, ত্রুটি পড়া মানুষটির ঠিক যা দরকার, আরre.escapeবন্ধনীগুলোকে আক্ষরিক রাখে। পুরো বাক্য মেলালে যেদিন কেউ শব্দ বদলাবে সেদিনই ভাঙত।- বার্তা বিশ্লেষণের বদলে অ্যাট্রিবিউট।
availableআরrequestedএমন সংখ্যা যা সরাসরি তুলনা করা যায়; স্ট্রিং থেকে সেগুলো বের করলে ডেটা নয়, ফরম্যাটিং টেস্ট হতো। - অতিরিক্ত উত্তোলনের জন্য দুটি টেস্ট। একটি যাচাই করে ত্রুটি কী বলে, অন্যটি কোড পেছনে কী রেখে গেল। একটি ফেল করলে তার নামই বলে দেয় কোন প্রতিশ্রুতি ভেঙেছে।
- সীমানার টেস্টটি
>=বাগ ধরেছে। ত্রুটির টেস্টগুলোর কোনোটিই তা পারত না: সবগুলো তখনো ত্রুটি তুলছিল। কেবল যে টেস্ট প্রান্তে কোনো ত্রুটি আশা করে না, সেটিই টের পেয়েছে। - তৃতীয় ভাঙনটি সবুজ রয়ে গেছে। এটি অধ্যায়ের আগের সেই ত্রুটির-পরের-লাইনের ফাঁদ: মিথ্যা assert-টি কখনো চলেনি। একমাত্র রক্ষাকবচ অভ্যাস — ব্লকের ভেতরে একটি কল, যাচাই তার পরে।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন