assert আর ব্যর্থতার রিপোর্ট পড়া
pytest কীভাবে একটি সাধারণ assert-এর ভেতরের মান দেখায়, ব্যর্থতার রিপোর্ট লাইন ধরে ধরে কীভাবে পড়তে হয়, আর কোন তুলনাটি লিখলে রিপোর্ট সবচেয়ে বেশি কথা বলে।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
সাধারণ পাইথনে লেখা একটি যাচাই, check.py নামের একটি ফাইলে:
def full_name(first, last):
return f"{first} {last}".title()
assert full_name("ada", "lovelace") == "Ada Lovelace "Traceback (most recent call last):
File "/home/you/shop/check.py", line 5, in <module>
assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorপাইথন কেবল জানায় যে দাবিটি মিথ্যা ছিল, এর বেশি কিছু নয়। full_name আসলে কী ফিরিয়ে দিয়েছিল? জানতে হলে একটি print যোগ করতে হতো, আবার চালাতে হতো, তারপর দেখতে হতো।
একই লাইনটি একটি test-এ বসান, test_names.py-তে, আর pytest -q চালান:
from names import full_name
def test_full_name():
assert full_name("ada", "lovelace") == "Ada Lovelace "F [100%]
=================================== FAILURES ===================================
________________________________ test_full_name ________________________________
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
E AssertionError: assert 'Ada Lovelace' == 'Ada Lovelace '
E
E - Ada Lovelace
E ? -
E + Ada Lovelace
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionError: assert 'Ada Lovelace' ...
1 failed in 0.01sএখন দুটি মানই দেখা যাচ্ছে, আর যে একটিমাত্র অক্ষরে পার্থক্য, তার নিচে একটি চিহ্ন: প্রত্যাশিত string-এর শেষে একটি বাড়তি স্পেস। ফাংশনটি ঠিক ছিল; ভুল ছিল test-টি।
একটি ব্যর্থ test ততটুকুই কাজের, যতটুকু সে আপনাকে জানায়। এই অধ্যায় pytest যা যা জানায় তার সবটা পড়া নিয়ে, আর এমন assert লেখা নিয়ে, যাতে pytest-এর বলার মতো কিছু থাকে।
এই অধ্যায় শেষে আপনি পারবেন
- assertion rewriting কী, আর কোথায় তা খাটে ও কোথায় খাটে না, তা ব্যাখ্যা করতে
- একটি failure report লাইন ধরে ধরে পড়তে:
>,E,where,file:lineআর সংক্ষিপ্ত সারাংশ ==,in,is None,is Trueআর খালিassert x-এর মধ্যে বেছে নিতে- list, dict আর string-এর diff পড়তে, আর
-vvদিয়ে পুরো diff চাইতে - একটি assert-এ বার্তা যোগ করতে, আর সবসময় সত্য হওয়া tuple-টি এড়াতে
--tb=short,--tb=line,--tb=noআর-lদিয়ে report-এর আকার ঠিক করতে
আগে যা জানা লাগবে: pytest ইনস্টল করা ও প্রথম test।
টেস্ট লেখার আগে
একটি assert হলো লিখে রাখা একটি প্রতিশ্রুতি। একটি লেখার আগে তিনটি প্রশ্নের উত্তর দরকার, আর তার কোনোটিতেই pytest জড়িত নয়।
ঠিক কী প্রতিশ্রুতি দেওয়া হচ্ছে? এই অধ্যায় যে ফাংশন দিয়ে শেষ হয়, সেটি ধরুন: summarise(lines), যা "pen, 12, 15.0"-এর মতো ইনভয়েসের লাইন পড়ে। এক বাক্যে তার চুক্তি: ফাঁকা লাইন বাদ দেয়, আর একটি dict ফেরত দেয় যাতে থাকে জিনিসের সংখ্যা, দেওয়া ক্রমে তাদের নাম, আর দুই দশমিক ঘর পর্যন্ত রাউন্ড করা মোট। পরে আপনি যত assert লিখবেন, প্রতিটি ওই বাক্যের একটি অংশ। বাক্যটি বলতে না পারলে আপনি test লেখার জন্য তৈরি নন — শেষে কোডটি ঘটনাচক্রে যা করে, সেটাকেই test করে বসবেন।
কী কী তৈরি থাকতে হবে? এই অধ্যায়ের জন্য খুব সামান্য: প্রথম অধ্যায়ের virtual environment, যাতে pytest ইনস্টল করা (python -m pytest --version উত্তর দেয়), আর যে মডিউল test করা হচ্ছে, সেটি pytest যেখান থেকে চালাচ্ছেন সেখান থেকে import করা যায় — এখানে invoice.py থাকে test_invoice.py-এর পাশেই, আর আপনি সেই ফোল্ডার থেকেই pytest চালান। কোনো ফাইল নেই, নেটওয়ার্ক নেই, fixture নেই।
কোন কোন কেস, আর প্রতিটি কী আশা করে? প্রত্যাশিত মানগুলো হাতে কষে বের করুন, কোড চালানোর আগে। 12 × 15.0 + 2 × 850.0 হলো 1880.0; সংখ্যাটি আসে পাটিগণিত থেকে, summarise কী ছাপাল তা থেকে নয়। এর বদলে আউটপুট কপি করে test-এ বসালে test-টি আজকের আচরণই রেকর্ড করে, বাগসহ, আর তখন থেকে সেই বাগটিকেই রক্ষা করে যাবে।
| কেস | ইনপুট | প্রত্যাশিত | যে assert লিখবেন | | --- | --- | --- | --- | | একটি লাইন, নামের চারপাশে স্পেস | " pen , 12, 15.0" | {"name": "pen", "qty": 12, "price": 15.0} | পুরো dict-এর উপর == | | ফাঁকা লাইন বাদ যায় | ["pen, 12, 15.0", "", "bag, 2, 850.0"] | সংখ্যা 2 | == | | পুরো সারাংশ | একই তিনটি লাইন | {"count": 2, "names": ["pen", "bag"], "total": 1880.0} | পুরো dict-এর উপর == | | আছে এমন একটি জিনিস খোঁজা | "pen" | একটি রেকর্ড, None নয় | is not None | | নেই এমন একটি জিনিস খোঁজা | খালি list, "pen" | None | is None | | মোটটি অর্থপূর্ণ | একই তিনটি লাইন | 0-এর চেয়ে বড় | বার্তাসহ > |
শেষ কলামটিই এই অধ্যায়ের বিষয়: প্রতিটি প্রতিশ্রুতির জন্য সেই তুলনা, যা তাকে সবচেয়ে নিখুঁতভাবে বলে আর ব্যর্থ হলে সবচেয়ে কাজের report দেয়।
কী test করবেন না। str.split ভাগ করে কি না, round রাউন্ড করে কি না, int("12") হলো 12 কি না — এগুলো পাইথন নিজেই test করে। pytest-এর আউটপুটের হুবহু শব্দও আপনার test করার বিষয় নয়। "pen, twelve, 15.0"-এর মতো একটি বিকৃত লাইন পরিকল্পনায় থাকা উচিত, কিন্তু একটি error উঠছে কি না তা assert করতে লাগে pytest.raises, যা চতুর্থ অধ্যায়ের বিষয়; সারিটি এখনই লিখে রাখুন, test-টি তখন।
pytest কীভাবে একটি assert-এর ভেতরে দেখে
একটি assert ব্যর্থ হওয়ার মুহূর্তেই সাধারণ পাইথন মানগুলো ফেলে দেয়। pytest সেগুলো রেখে দেয়, কারণ চালানোর আগে সে আপনার test ফাইলটি বদলে নেয়।
pytest যখন একটি test মডিউল import করে, তখন সে প্রতিটি assert স্টেটমেন্ট খুঁজে বের করে আর সেটিকে এমন কোডে নতুন করে লেখে, যা শর্তটি যাচাইয়ের আগে প্রতিটি মধ্যবর্তী মান জমা রাখে — full_name(...)-এর ফল, ডান দিকের string। যাচাই ব্যর্থ হলে সেই জমানো মানগুলোই হয়ে যায় report। এটাই assertion rewriting, আর এই কারণেই pytest-এর কোনো assertEqual বা assertIn লাগে না: সাধারণ একটি == সহ সাধারণ একটি assert-ই যথেষ্ট।
এটি আপনার জন্য কী করে, তা দেখতে এটি বন্ধ করে দিন। একই test-এ pytest -q --assert=plain:
def test_full_name():
> assert full_name("ada", "lovelace") == "Ada Lovelace "
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError
test_names.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_names.py::test_full_name - AssertionErrorআবার সেই একটিমাত্র শব্দ। Rewriting খাটে test ফাইলে (test_*.py, *_test.py) আর conftest.py-তে। আপনার অন্য মডিউলগুলোতে এটি খাটে না, আর কোনো helper-এ একটি assert বসানো মাত্রই সেটি গুরুত্বপূর্ণ হয়ে ওঠে — "যখন ভেঙে যায়" অংশে দেখানো হয়েছে সেটি দেখতে কেমন।
একটি failure report, লাইন ধরে ধরে পড়া
একটি ছোট মডিউল, cart.py:
def subtotal(items):
return sum(price * qty for price, qty in items)
def total(items, discount):
return subtotal(items) - discountআর test_cart.py, যেখানে দ্বিতীয় test-টি ভুল সংখ্যা আশা করে:
from cart import subtotal, total
def test_subtotal():
assert subtotal([(15.0, 2), (60.0, 1)]) == 90.0
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
assert total(items, 10) == 70.0শুধু pytest চালান:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 2 items
test_cart.py .F [100%]
=================================== FAILURES ===================================
___________________________ test_total_with_discount ___________________________
def test_total_with_discount():
items = [(15.0, 2), (60.0, 1)]
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
========================= 1 failed, 1 passed in 0.01s ==========================উপর থেকে পড়ুন:
test_cart.py .F— প্রতিটি test-এর জন্য একটি অক্ষর, ক্রম অনুযায়ী।.পাস করেছে,Fব্যর্থ হয়েছে।____ test_total_with_discount ____— একটি ব্যর্থতার শিরোনাম। প্রতিটি ব্যর্থ test নিজের একটি অংশ পায়।- সোর্সের লাইনগুলো — ব্যর্থ লাইনটি পর্যন্ত test-এর বডি।
>— ঠিক যে লাইনটি exception তুলেছে। এর উপরের সবকিছু কোনো আপত্তি ছাড়াই চলেছে।E assert 80.0 == 70.0— আবার সেই assert, যেখানে প্রতিটি expression-এর জায়গায় তার মান বসানো। বাঁ দিক ছিল80.0, ডান দিক70.0।E + where 80.0 = total(...)— একটি মান কোথা থেকে এল। ফাংশন কলের ক্ষেত্রে pytest কলটি দেখায়, আর্গুমেন্টগুলো বসিয়ে। নেস্টেড কল থেকে আসে নেস্টেডwhereলাইন, প্রতিটি আরও ভেতরে সরানো।test_cart.py:10: AssertionError— ফাইল আর লাইন, এডিটরে সরাসরি সেখানে যাওয়ার জন্য তৈরি, সাথে exception-এর ধরন।short test summary info— প্রতিটি ব্যর্থতার জন্য একটি লাইন,FAILED path::test_name - error-এর প্রথম লাইন। ব্যর্থতা অনেক হলে আগে এই তালিকাটি পড়ুন।- শেষ লাইন — সংখ্যাগুলো আর সময়।
এই ক্রমে কেন পড়বেন? কারণ প্রতিটি লাইন খোঁজের পরিসর ছোট করে আনে। সারাংশ বলে কোন test; > লাইন বলে কোন দাবি; E লাইন বলে কোন দিকটা গড়বড়; where লাইন বলে কোন কল সেটি তৈরি করেছে। কেবল তারপরেই আপনি কোড খোলেন — আর খোলেন ঠিক ফাংশনটিতেই।
কখনো প্রথম E লাইনটি পড়ায় AssertionError: assert ..., আবার কখনো শুধু assert ...। ব্যর্থতা একই; মানগুলোর ভেতরে কোটেশন চিহ্ন থাকলে prefix-টি থেকে যায়।
যে তুলনাগুলো আপনি সবচেয়ে বেশি লিখবেন
assert-এর পরে যেকোনো expression বসতে পারে, আর সাধারণগুলো pytest ব্যাখ্যা করে দেয়। users.py:
def find_user(users, email):
for user in users:
if user["email"] == email:
return user
return None
def active_emails(users):
return [u["email"] for u in users if u["active"]]চারটি test, চার রকম তুলনা:
from users import active_emails, find_user
ADA = {"email": "ada@example.com", "active": True}
ALAN = {"email": "alan@example.com", "active": False}
def test_equal():
assert active_emails([ADA, ALAN]) == ["ada@example.com", "alan@example.com"]
def test_in():
assert "alan@example.com" in active_emails([ADA, ALAN])
def test_is_not_none():
assert find_user([ADA], "ADA@example.com") is not None
def test_greater():
assert len(active_emails([ADA, ALAN])) > 1চারটিই ব্যর্থ হয়। একটি pytest -q রান থেকে কেটে নেওয়া প্রতিটির > আর E লাইন:
> assert active_emails([ADA, ALAN]) == ["ada@example.com", "alan@example.com"]
E AssertionError: assert ['ada@example.com'] == ['ada@example...@example.com']
E
E Right contains one more item: 'alan@example.com'
E Use -v to get more diff
> assert "alan@example.com" in active_emails([ADA, ALAN])
E AssertionError: assert 'alan@example.com' in ['ada@example.com']
E + where ['ada@example.com'] = active_emails([{'email': 'ada@example.com', 'active': True}, {'email': 'alan@example.com', 'active': False}])
> assert find_user([ADA], "ADA@example.com") is not None
E AssertionError: assert None is not None
E + where None = find_user([{'email': 'ada@example.com', 'active': True}], 'ADA@example.com')
> assert len(active_emails([ADA, ALAN])) > 1
E AssertionError: assert 1 > 1
E + where 1 = len(['ada@example.com'])
E + where ['ada@example.com'] = active_emails([{'email': 'ada@example.com', 'active': True}, {'email': 'alan@example.com', 'active': False}])প্রতিটি আলাদা কথা বলে:
==মান তুলনা করে। list-এর ক্ষেত্রে pytest এটাও বলে দেয় যে তারা কীভাবে আলাদা — ডান দিকে একটি জিনিস বেশি আছে। (active_emailsঅ্যালানকে বাদ দিয়ে ঠিকই করেছে; ভুল ছিল এই test-এর প্রত্যাশা।)inসদস্যপদ যাচাই করে, আর পুরো container-টি দেখায়, যাতে আপনি দেখতে পান তার বদলে সেখানে কী ছিল।is None/is not Noneএকমাত্রNoneঅবজেক্টটির সাথে পরিচয় (identity) যাচাই করে।find_userNoneফিরিয়েছে, কারণ খোঁজাটা ছোট-বড় হাতের অক্ষরের পার্থক্য মানে।<,>,<=,>=যেমন লেখা, তেমনই কাজ করে। দুটিwhereলাইন:1এসেছেlen(...)থেকে, আর সেই list এসেছেactive_emails(...)থেকে।
প্রতিটি রূপকেই যদি ব্যর্থ করানো যায়, তাহলে বাছাইটা গুরুত্বপূর্ণ কেন? কারণ report তৈরি হয় আপনার লেখা expression থেকে। "অ্যালান কি list-এ আছে?" জিজ্ঞেস করার ভুল উপায়:
def test_count():
emails = ["ada@example.com"]
assert emails.count("alan@example.com") > 0> assert emails.count("alan@example.com") > 0
E AssertionError: assert 0 > 0
E + where 0 = <built-in method count of list object at 0x7d9189cec640>('alan@example.com')
E + where <built-in method count of list object at 0x7d9189cec640> = ['ada@example.com'].countশিরোনাম হলো assert 0 > 0, আর যে list-টি নিয়ে আপনার মাথাব্যথা, সেটি চাপা পড়ে আছে দ্বিতীয় where-এর শেষে। সঠিক উপায়, assert "alan@example.com" in emails, list-টিকে শিরোনামেই নিয়ে আসে। মনে যে বাক্যটি ছিল, assert-টি সেভাবেই লিখুন, আর pytest ব্যর্থতাটি একই ভাষায় ব্যাখ্যা করবে।
Truthiness: assert x আর assert x == True এক নয়
assert x পাস করে যখনই x truthy — False, None, 0, "" আর খালি container ছাড়া যেকোনো কিছু। প্রায়ই ঠিক এটাই আপনি চান; কখনো কখনো এটি একটি বাগ লুকিয়ে ফেলে।
rules.py-তে দুটি ফাংশন আছে। overdue ঠিক আছে; is_adult-এ একটি বাগ আছে — সে boolean-এর বদলে string ফেরত দেয়:
def overdue(invoices):
return [i for i in invoices if i["days"] > 30]
def is_adult(age):
if age >= 18:
return "yes"
return "no"from rules import is_adult, overdue
def test_finds_overdue():
assert overdue([{"id": 1, "days": 12}, {"id": 2, "days": 30}])
def test_child_is_not_adult():
assert not is_adult(12)
def test_adult_truthy():
assert is_adult(40)
def test_adult_is_true():
assert is_adult(40) is Truepytest -q, তিনটি ব্যর্থতাকে কেটে শুধু তাদের E লাইনে নামিয়ে আনা:
FF.F [100%]
E AssertionError: assert []
E + where [] = overdue([{'id': 1, 'days': 12}, {'id': 2, 'days': 30}])
E AssertionError: assert not 'no'
E + where 'no' = is_adult(12)
E AssertionError: assert 'yes' is True
E + where 'yes' = is_adult(40)
=========================== short test summary info ============================
FAILED test_rules.py::test_finds_overdue - AssertionError: assert []
FAILED test_rules.py::test_child_is_not_adult - AssertionError: assert not 'no'
FAILED test_rules.py::test_adult_is_true - AssertionError: assert 'yes' is True
3 failed, 1 passed in 0.01sপ্রথম ব্যর্থতা: একটি খালি assert some_list-ও কাজের report পায়। assert [] বলে যে list-টি খালি ফিরেছে, ভুল নয়। ত্রিশ দিন ত্রিশের বেশি নয়, তাই কোড ঠিক আর test-এর ডেটা ভুল — এক লাইন থেকেই জানা গেল।
এবার FF.F-এর বিন্দুটি। test_adult_truthy পাস করেছে। is_adult ফেরত দেয় string "yes", যা truthy, তাই একটি বাগওয়ালা ফাংশনেও assert is_adult(40) সন্তুষ্ট হয়ে গেল। "no"-ও truthy, এই কারণেই assert not is_adult(12) এটি ধরতে পেরেছে — কিন্তু কেবল কপালগুণে, আপনি কোন কেসটি test করেছিলেন তার উপর নির্ভর করে।
তাহলে কি assert x == True? এটি "yes" ধরে ফেলে, কিন্তু এর নিজেরও একটি ফাঁক আছে: পাইথনে 1 == True আর 0 == False। stock.py:
def in_stock(qty):
return qty and qty > 0from stock import in_stock
def test_zero_equals_false():
assert in_stock(0) == False
def test_zero_is_false():
assert in_stock(0) is False.F [100%]
> assert in_stock(0) is False
E assert 0 is False
E + where 0 = in_stock(0)
1 failed, 1 passed in 0.01sqty যখন 0, তখন qty and qty > 0 ফেরত দেয় 0, False নয়। == test-টি পাস করেছে; is test-টি ধরে ফেলেছে। এখান থেকে নিয়মটি দাঁড়ায়:
assert x/assert not xযখন যেকোনো truthy বা falsy মানই চলবে — "list-টি খালি নয়", "কোনো error বার্তা নেই"।assert x is True/assert x is Falseযখন ফাংশনটি একটি সত্যিকারেরbool-এর প্রতিশ্রুতি দেয়।assert x == Trueপ্রায় কখনোই নয়। এটিis True-এর চেয়ে ঢিলে, আর খালিassert x-এর চেয়ে কম বলে; linter-গুলো এটিকে চিহ্নিত করে।
মান যখন বড়: diff
ছোট মানের ক্ষেত্রে assert 80.0 == 70.0-ই পুরো গল্প। চল্লিশটি জিনিসের একটি list বা তিন লাইনের একটি ইমেইলের ক্ষেত্রে pytest দুই দিক তুলনা করে আর কেবল পার্থক্যটুকু দেখায়।
def test_list():
got = ["pen", "notebook", "bag", "eraser"]
assert got == ["pen", "notebook", "ruler", "eraser"]
def test_dict():
got = {"id": 7, "name": "Ada", "role": "admin", "active": True}
assert got == {"id": 7, "name": "Ada", "role": "editor", "active": True}
def test_multiline_string():
got = "Dear Ada,\nYour order 7 has shipped.\nThanks!"
assert got == "Dear Ada,\nYour order 7 has been shipped.\nThanks!"তিনটি ব্যর্থতার E লাইন:
E AssertionError: assert ['pen', 'note...ag', 'eraser'] == ['pen', 'note...er', 'eraser']
E
E At index 2 diff: 'bag' != 'ruler'
E Use -v to get more diff
E AssertionError: assert {'id': 7, 'na...active': True} == {'id': 7, 'na...active': True}
E
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E Use -v to get more diff
E AssertionError: assert 'Dear Ada,\nY...ped.\nThanks!' == 'Dear Ada,\nY...ped.\nThanks!'
E
E Dear Ada,
E - Your order 7 has been shipped.
E ? -----
E + Your order 7 has shipped.
E Thanks!প্রথম E লাইনটি জায়গায় আঁটাতে ... দিয়ে ছোট করা হয়েছে; তার নিচের লাইনগুলোতে বিস্তারিত থাকে:
- List — প্রথম যে index-এ তারা আলাদা, বা কোন দিকে বাড়তি জিনিস আছে।
- Dict — যে key-গুলোর মান আলাদা। একই রকম key-গুলো গোনা হয়, ছাপা হয় না।
- String — লাইন ধরে ধরে একটি diff। দুটি স্পেস দিয়ে শুরু হওয়া লাইন দুই দিকেই একই।
+চিহ্নিত করে==-এর বাঁ দিক,-ডান দিক। নিচের?লাইনটি ঠিক কোন অক্ষরগুলো, তা দেখিয়ে দেয়।
তাই আপনি যদি সবসময় assert actual == expected লেখেন, + হলো আপনার কোড যা তৈরি করেছে আর - হলো যা আপনি চেয়েছিলেন। বিভিন্ন test-এ ক্রম মিশিয়ে ফেললে প্রতিবার থেমে বের করতে হবে কোনটা কোনটা; ক্রম ঠিক রাখলে কখনোই করতে হবে না।
এক লাইনের একটি লম্বা string-ও একই আচরণ পায়, একই অংশটুকু বাদ দিয়ে:
def test_receipt_line():
got = "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2420.00"
assert got == "2026-10-07 | order 1042 | 3 items | paid by card | ships to Dhaka | total 2402.00"E AssertionError: assert '2026-10-07 |...total 2420.00' == '2026-10-07 |...total 2402.00'
E
E Skipping 66 identical leading characters in diff, use -v to show
E - | total 2402.00
E ? -
E + | total 2420.00
E ? +৮০ অক্ষরের একটি লাইনের শেষে জায়গা অদলবদল হওয়া দুটি অঙ্ক, আপনার হয়ে খুঁজে দেওয়া।
-v আর -vv: পুরো diff চাওয়া
ইঙ্গিতগুলো খেয়াল করুন: Use -v to get more diff, use -vv to show। ডিফল্টভাবে pytest লম্বা ব্যাখ্যা ছেঁটে দেয়, আর প্রতিটি -v বিস্তারিতের মাত্রা বাড়ায়। pytest -vv দিয়ে dict test-টি:
E AssertionError: assert {'id': 7, 'name': 'Ada', 'role': 'admin', 'active': True} == {'id': 7, 'name': 'Ada', 'role': 'editor', 'active': True}
E
E Common items:
E {'active': True, 'id': 7, 'name': 'Ada'}
E Differing items:
E {'role': 'admin'} != {'role': 'editor'}
E
E Full diff:
E {
E 'id': 7,
E 'name': 'Ada',
E - 'role': 'editor',
E ? ^ ^^^
E + 'role': 'admin',
E ? ^ + ^
E 'active': True,
E }কিছুই ছোট করা হয়নি: দুটি dict পুরোপুরি, একই জিনিসগুলো, আর এক লাইনে একটি key ধরে একটি diff। তাহলে সবসময় -vv কেন নয়? কারণ দুশো রেকর্ডের একটি list-এ পুরো diff দুশো লাইন, আর ডিফল্ট সারাংশটি — "index 2-এ" — ঠিক যা আপনার দরকার ছিল। ডিফল্ট দিয়ে শুরু করুন; যে একটি test-এর সারাংশ যথেষ্ট নয়, কেবল সেটির জন্য -vv যোগ করুন।
আপনার নিজের বার্তা: assert cond, "msg"
একটি কমার পরে assert একটি বার্তা নিতে পারে, যা pytest তার নিজের ব্যাখ্যার উপরে ছাপে:
def test_everything_in_stock():
stock = {"pen": 12, "bag": 0}
for name, qty in stock.items():
assert qty > 0, f"{name} is out of stock"> assert qty > 0, f"{name} is out of stock"
E AssertionError: bag is out of stock
E assert 0 > 0বার্তা ছাড়া আপনি দেখতেন assert 0 > 0, আর বের করতে হতো কোন জিনিসটি শূন্য ছিল। একটি বার্তা তখনই নিজের জায়গা অর্জন করে, যখন শুধু মানগুলো দিয়ে বোঝা যায় না কোন কেসটি ব্যর্থ হলো বা নিয়মটি কেন আছে। ভুল ব্যবহার হলো pytest যা আগেই দেখায় তা আবার বলা: "expected 70.0 but got 80.0" বার্তাটি assert 80.0 == 70.0-এর সাথে কিছুই যোগ করে না।
tuple-এর ফাঁদ
বার্তা যোগ করায় লাইনটি লম্বা হয়ে গেলে পুরো জিনিসটা বন্ধনীতে মুড়ে দেওয়ার লোভ হয়:
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong"). [100%]
=============================== warnings summary ===============================
test_trap.py:3
/home/you/shop/test_trap.py:3: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
assert (total == 70.0, "total is wrong")
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01stest-টি পাস করেছে, যদিও total হলো 80.0। ভেতরে কমাসহ বন্ধনী দুটি জিনিসের একটি tuple তৈরি করে, আর খালি নয় এমন tuple সবসময় truthy — assert যাচাই করে tuple-টিকে, তুলনাটিকে নয়। pytest সতর্ক করে, কিন্তু একটি লম্বা রানের নিচে একটি warning সহজেই চোখ এড়িয়ে যায়। বন্ধনীগুলো সরিয়ে দিন:
def test_total():
total = 80.0
assert total == 70.0, "total is wrong"> assert total == 70.0, "total is wrong"
E AssertionError: total is wrong
E assert 80.0 == 70.0লাইনটি খুব লম্বা হলে শুধু বার্তাটির চারপাশে বন্ধনী দিন:
def test_total():
total = 80.0
assert total == 70.0, (
"total is wrong after applying the loyalty discount"
)প্রতিটি আচরণের জন্য একটি assert
যে test ফিল্ডগুলো একটার পর একটা যাচাই করে, সেটি প্রথম ব্যর্থ ফিল্ডেই থেমে যায়। orders.py-তে দুটি বাগ আছে — নামটি strip করা হয় না আর পরিমাণটি string-ই থেকে যায়:
def parse_order(line):
order_id, customer, qty, price = line.split(",")
return {"id": int(order_id), "customer": customer, "qty": qty, "price": float(price)}প্রথমে ভুল উপায়, তারপর সঠিক উপায়:
from orders import parse_order
def test_parse_order_fields():
order = parse_order("1042, Ada ,3,15.0")
assert order["id"] == 1042
assert order["customer"] == "Ada"
assert order["qty"] == 3
assert order["price"] == 15.0
def test_parse_order():
order = parse_order("1042, Ada ,3,15.0")
assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}> assert order["customer"] == "Ada"
E AssertionError: assert ' Ada ' == 'Ada'
E
E - Ada
E + Ada
E ? + +
> assert order == {"id": 1042, "customer": "Ada", "qty": 3, "price": 15.0}
E AssertionError: assert {'id': 1042, ...'price': 15.0} == {'id': 1042, ...'price': 15.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'customer': ' Ada '} != {'customer': 'Ada'}
E {'qty': '3'} != {'qty': 3}
E Use -v to get more diffপ্রথম test একটি বাগ জানায়। আপনি সেটি ঠিক করেন, আবার চালান, আর কেবল তখনই দ্বিতীয়টির দেখা পান। দ্বিতীয় test পুরো ফলটি তুলনা করে আর দুটিই একসাথে জানায়।
নির্দেশনাটি হলো প্রতি test-এ একটি আচরণ, প্রতি test-এ একটি assert নয়। "একটি লাইন parse করলে এই রেকর্ডটি পাওয়া যায়" একটি আচরণ, তাই পুরো মানের একটিমাত্র তুলনাই আদর্শ। কয়েকটি assert মিলে যদি একটি আচরণ বর্ণনা করে, তাহলে কয়েকটি assert-ও চলে। যা এড়াতে হবে তা হলো এমন একটি test, যা parse করা যাচাই করে, তারপর ফাঁকা লাইন বাদ দেওয়া, তারপর মোট করা — তিনটি আচরণ, যেখানে প্রথম ব্যর্থতা বাকি দুটিকে আড়াল করে আর test-এর নাম বলতে পারে না কী ভেঙেছে।
কতটা report: --tb আর -l
শিরোনাম আর file:line-এর মাঝের অংশটি হলো traceback, আর --tb ঠিক করে তার কতটা দেখানো হবে। pytest -q --tb=short দিয়ে cart.py-এর test:
___________________________ test_total_with_discount ___________________________
test_cart.py:10: in test_total_with_discount
assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)কেবল ব্যর্থ লাইনটি, পুরো ফাংশন নয়। --tb=line প্রতিটি ব্যর্থতার জন্য একটি অবস্থান দেয়:
E assert 80.0 == 70.0
+ where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
/home/you/shop/test_cart.py:10: assert 80.0 == 70.0আর --tb=no রেখে দেয় শুধু সারাংশটুকু:
.F [100%]
=========================== short test summary info ============================
FAILED test_cart.py::test_total_with_discount - assert 80.0 == 70.0
1 failed, 1 passed in 0.01sডিফল্ট, --tb=auto, হলো সেই লম্বা রূপ যা পুরো অধ্যায় জুড়ে দেখেছেন। একটি বড় রানে কী ব্যর্থ হলো তা দেখতে --tb=no বা --tb=line ব্যবহার করুন; তারপর কেন তা দেখতে একটি test ডিফল্ট দিয়ে আবার চালান।
উল্টো দিকে, -l (--showlocals) ব্যর্থ ফাংশনের লোকাল ভ্যারিয়েবলগুলো যোগ করে। pytest -q -l:
> assert total(items, 10) == 70.0
E assert 80.0 == 70.0
E + where 80.0 = total([(15.0, 2), (60.0, 1)], 10)
items = [(15.0, 2), (60.0, 1)]
test_cart.py:10: AssertionErrorযে test-এ একটি লুপ বা কয়েকটি মধ্যবর্তী ভ্যারিয়েবল আছে, সেখানে প্রায়ই এই বাড়তি অংশটিই ব্যর্থতার ব্যাখ্যা দেয় — শেষে সমাধানে আপনি এটি দেখবেন।
একটা সম্পূর্ণ উদাহরণ
invoice.py "pen, 12, 15.0"-এর মতো লাইন পড়ে:
def parse_line(line):
name, qty, price = line.split(",")
return {"name": name.strip(), "qty": int(qty), "price": float(price)}
def summarise(lines):
items = [parse_line(line) for line in lines if line.strip()]
total = sum(item["qty"] * item["price"] for item in items)
return {
"count": len(items),
"names": [item["name"] for item in items],
"total": round(total, 2),
}
def find_item(items, name):
for item in items:
if item["name"] == name:
return item
return Nonetest_invoice.py হলো "টেস্ট লেখার আগে" অংশের পরিকল্পনাটি, প্রতিটি সারির জন্য একটি test, প্রতিটি লেখা টেবিলের শেষ কলামের তুলনা দিয়ে:
from invoice import find_item, parse_line, summarise
LINES = ["pen, 12, 15.0", "", "bag, 2, 850.0"]
def test_parse_line_strips_and_converts():
assert parse_line(" pen , 12, 15.0") == {"name": "pen", "qty": 12, "price": 15.0}
def test_blank_lines_are_skipped():
assert summarise(LINES)["count"] == 2
def test_summary():
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
def test_find_item_present():
items = [parse_line("pen, 12, 15.0")]
assert find_item(items, "pen") is not None
def test_find_item_missing():
assert find_item([], "pen") is None
def test_total_is_positive():
total = summarise(LINES)["total"]
assert total > 0, f"total should be positive, got {total}"pytest -q:
...... [100%]
6 passed in 0.01sএবার কেউ summarise-কে "গুছিয়ে" দিল, যাতে নামগুলো সাজানো অবস্থায় ফেরে, একটি লাইন বদলে "names": sorted(item["name"] for item in items), করে। pytest -q --tb=short:
..F... [100%]
=================================== FAILURES ===================================
_________________________________ test_summary _________________________________
test_invoice.py:15: in test_summary
assert summarise(LINES) == {"count": 2, "names": ["pen", "bag"], "total": 1880.0}
E AssertionError: assert {'count': 2, ...otal': 1880.0} == {'count': 2, ...otal': 1880.0}
E
E Omitting 2 identical items, use -vv to show
E Differing items:
E {'names': ['bag', 'pen']} != {'names': ['pen', 'bag']}
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_invoice.py::test_summary - AssertionError: assert {'count': 2, .....
1 failed, 5 passed in 0.01sএই অধ্যায় যেভাবে শিখিয়েছে, সেভাবে পড়ুন। ছয়টির মধ্যে একটি test ব্যর্থ হয়েছে, test_summary, লাইন 15-এ। প্রথম E লাইনটি ছোট করা, তাই বিস্তারিত নিচে: দুটি key মিলেছে, আর names আলাদা — কোড থেকে ['bag', 'pen'] (বাঁ দিক) বনাম test-এ ['pen', 'bag'] (ডান দিক)। ক্রম বদলেছে, আর কিছু নয়। এটি বাগ নাকি ইচ্ছাকৃত পরিবর্তন, সেটি এখন ইনভয়েস নিয়ে একটি সিদ্ধান্ত, কোডের ভেতরে খোঁজাখুঁজি নয়।
এটাও খেয়াল করুন, কোন test-গুলো ব্যর্থ হয়নি। সংখ্যা, খোঁজা আর মোট এখনো পাস করছে, তাই report পরিবর্তনটিকে একটি dict-এর একটি key-তে নামিয়ে আনে। প্রতি test-এ একটি আচরণের এটাই লাভ: পাস আর ব্যর্থতার নকশাটাই নিজে একটি রোগনির্ণয়।
যখন ভেঙে যায়
E AssertionError কোনো মান ছাড়া, একটি helper মডিউল থেকে Rewriting কেবল test ফাইল আর conftest.py ঢাকে। ধরুন checks.py-এর ভেতরের একটি assert একটি খালি AssertionError দেয়। মডিউলটি import হওয়ার আগেই, conftest.py-এর শুরুতে, সেটি নিবন্ধন করুন: pytest.register_assert_rewrite("checks")। তখন report যথারীতি assert 0 > 0 দেখাবে।
PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses? আপনি লিখেছেন assert (condition, "message")। সেটি একটি tuple, আর test-টি কখনোই ব্যর্থ হতে পারে না। বাইরের বন্ধনীগুলো সরান; লাইন লম্বা হলে শুধু বার্তাটি মুড়ে দিন।
AssertionError: assert None == ... বা + where None = ... যে ফাংশনটি কল করেছেন, সেটি None ফিরিয়েছে। সাধারণত তার এমন একটি পথ আছে যেখানে কোনো return নেই, অথবা সে কিছু একটা জায়গায় বদলে দেয় আর কিছুই ফেরত দেয় না — list.sort() এর ক্লাসিক উদাহরণ।
Use -v to get more diff / ...Full output truncated (8 lines hidden), use '-vv' to show এটি কোনো error নয় — pytest ব্যাখ্যাটি ছোট করেছে। সেই একটি test -vv দিয়ে আবার চালান।
মানটি স্পষ্টতই ভুল, তবু test পাস করছে খুঁজুন assert x, যেখানে x হলো "no"-এর মতো খালি নয় এমন একটি string; assert x == True, যেখানে x সমান 1; অথবা tuple-এর ফাঁদ। যে test কখনো ব্যর্থ হয়নি, সেটি যে কাজ করে তা কখনো দেখানো হয়নি — একবার ইচ্ছে করে কোডটি ভাঙুন আর দেখুন সেটি লাল হয় কি না।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
টেস্টটি ব্যর্থ হয়। প্রথম E লাইনের নিচে pytest কোন ব্যাখ্যার লাইনটি ছাপায়?
def test_names():
got = ["pen", "bag"]
assert got == ["pen", "bag", "ink"]- AAt index 2 diff: 'ink' != None
- BLeft contains one more item: 'ink'
- CRight contains one more item: 'ink'
- Dassert 2 == 3
total হলো 80.0, তবু এই টেস্টটি পাস করে। কেন?
def test_total():
total = 80.0
assert (total == 70.0, "total is wrong")- Aবার্তা থাকা assert-গুলো pytest এড়িয়ে যায়
- Bfloat-এর ক্ষেত্রে `80.0 == 70.0` সত্য
- Cবার্তার স্ট্রিংটি শর্তের জায়গা নিয়ে নেয়
- Dবন্ধনী দুটি উপাদানের একটি tuple বানায়, আর খালি নয় এমন tuple সবসময় সত্য
আপনি সবসময় assert actual == expected লেখেন। স্ট্রিং diff-এ + আর - দিয়ে শুরু হওয়া লাইন কোনটি কী?
- A`+` প্রত্যাশিত মান, `-` কোড যা দিয়েছে
- B`+` কোড যা দিয়েছে (বাঁ দিক), `-` প্রত্যাশিত মান (ডান দিক)
- C`+` দুই মানেই যোগ হওয়া অক্ষর, `-` দুই মানেই বাদ পড়া অক্ষর
- Dকোন মানটি লম্বা তার ওপর নির্ভর করে
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
চারটি ফাংশনসহ grades.py তৈরি করুন:
letter(score)— 80 থেকে"A", 65 থেকে"B", 50 থেকে"C", নইলে"F"passed(score)— একটি সত্যিকারেরbool, 50 থেকেTruereport(scores)— নাম থেকে নম্বরের একটি dict নেয়, নাম থেকে লেটারের একটি dict ফেরত দেয়best(scores)— সবচেয়ে বেশি নম্বরের নামটি, অথবা খালি dict হলেNone
প্রথমে test-এর পরিকল্পনাটি একটি টেবিল হিসেবে লিখুন, এই অধ্যায় যেভাবে করেছে: কেস, ইনপুট, প্রত্যাশিত, আর যে assert ব্যবহার করবেন। তারপর অন্তত ছয়টি test নিয়ে test_grades.py লিখুন। এগুলোর প্রতিটি অন্তত একবার ব্যবহার করুন: পুরো dict-এর উপর ==, in, is None, is True বা is False, >=-এর মতো একটি তুলনা, আর এমন একটি বার্তাসহ assert যা ব্যর্থ কেসটির নাম বলে।
তারপর ইচ্ছে করে জিনিস ভাঙুন আর প্রতিটি report পড়ুন:
passed-কে bool-এর বদলেscore >= 50 and scoreফেরত দেওয়ান। কোন test-গুলো ধরে?"B"-এর সীমা 65 থেকে 66-এ সরান। কোন test-গুলো ধরে, কোনগুলো ধরে না — আর কেন?- আপনার একটি assert-কে তার বার্তাসহ বন্ধনীতে মুড়ে দিন। warning-টি খুঁজে বের করুন।
- ভাঙন ২ বহাল রেখে ফাইলটি
--tb=lineদিয়ে চালান, তারপর ব্যর্থ test-টি-lদিয়ে।
প্রতিটি ভাঙনের জন্য লিখে রাখুন report-এর সেই প্রথম লাইনটি, যা আপনাকে বলেছিল কী ভুল।
সমাধান
প্রথমে পরিকল্পনা। গ্রেড দেওয়ার কোড ভুল করে সীমানাতেই, তাই প্রতিটি সীমার দুই দিকই নেওয়া হয়েছে — 80 আর 79, 65 আর 64, 50 আর 49:
| কেস | ইনপুট | প্রত্যাশিত | Assert | | --- | --- | --- | --- | | প্রতিটি সীমা, দুই দিক | 80, 79, 65, 64, 50, 49 | A, B, B, C, C, F | নম্বরের উল্লেখসহ বার্তা দিয়ে == | | passed একটি সত্যিকারের bool | 50, 49 | True, False | is True, is False | | পুরো report | চারজন শিক্ষার্থী | লেটারের পুরো dict | dict-এর উপর == | | কেউ একজন ফেল করেছে | একই চারজন | লেটারগুলোর মধ্যে "F" | in | | best সত্যিই সেরা | একই চারজন | বিজয়ীর চেয়ে বেশি কোনো নম্বর নেই | বার্তাসহ >= | | র্যাঙ্ক করার মতো কেউ নেই | {} | None | is None |
grades.py:
def letter(score):
if score >= 80:
return "A"
if score >= 65:
return "B"
if score >= 50:
return "C"
return "F"
def passed(score):
return score >= 50
def report(scores):
return {name: letter(score) for name, score in scores.items()}
def best(scores):
if not scores:
return None
return max(scores, key=scores.get)test_grades.py:
from grades import best, letter, passed, report
SCORES = {"ada": 91, "alan": 64, "grace": 50, "linus": 49}
def test_letter_boundaries():
cases = [(80, "A"), (79, "B"), (65, "B"), (64, "C"), (50, "C"), (49, "F")]
for score, expected in cases:
assert letter(score) == expected, f"letter({score})"
def test_passed_returns_a_real_bool():
assert passed(50) is True
assert passed(49) is False
def test_report():
assert report(SCORES) == {"ada": "A", "alan": "C", "grace": "C", "linus": "F"}
def test_a_failing_letter_appears():
assert "F" in report(SCORES).values()
def test_best_has_the_highest_score():
top = best(SCORES)
for name, score in SCORES.items():
assert SCORES[top] >= score, f"{name} scored more than {top}"
def test_best_of_nobody():
assert best({}) is Nonepytest -q:
...... [100%]
6 passed in 0.01sভাঙন ১ — passed ফেরত দেয় score >= 50 and score:
.F.... [100%]
> assert passed(50) is True
E assert 50 is True
E + where 50 = passed(50)
FAILED test_grades.py::test_passed_returns_a_real_bool - assert 50 is True
1 failed, 5 passed in 0.01s50 truthy, তাই assert passed(50) এটিকে পার হতে দিত। is True এটি ধরেছে, আর assert 50 is True সেই মানটির নাম বলে দেয়, যা একটি bool হওয়ার কথা ছিল।
ভাঙন ২ — "B"-এর সীমা 66-এ সরানো, --tb=line দিয়ে:
F..... [100%]
=================================== FAILURES ===================================
E AssertionError: letter(65)
assert 'C' == 'B'
- B
+ C
/home/you/grades/test_grades.py:9: AssertionError: letter(65)
=========================== short test summary info ============================
FAILED test_grades.py::test_letter_boundaries - AssertionError: letter(65)
1 failed, 5 passed in 0.01sকেবল সীমার test-টি ধরেছে। test_report তখনো পাস করেছে, কারণ SCORES-এ কোনো শিক্ষার্থীর নম্বর ঠিক 65 নয় — একটি test কেবল সেই মানগুলোই ঢাকে যা আপনি তাতে দিয়েছেন, আর এই কারণেই পরিকল্পনায় প্রতিটি সীমার দুই দিকই রাখা হয়েছিল। বার্তা letter(65) আসে সবার আগে, আর এটি দরকারি: লুপটি একই লাইনে ছয়টি কেস চালায়, আর এটি ছাড়া আপনি জানতেন একটি লেটার ভুল, কিন্তু কোন নম্বরের জন্য তা জানতেন না। + C হলো কোড যা ফেরত দিয়েছে, - B হলো test যা আশা করেছিল।
একই ব্যর্থতা pytest -q -l test_grades.py::test_letter_boundaries দিয়ে চালালে শেষে লুপের ভ্যারিয়েবলগুলো আসে:
cases = [(80, 'A'), (79, 'B'), (65, 'B'), (64, 'C'), (50, 'C'), (49, 'F')]
expected = 'B'
score = 65ভাঙন ৩ — লাইন 32-এ assert (best({}) is None, "no scores, no best"):
...... [100%]
/home/you/grades/test_grades.py:32: PytestAssertRewriteWarning: assertion is always true, perhaps remove parentheses?
6 passed, 1 warning in 0.01sছয়টি পাস করেছে — আর তার একটি আর কখনো ব্যর্থ হতে পারবে না, best যা-ই ফেরত দিক। warning-টিই একমাত্র লক্ষণ।
যে প্রথম লাইনগুলো আপনাকে বলেছিল কী ভুল: assert 50 is True, AssertionError: letter(65), আর assertion is always true। এখন থেকে প্রতিটি ব্যর্থতায় আপনি সবার আগে এই ধরনের লাইনই খুঁজবেন।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন