অধ্যায় 02

assert আর ব্যর্থতার রিপোর্ট পড়া

pytest কীভাবে একটি সাধারণ assert-এর ভেতরের মান দেখায়, ব্যর্থতার রিপোর্ট লাইন ধরে ধরে কীভাবে পড়তে হয়, আর কোন তুলনাটি লিখলে রিপোর্ট সবচেয়ে বেশি কথা বলে।

45 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

সাধারণ পাইথনে লেখা একটি যাচাই, check.py নামের একটি ফাইলে:

python
def full_name(first, last):
    return f"{first} {last}".title()


assert full_name("ada", "lovelace") == "Ada Lovelace "
text
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 চালান:

python
from names import full_name


def test_full_name():
    assert full_name("ada", "lovelace") == "Ada Lovelace "
text
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:

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

python
def subtotal(items):
    return sum(price * qty for price, qty in items)


def total(items, discount):
    return subtotal(items) - discount

আর test_cart.py, যেখানে দ্বিতীয় test-টি ভুল সংখ্যা আশা করে:

python
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 চালান:

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

python
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, চার রকম তুলনা:

python
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 লাইন:

text
>       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_user None ফিরিয়েছে, কারণ খোঁজাটা ছোট-বড় হাতের অক্ষরের পার্থক্য মানে।
  • <, >, <=, >= যেমন লেখা, তেমনই কাজ করে। দুটি where লাইন: 1 এসেছে len(...) থেকে, আর সেই list এসেছে active_emails(...) থেকে।

প্রতিটি রূপকেই যদি ব্যর্থ করানো যায়, তাহলে বাছাইটা গুরুত্বপূর্ণ কেন? কারণ report তৈরি হয় আপনার লেখা expression থেকে। "অ্যালান কি list-এ আছে?" জিজ্ঞেস করার ভুল উপায়:

python
def test_count():
    emails = ["ada@example.com"]
    assert emails.count("alan@example.com") > 0
text
>       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 ফেরত দেয়:

python
def overdue(invoices):
    return [i for i in invoices if i["days"] > 30]


def is_adult(age):
    if age >= 18:
        return "yes"
    return "no"
python
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 True

pytest -q, তিনটি ব্যর্থতাকে কেটে শুধু তাদের E লাইনে নামিয়ে আনা:

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

python
def in_stock(qty):
    return qty and qty > 0
python
from 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
text
.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.01s

qty যখন 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 দুই দিক তুলনা করে আর কেবল পার্থক্যটুকু দেখায়।

python
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 লাইন:

text
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-ও একই আচরণ পায়, একই অংশটুকু বাদ দিয়ে:

python
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"
text
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-টি:

text
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 তার নিজের ব্যাখ্যার উপরে ছাপে:

python
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"
text
>           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-এর ফাঁদ

বার্তা যোগ করায় লাইনটি লম্বা হয়ে গেলে পুরো জিনিসটা বন্ধনীতে মুড়ে দেওয়ার লোভ হয়:

python
def test_total():
    total = 80.0
    assert (total == 70.0, "total is wrong")
text
.                                                                        [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.01s

test-টি পাস করেছে, যদিও total হলো 80.0। ভেতরে কমাসহ বন্ধনী দুটি জিনিসের একটি tuple তৈরি করে, আর খালি নয় এমন tuple সবসময় truthy — assert যাচাই করে tuple-টিকে, তুলনাটিকে নয়। pytest সতর্ক করে, কিন্তু একটি লম্বা রানের নিচে একটি warning সহজেই চোখ এড়িয়ে যায়। বন্ধনীগুলো সরিয়ে দিন:

python
def test_total():
    total = 80.0
    assert total == 70.0, "total is wrong"
text
>       assert total == 70.0, "total is wrong"
E       AssertionError: total is wrong
E       assert 80.0 == 70.0

লাইনটি খুব লম্বা হলে শুধু বার্তাটির চারপাশে বন্ধনী দিন:

python
def test_total():
    total = 80.0
    assert total == 70.0, (
        "total is wrong after applying the loyalty discount"
    )

প্রতিটি আচরণের জন্য একটি assert

যে test ফিল্ডগুলো একটার পর একটা যাচাই করে, সেটি প্রথম ব্যর্থ ফিল্ডেই থেমে যায়। orders.py-তে দুটি বাগ আছে — নামটি strip করা হয় না আর পরিমাণটি string-ই থেকে যায়:

python
def parse_order(line):
    order_id, customer, qty, price = line.split(",")
    return {"id": int(order_id), "customer": customer, "qty": qty, "price": float(price)}

প্রথমে ভুল উপায়, তারপর সঠিক উপায়:

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

text
___________________________ 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 প্রতিটি ব্যর্থতার জন্য একটি অবস্থান দেয়:

text
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 রেখে দেয় শুধু সারাংশটুকু:

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

text
>       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"-এর মতো লাইন পড়ে:

python
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 None

test_invoice.py হলো "টেস্ট লেখার আগে" অংশের পরিকল্পনাটি, প্রতিটি সারির জন্য একটি test, প্রতিটি লেখা টেবিলের শেষ কলামের তুলনা দিয়ে:

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

text
......                                                                   [100%]
6 passed in 0.01s

এবার কেউ summarise-কে "গুছিয়ে" দিল, যাতে নামগুলো সাজানো অবস্থায় ফেরে, একটি লাইন বদলে "names": sorted(item["name"] for item in items), করে। pytest -q --tb=short:

text
..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 কখনো ব্যর্থ হয়নি, সেটি যে কাজ করে তা কখনো দেখানো হয়নি — একবার ইচ্ছে করে কোডটি ভাঙুন আর দেখুন সেটি লাল হয় কি না।