অধ্যায় 00

টেস্ট লেখার আগে — আসলে কী কী লাগে

টেস্ট কোড লেখার আগের চিন্তাটুকু: অস্পষ্ট চাহিদাকে সুনির্দিষ্ট চুক্তিতে রূপ দেওয়া, কোডকে টেস্টযোগ্য করা, সীমানাসহ কেস বাছাই, কোড না চালিয়ে প্রত্যাশিত উত্তর জানা, আর কী টেস্ট করবেন না — সবই সাধারণ পাইথন ও assert দিয়ে।

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

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

এই যে একটি ফাংশন, যা পরীক্ষার নম্বরের একটি তালিকার গড় বের করে, আর এ রকম ফাংশন আমরা বেশিরভাগই যেভাবে যাচাই করি — দু-একবার ডেকে দেখি কী আসে:

python
def average(scores):
    return sum(scores) / len(scores)


print(average([80, 90, 70]))
print(average([100]))
text
80.0
100.0

দুটো উত্তরই ঠিক। ফাংশনটি "কাজ করে", তাই প্রোগ্রামে ঢুকে যায়। এক সপ্তাহ পরে এমন একটি ক্লাস একই লাইনে পৌঁছায়, যাদের এখনো কোনো ফল আসেনি:

python
def average(scores):
    return sum(scores) / len(scores)


print(average([]))
text
Traceback (most recent call last):
  File "/home/you/school/report.py", line 5, in <module>
    print(average([]))
          ^^^^^^^^^^^
  File "/home/you/school/report.py", line 2, in average
    return sum(scores) / len(scores)
           ~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero

"চালিয়ে দেখেছি, কাজ করে" — কথাটা সত্যি ছিল। কিন্তু তার মানে কেবল এটুকুই ছিল: "যে দুটো ইনপুট আমি হাতের কাছে দিয়ে দেখেছি, সেগুলোর জন্য কাজ করে"। হাতে-করা যাচাইয়ের দুটো দুর্বলতা এখানে ধরা পড়ে।

প্রথমটি হলো, এটা কেবল সেটুকুই দেখে যা আপনার সেই মুহূর্তে মাথায় এসেছিল, আর খালি তালিকা ঠিক সেই ধরনের জিনিস যা সেই মুহূর্তে কারো মাথায় আসে না।

দ্বিতীয়টি হলো, এটা আর ফিরে আসে না। দুটো print একবার চালানো হলো, একবার পড়া হলো, তারপর মুছে ফেলা হলো। পরের মাসে ফাংশনটি বদলালে কেউ সেগুলো আর চালায় না।

স্বয়ংক্রিয় টেস্ট দ্বিতীয় দুর্বলতাটি সারায়, আর এই কোর্সের বাকিটা pytest দিয়ে সেই টেস্ট লেখা নিয়েই। কিন্তু প্রথম দুর্বলতাটি আরেকটু খুঁটিয়ে দেখুন, কারণ কোনো টুল এটা সারায় না। ধরুন আপনি এখনই খালি তালিকার জন্য একটা যাচাই লিখতে চান। তাতে কী লেখা থাকবে? average([]) কি 0 ফেরত দেবে? None দেবে? ত্রুটি তুলবে? কেউ ঠিক করেনি। যে আচরণ কেউ ঠিক করেনি, তার জন্য টেস্ট লেখা যায় না, আর যে উত্তর আপনি আগে থেকে জানেন না, তা মেলানোও যায় না।

এই অধ্যায় সেটা নিয়েই: টেস্ট কোডের প্রথম লাইনের আগে যে ভাবনাটা হতে হয়। এটা ঠিকঠাক হলে টেস্ট প্রায় নিজে নিজেই লেখা হয়ে যায়। এড়িয়ে গেলে এমন টেস্ট পাবেন যা পাস করে, অথচ বাগটা ধরে না।

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

  • এক বাক্যে বলতে, টেস্ট কী: আচরণ নিয়ে একটি চালানো-যায় এমন দাবি
  • একটি অস্পষ্ট চাহিদাকে একটি সুনির্দিষ্ট কন্ট্র্যাক্টে বদলাতে — ইনপুট, আউটপুট, ত্রুটি, পার্শ্বপ্রতিক্রিয়া
  • টেস্ট করা কঠিন এমন কোড চিনতে, আর সেটাকে একটি বিশুদ্ধ কেন্দ্র ও একটি পাতলা খোলসে নতুন করে সাজাতে
  • সমতুল্য শ্রেণি আর সীমানা-মান দিয়ে ঘটনাগুলো টেবিল আকারে পরিকল্পনা করতে
  • কোড থেকে স্বাধীনভাবে প্রত্যাশিত উত্তর বের করতে — অর্থাৎ ওরাকল
  • কী টেস্ট করবেন না, আর কখন যথেষ্ট টেস্ট হয়েছে, তা ঠিক করতে
  • পরিকল্পনাটিকে সাধারণ assert যাচাই হিসেবে লিখতে, চালাতে, আর বলতে কেন সেগুলো এখনো যথেষ্ট নয়

আগে যা জানা লাগবে: কিছু না — কোর্স এখান থেকেই শুরু। পাইথন 3 থাকতে হবে, আর একটি ফাংশন লিখতে জানতে হবে। pytest এখনো লাগবে না; এই অধ্যায়ের সবকিছু সাধারণ পাইথনেই চলে।


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

এই কোর্সের প্রতিটি অধ্যায়ে এই নামের একটি অংশ আছে, আর প্রতিটিই নিজের বিষয়ের জন্য একই ছয়টি প্রশ্নের উত্তর দেয়। প্রশ্নগুলো এই অধ্যায় থেকেই আসে, তাই এখানে এক জায়গায় সেগুলো দেওয়া হলো। রেখে দিন; কোর্সের বাকিটা এগুলোকে চেকলিস্ট হিসেবে ব্যবহার করে।

  1. ঠিক কী প্রতিশ্রুতি দেওয়া হচ্ছে? কন্ট্র্যাক্ট: এই ইনপুটগুলোর জন্য এই আউটপুট; এই ইনপুটগুলোর জন্য এই ত্রুটি; আর এই পার্শ্বপ্রতিক্রিয়াগুলো, অথবা কোনোটিই নয়।
  2. কোডটিকে কি টেস্ট থেকে ডাকা যায়? সে কি ইনপুট আর্গুমেন্ট হিসেবে নেয় আর একটা ফল ফেরত দেয় — নাকি কিবোর্ড থেকে পড়ে, প্রিন্ট করে, আর ঘড়ি দেখে?
  3. কোন কোন ঘটনা? প্রতিটি ধরনের ইনপুট থেকে একটি, প্রতিটি প্রান্তের দুই পাশ, অবৈধ ইনপুট, খালি আর খুব বড়।
  4. সঠিক উত্তর আমি জানব কীভাবে? হাতে হিসাব করে বা স্পেসিফিকেশন থেকে — যে কোড টেস্ট হচ্ছে সেটা চালিয়ে কখনোই নয়।
  5. কী কী জায়গামতো থাকতে হবে? পাইথনের ভার্সন, একটি এনভায়রনমেন্ট, টেস্ট ফাইলগুলো কোথায় থাকবে — আর পরের অধ্যায়গুলোতে, বিষয়টির দরকারি ফাইল, সেটিং ও টুল।
  6. কী আমি টেস্ট করব না? পাইথন নিজে, অন্যের লাইব্রেরি, ভুল হওয়ার মতো জটিলতাই নেই এমন কোড, আর এমন আচরণ যার প্রতিশ্রুতি কেউ দেয়নি।

ওপরের average-এ এগুলো প্রয়োগ করে দেখা যাক — ফাংশনটি এত ছোট যে পুরোটা করা যায়।

কন্ট্র্যাক্টে একটা ফাঁক আছে, তাই প্রথম ধাপ সেটা বন্ধ করা। ধরুন আপনি জিজ্ঞেস করলেন, আর উত্তর এল: "কোনো নম্বর ছাড়া গড়ের কোনো মানে হয় না; ValueError তুলুন"। ফাংশনটি এমনিতেই টেস্টযোগ্য — একটি তালিকা নেয়, একটি সংখ্যা ফেরত দেয়। এই অধ্যায়ের জন্য জায়গামতো থাকা দরকার কেবল পাইথন 3। আর ঘটনাগুলো:

| ঘটনা | ইনপুট | প্রত্যাশিত | কেন | | --- | --- | --- | --- | | সাধারণ | [80, 90, 70] | 80.0 | স্বাভাবিক ব্যবহার: (80 + 90 + 70) / 3 | | একটিমাত্র নম্বর | [100] | 100.0 | গড় আছে এমন সবচেয়ে ছোট তালিকা | | খালি | [] | ValueError | যে ঘটনাটি ভেঙেছিল; এখন ঠিক করা হয়েছে |

তালিকায় যা নেই: sum ঠিকমতো যোগ করে কি না, বা / ভাগ করে কি না — সেটা পাইথনের কাজ, আর আপনি বা আমি যা লিখব তার চেয়ে অনেক বেশি খুঁটিয়ে সেটা টেস্ট করা হয়েছে।

অধ্যায়ের বাকি অংশ ছয়টি প্রশ্ন একে একে ধরে, আর শেষে পুরো প্রক্রিয়াটি একটি বাস্তবসম্মত ফাংশনের ওপর চালিয়ে দেখায়।

টেস্ট আসলে কী

টেস্ট হলো আচরণ নিয়ে একটি চালানো-যায় এমন দাবি: এই ইনপুট দিলে, এই ফল আশা করি। "ফল" ঠিক তিন রকমের হয়:

  • একটি ফেরত মান — average([80, 90, 70]) দেয় 80.0
  • একটি ত্রুটি — average([]) তোলে ValueError
  • একটি পার্শ্বপ্রতিক্রিয়া — add_score(scores, 70)-এর পরে scores তালিকার শেষে থাকে 70

দাবি লিখে রাখার জন্য পাইথনে আগে থেকেই একটি স্টেটমেন্ট আছে: assert। শর্ত সত্য হলে এটা কিছুই করে না, আর মিথ্যা হলে AssertionError তোলে। নিচে প্রতিটি ধরনের একটি করে দাবি, যেখানে average এখন তার ঠিক-করা কন্ট্র্যাক্ট মেনে চলে:

python
def average(scores):
    if not scores:
        raise ValueError("average() of an empty list")
    return sum(scores) / len(scores)


def add_score(scores, new_score):
    scores.append(new_score)


# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0

# 2. An error: given this input, expect this exception.
try:
    average([])
except ValueError:
    pass
else:
    raise AssertionError("average([]) should raise ValueError")

# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]

print("3 claims checked")
text
3 claims checked

খেয়াল করুন প্রতিটি দাবির কী লাগে: আপনার বেছে নেওয়া একটি ইনপুট, আগে থেকে জানা একটি ফল, আর কোডটি ডেকে সে কী করল তা দেখার একটি উপায়। ছয়টি প্রশ্নের কাজই হলো শুরুর আগে এই তিনটি আপনার হাতে আছে কি না নিশ্চিত করা। এটাও খেয়াল করুন, ত্রুটির যাচাইটা কত আনাড়ি — "এটা ত্রুটি তুলবে" বলার জন্য try/except/else-এর পাঁচ লাইন। চতুর্থ অধ্যায়ে pytest এটাকে এক লাইনে নামিয়ে আনে।

১. কন্ট্র্যাক্ট: অস্পষ্ট চাহিদা থেকে সুনির্দিষ্ট প্রতিশ্রুতি

চাহিদা সাধারণত আসে একটি বাক্য হয়ে: "সদস্যরা বড় অর্ডারে ১০% ছাড় পাবেন।" শুনতে সম্পূর্ণ মনে হয়। দুজন যত্নশীল ডেভেলপারকে দিন, তারপর দেখুন:

python
# "Members get 10% off big orders." Two honest readings of the same sentence.

def discount_by_asha(total, is_member):
    if is_member and total > 1000:
        return total * 0.10
    return 0


def discount_by_ravi(total, is_member):
    if is_member and total >= 1000:
        return round(total * 0.10)
    return 0


for total in [500, 1000, 1234.56]:
    print(total, discount_by_asha(total, True), discount_by_ravi(total, True))
text
500 0 0
1000 0 100
1234.56 123.456 123

দুজনের কেউই ভুল করেনি। বাক্যটি বলেনি ঠিক 1000 "বড়" হিসেবে গণ্য হবে কি না, বা কীভাবে রাউন্ড করতে হবে। প্রত্যেকে ফাঁকটা নিজের মতো ভরেছে, আর 1000-এর একটি অর্ডার একজনের কাছ থেকে পায় 0 ছাড়, অন্যজনের কাছ থেকে 100। কোনো টেস্ট বলতে পারবে না কে ঠিক, কারণ "ঠিক" কখনো সংজ্ঞায়িতই হয়নি।

কন্ট্র্যাক্ট হলো চাহিদাটিকে এতটা সুনির্দিষ্ট করা, যাতে তা টেস্ট করা যায়। বাক্য থেকে কন্ট্র্যাক্টে পৌঁছাতে প্রশ্ন করুন — প্রতিবার একই প্রশ্নগুলো:

  • ইনপুট। কোন ধরন? কোন পরিসর? কোনো সীমা আছে কি, আর সীমাটি নিজে কি অন্তর্ভুক্ত? শূন্য, ঋণাত্মক, খালি, None হলে কী?
  • আউটপুট। ঠিক কী ফেরত আসে — ছাড়ের পরিমাণ, নাকি নতুন মোট? কোন ধরন? কত ঘর পর্যন্ত রাউন্ড?
  • ত্রুটি। কোন ইনপুট প্রত্যাখ্যাত হয়, আর কীভাবে — কোন এক্সেপশন দিয়ে?
  • পার্শ্বপ্রতিক্রিয়া। সে কি কিছু বদলায় — পাঠানো তালিকা, কোনো ফাইল, ডেটাবেস — বা কিছু প্রিন্ট করে? নাকি কিছুই না?

ছাড় নিয়ে এই প্রশ্নগুলো করলে উত্তর মেলে, আর উত্তরগুলো যায় কোডের ঠিক পাশেই — docstring-এ:

python
def member_discount(total, is_member):
    """Return the discount on an order, in currency units.

    - total: the order total before discount, a number >= 0.
      A negative total raises ValueError.
    - is_member: True or False.
    - Members get 10% of the total when the total is 1000 or more
      (1000 itself qualifies). Everyone else gets 0.
    - The result is rounded to 2 decimal places.
    - Returns the discount amount, not the new total.
    - No side effects: reads nothing, prints nothing, changes nothing.
    """
    if total < 0:
        raise ValueError(f"total must not be negative, got {total}")
    if is_member and total >= 1000:
        return round(total * 0.10, 2)
    return 0


print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
    member_discount(-5, True)
except ValueError as error:
    print("ValueError:", error)
text
0
100.0
123.46
0
ValueError: total must not be negative, got -5

docstring-এর প্রতিটি লাইন এখন এমন কিছু যা টেস্ট যাচাই করতে পারে, আর প্রতিটি যাচাইয়ের প্রত্যাশিত মান স্পষ্ট। ভালো কন্ট্র্যাক্টের পরীক্ষা এটাই: একজন অপরিচিত মানুষ কি কোড না পড়েই যেকোনো ইনপুটের প্রত্যাশিত মান লিখে দিতে পারবেন? না পারলে, এখনো একটা প্রশ্ন করা বাকি।

প্রশ্নগুলোর উত্তর দেবে কে? চাহিদাটি যার — প্রোডাক্ট ওনার, শিক্ষক, ক্লায়েন্ট, বা আপনি নিজে। জিজ্ঞেস করার কেউ না থাকলে সিদ্ধান্ত নিন, সিদ্ধান্তটি কন্ট্র্যাক্টে লিখুন, আর এগিয়ে যান। স্পষ্ট সিদ্ধান্ত পর্যালোচনা করা যায়, বদলানো যায়; নীরব সিদ্ধান্ত কোডের ভেতর পড়ে থাকে, যতদিন না কাউকে চমকে দেয়।

২. টেস্টযোগ্য কোড: বিশুদ্ধ কেন্দ্র আর পাতলা খোলস

টেস্ট একটি ফাংশনকে বেছে নেওয়া ইনপুট দিয়ে ডাকে আর যা ফেরত আসে তা মেলায়। কিছু ফাংশন এটা অসম্ভব করে তোলে। এই যে একটি, একটি ক্যাফের হ্যাপি আওয়ারের জন্য — ১৭:০০ থেকে ১৯:০০-র মধ্যে পানীয়ে ২০% ছাড়:

python
from datetime import datetime


def drink_price():
    price = float(input("Price: "))
    hour = datetime.now().hour
    if 17 <= hour < 19:
        price = price * 0.8
    print(f"You pay {price:.2f}")

চালিয়ে একটা দাম টাইপ করলে এটা কাজ করে। এবার আরেকটি ফাইল, check_drinks.py থেকে এটাকে যাচাই করার চেষ্টা করুন:

python
from drinks import drink_price

result = drink_price()
print("returned:", result)

স্বয়ংক্রিয় যাচাই চলে কিবোর্ডে কেউ না থাকা অবস্থায়। < /dev/null প্রোগ্রামকে ঠিক সেটাই দেয় — এমন একটি ইনপুট যাতে কিছুই নেই:

text
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
  File "/home/you/cafe/check_drinks.py", line 3, in <module>
    result = drink_price()
             ^^^^^^^^^^^^^
  File "/home/you/cafe/drinks.py", line 5, in drink_price
    price = float(input("Price: "))
                  ^^^^^^^^^^^^^^^^
EOFError: EOF when reading a line

একটা দাম খাইয়ে দিলে চলে — কিন্তু দেখুন কী ফেরত আসে:

text
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: None

তিনটি আলাদা সমস্যা, প্রতিটিই খুব সাধারণ:

  • সে নিজের ইনপুট নিজে পড়ে input() দিয়ে, আর্গুমেন্ট হিসেবে নেয় না, তাই টেস্ট ইনপুট বেছে দিতে পারে না।
  • সে নিজের ফল প্রিন্ট করে, ফেরত দেয় না, তাই টেস্ট পায় None আর মেলানোর মতো কিছু থাকে না।
  • সে নিজেই ঘড়ি দেখে। ওই রানটি ছিল সকাল সাতটার ঠিক আগে, তাই উত্তর এসেছিল 10.00; একই কমান্ড সন্ধ্যা সাড়ে পাঁচটায় দেয় 8.00। এর ওপর একটা যাচাই কখন চলল তার ওপর নির্ভর করে পাস বা ফেল করবে — একটি ফ্লেকি (flaky) টেস্ট, যা কোনো টেস্ট না থাকার চেয়েও খারাপ, কারণ মানুষ এটাকে উপেক্ষা করতে শিখে যায়।

সমাধানটা কোনো টেস্টিংয়ের কৌশল নয়। এটা আকার বদলানো: সিদ্ধান্তকে ইনপুট আর আউটপুট থেকে আলাদা করুন। সিদ্ধান্তটি হয়ে যায় একটি বিশুদ্ধ (pure) ফাংশন — তার যা দরকার সব আসে আর্গুমেন্ট হয়ে, উত্তর যায় ফেরত মান হয়ে, আর সে অন্য কিছু ছোঁয় না। কিবোর্ড, স্ক্রিন আর ঘড়ি চলে যায় তার চারপাশের একটি পাতলা খোলসে:

python
from datetime import datetime


def drink_price(price, hour):
    """Return the price to pay for a drink.

    20% off from 17:00 up to, but not including, 19:00.
    """
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


def main():
    # The thin shell: all the input, output and clock reading lives here.
    price = float(input("Price: "))
    print(f"You pay {drink_price(price, datetime.now().hour):.2f}")


if __name__ == "__main__":
    main()

এখন যাচাইগুলো দিনের যেকোনো সময়ে, যেকোনো ঘণ্টা বেছে নিতে পারে:

python
from drinks import drink_price

assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")
text
all checks passed

কিবোর্ডের সামনে বসা মানুষের কাছে প্রোগ্রামটি ঠিক আগের মতোই আচরণ করে। বদলেছে এটুকু: যে অংশটি টেস্ট করার মতো — নিয়মটি — সেটা এখন আলাদা করে টেস্ট করা যায়, আর খোলসটি এত পাতলা যে তাতে ভুল করার মতো প্রায় কিছুই নেই।

টেস্ট করা কঠিন কোডের সতর্কসংকেত, যাতে টেস্ট লেখার আগেই চিনতে পারেন:

| সংকেত | টেস্টের কেন অসুবিধা | সাধারণ সমাধান | | --- | --- | --- | | লজিকের ভেতরে input() | টেস্ট ইনপুট বেছে দিতে পারে না | আর্গুমেন্ট হিসেবে নিন | | একমাত্র ফল print() | মেলানোর জন্য কিছু ফেরত আসে না | মান ফেরত দিন; প্রিন্ট করুন খোলসে | | ভেতরে datetime.now(), random | প্রতিবার চালালে উত্তর বদলায় | সময় বা র‍্যান্ডম মানটি বাইরে থেকে দিন | | গ্লোবাল ভেরিয়েবল পড়ে বা বদলায় | টেস্টগুলো একে অপরকে প্রভাবিত করে | আর্গুমেন্টে দিন, নতুন মান ফেরত দিন | | ভেতরে নির্দিষ্ট ফাইল বা URL খোলে | টেস্টের সেই ফাইল বা নেটওয়ার্ক লাগে | ডেটা, বা পাথটি, বাইরে থেকে দিন |

কোড সবসময় নতুন করে সাজানো যায় না — কখনো সেটা অন্যের, কখনো I/O-ই হলো আচরণটা। সেসব ক্ষেত্রের জন্য pytest-এর টুল আছে: capsys প্রিন্ট হওয়া আউটপুট ধরে (নবম অধ্যায়), tmp_path একটি ফেলে-দেওয়া ফোল্ডার দেয় (নবম অধ্যায়), আর monkeypatch ও mock ঘড়ি, নেটওয়ার্ক আর এনভায়রনমেন্টকে বদলে দেয় (দশম ও একাদশ অধ্যায়)। কিন্তু নতুন করে সাজানোটা আগে। যে কোড টেস্ট করা সহজ, সেটা সাধারণত বোঝাও সহজ।

৩. ঘটনা বেছে নেওয়া

সব ইনপুট টেস্ট করা যায় না; শুধু drink_price-ই প্রতিটি ঘণ্টায় প্রতিটি দাম গ্রহণ করে। কাজ হলো ছোট একটি সেট বেছে নেওয়া, যেখানে প্রতিটি ঘটনা এমন একটি ভুল ধরতে পারে যা বাকিগুলো ধরত না। দুটি ধারণা এর বেশিরভাগ কাজ করে।

সমতুল্য শ্রেণি (equivalence classes)। কন্ট্র্যাক্ট যে ইনপুটগুলোকে একইভাবে দেখে, সেগুলোকে এক দলে রাখুন। দলের যেকোনো একজন বাকিদের প্রতিনিধি: drink_price(10, 12) ঠিক হলে drink_price(10, 11)-ও প্রায় নিশ্চিতভাবে ঠিক, কারণ দুটোই কোডের একই লাইন সামলায়। ধরুন কন্ট্র্যাক্ট আরও কড়া করা হয়েছে: ০–২৩-এর বাইরের ঘণ্টা, বা ঋণাত্মক দাম, ValueError তোলে। তাহলে ঘণ্টাগুলো পাঁচটি শ্রেণিতে পড়ে:

text
hour:   ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
        invalid   | full price    | 20% off | full price | invalid

সীমানা-মান (boundary values)। ভুল একটি শ্রেণির ভেতরে সমানভাবে ছড়িয়ে থাকে না। জড়ো হয় তার প্রান্তে, কারণ সেখানেই < আর <=-এর মধ্যে বাছাই হয়, আর সেখানেই "১৯:০০ পর্যন্ত" কোডে রূপ নেয়। তাই প্রতিটি প্রান্তে তার দুই পাশের মান টেস্ট করুন: এক শ্রেণির শেষটি আর পরের শ্রেণির প্রথমটি। কোনো সীমা L-এর জন্য মানে L - 1, L আর L + 1 দেখা; গণনা আর দৈর্ঘ্যের ক্ষেত্রে 0 আর 1-ও।

"প্রতিটি শ্রেণি থেকে একটি সাধারণ মান" কেন যথেষ্ট নয়, তা এখানে দেখুন। নিচে হ্যাপি আওয়ারের শর্তটি একটি সহজ ভুলে লেখা হয়েছে, 17 <= hour-এর জায়গায় 17 < hour:

python
def drink_price(price, hour):
    # A slip: 17 < hour instead of 17 <= hour.
    if 17 < hour < 19:
        return round(price * 0.8, 2)
    return price


# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")

# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")
text
typical cases passed
Traceback (most recent call last):
  File "/home/you/cafe/boundary.py", line 15, in <module>
    assert drink_price(10, 17) == 8.0
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

প্রতিটি শ্রেণি থেকে একটি ঘটনা: সব পাস, অথচ যে খদ্দেররা ঠিক পাঁচটায় আসেন, তাঁরা পুরো দাম দেন। সীমানার ঘটনাটি সঙ্গে সঙ্গে এটা ধরে ফেলে।

শ্রেণি আর সীমানা ইনপুটের আকারটা ঢেকে দেয়। তারপর এই প্রশ্নগুলো একে একে করুন, প্রতিটিই আসল বাগ খুঁজে পেয়েছে:

  • অবৈধ ইনপুট — কন্ট্র্যাক্ট যা প্রত্যাখ্যান করতে বলে: ঋণাত্মক দাম, ঘণ্টা 24।
  • খালি আর শূন্য — [], "", 0, দাম 0। "কিছুই না" কি ঠিকঠাক আচরণ করে?
  • None — কেবল যদি কন্ট্র্যাক্টে এর উল্লেখ থাকে। না থাকলে এটা কোনো ঘটনা নয় (ষষ্ঠ প্রশ্ন দেখুন)।
  • বড় মান — খুব লম্বা তালিকা, খুব বড় সংখ্যা। যার সীমা আছে, তাকে সীমাতেই টেস্ট করুন।
  • রাউন্ডিং — যেখানে হিসাব থেকে ফলে যত দশমিক ঘর রাখা হয় তার চেয়ে বেশি আসে।

সব মিলিয়ে এটাই একটি টেস্ট প্ল্যান: প্রতিটি ঘটনার জন্য এক সারি, কোনো টেস্ট কোডের আগেই লেখা। শেষ কলামটিই সবচেয়ে গুরুত্বপূর্ণ — যে সারির কারণ আপনি বলতে পারেন না, সেই সারির দরকার নেই।

| ঘটনা | ইনপুট (price, hour) | প্রত্যাশিত | কেন | | --- | --- | --- | --- | | সবচেয়ে ছোট বৈধ ঘণ্টা | (10, 0) | 10 | বৈধ পরিসরের প্রান্ত | | পুরো দামের শেষ ঘণ্টা | (10, 16) | 10 | ১৭:০০ প্রান্তের ঠিক নিচে | | ছাড়ের প্রথম ঘণ্টা | (10, 17) | 8.0 | ১৭:০০ প্রান্তের ওপরেই | | ছাড়ের শেষ ঘণ্টা | (10, 18) | 8.0 | ১৯:০০ প্রান্তের ঠিক নিচে | | আবার পুরো দামের প্রথম ঘণ্টা | (10, 19) | 10 | ১৯:০০ প্রান্তের ওপরেই — "অন্তর্ভুক্ত নয়" | | সবচেয়ে বড় বৈধ ঘণ্টা | (10, 23) | 10 | বৈধ পরিসরের প্রান্ত | | বিনামূল্যের পানীয় | (0, 17) | 0 | শূন্য দাম | | রাউন্ডিং | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992, দুই ঘর পর্যন্ত রাউন্ড | | ঘণ্টা খুব কম | (10, -1) | ValueError | পরিসরের ঠিক বাইরে | | ঘণ্টা খুব বেশি | (10, 24) | ValueError | পরিসরের ঠিক বাইরে | | ঋণাত্মক দাম | (-1, 12) | ValueError | অবৈধ ইনপুট |

আর পরিকল্পনাটি যাচাইয়ে রূপ নিল, কড়া-করা ফাংশনের বিপরীতে:

python
def drink_price(price, hour):
    """Return the price to pay for a drink.

    - price: a number >= 0. hour: a whole number 0-23.
    - 20% off from 17:00 up to, but not including, 19:00.
    - The result is rounded to 2 decimal places.
    - An hour outside 0-23 or a negative price raises ValueError.
    """
    if not 0 <= hour <= 23:
        raise ValueError(f"hour must be 0-23, got {hour}")
    if price < 0:
        raise ValueError(f"price must not be negative, got {price}")
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


def raises_value_error(price, hour):
    try:
        drink_price(price, hour)
    except ValueError:
        return True
    return False


assert drink_price(10, 0) == 10      # lowest valid hour
assert drink_price(10, 16) == 10     # last full-price hour before
assert drink_price(10, 17) == 8.0    # first discounted hour
assert drink_price(10, 18) == 8.0    # last discounted hour
assert drink_price(10, 19) == 10     # first full-price hour after
assert drink_price(10, 23) == 10     # highest valid hour
assert drink_price(0, 17) == 0       # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1)    # just below the valid range
assert raises_value_error(10, 24)    # just above the valid range
assert raises_value_error(-1, 12)    # negative price
print("11 checks passed")
text
11 checks passed

এগারোটি সারি, আর প্রতিটি সেখানে আছে এমন একটি কারণে যা আপনি মুখে বলতে পারেন।

৪. ওরাকল: কোড ছাড়াই উত্তর জানা

প্রতিটি যাচাই কোডের উত্তরকে একটি প্রত্যাশিত উত্তরের সাথে মেলায়। প্রত্যাশিত উত্তরের উৎসকে বলে ওরাকল (oracle), আর তার একটাই নিয়ম: সেটা যে কোড টেস্ট হচ্ছে সেই কোড হতে পারবে না।

এটা এত স্পষ্ট যে বলার দরকারই মনে হয় না — যতক্ষণ না দেখেন কত সহজে নিয়মটা ভাঙে। এখানে member_discount-এ একটি বাগ আছে — ১০%-এর বদলে ১% — আর দুটি যাচাই:

python
def member_discount(total, is_member):
    if total < 0:
        raise ValueError(f"total must not be negative, got {total}")
    if is_member and total >= 1000:
        return round(total * 0.01, 2)   # bug: 1%, not 10%
    return 0


# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)

# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")
text
self-check passed, expected was 20.0
Traceback (most recent call last):
  File "/home/you/shop/oracle.py", line 15, in <module>
    assert member_discount(2000, True) == 200
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

প্রথম যাচাইটি ফাংশনটিকে তার নিজের সাথেই মেলায়। সঠিক ফাংশনের জন্য পাস করে, এই ভাঙা ফাংশনের জন্য করে, আর কেউ যে সংস্করণই লিখুক তার জন্যও করে — এটা কখনো ফেল করতে পারে না, তাই কিছুই জানায় না।

কেউ এটা ঠিক এতটা খোলামেলাভাবে লেখে না। একই ভুল সাধারণত দুটি ছদ্মবেশের একটিতে আসে:

  • আউটপুট কপি করা। ফাংশনটি চালালেন, সে প্রিন্ট করল 20.0, আর আপনি 20.0 যাচাইয়ে বসিয়ে দিলেন। কোড যদি আগে থেকেই ভুল থাকে, যাচাইটি এখন বাগটাকেই পাহারা দেয়।
  • সূত্রের পুনরাবৃত্তি। প্রত্যাশিত মান লিখলেন 2000 * 0.01 — কোড যে হিসাব করে, ঠিক সেটাই। কোডের লজিকের কপি কোডের ভুলগুলোও ভাগ করে নেয়। (নিচে "কিছু ভাঙা অবস্থা ও তার সমাধান"-এ এটা চালিয়ে দেখানো হয়েছে।)

ভালো ওরাকল, মোটামুটি সবচেয়ে প্রচলিত থেকে কম প্রচলিতের ক্রমে:

  • হাতে হিসাব করা, কন্ট্র্যাক্ট থেকে। এমন ইনপুট বাছুন যাতে কাজটা সহজ হয়: 1873.41-এর বদলে 2000, যাতে "এর ১০%" মনে মনেই করা যায়।
  • স্পেসিফিকেশনের উদাহরণ। চাহিদায় যদি লেখা থাকে "1500-এর অর্ডারে 150 ছাড়", ওই সারিটিই একটি টেস্ট।
  • জানা তথ্য। পানি ফোটে 100 °C আর 212 °F-এ; খালি তালিকার দৈর্ঘ্য 0।
  • আলাদা, ভরসাযোগ্য একটি পদ্ধতি। একটি সহজতর, ধীরতর হিসাব, অথবা স্ট্যান্ডার্ড লাইব্রেরির এমন একটি ফাংশন যা একই কাজ অন্যভাবে করে:
python
import statistics


def average(scores):
    if not scores:
        raise ValueError("average() of an empty list")
    return sum(scores) / len(scores)


samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
    assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")
text
agrees with statistics.mean on 4 samples

statistics.mean অন্য মানুষেরা লিখেছেন, অন্যভাবে, তাই এর সাথে মিলে যাওয়ার একটা অর্থ আছে। (দশমিক ফল == দিয়ে মেলানো বিপদে ফেলতে পারে — পাইথনে 0.1 + 0.2 == 0.3 হলো False। এখানের মানগুলো নিরাপদ; ফ্লোট ঠিকভাবে কীভাবে মেলাতে হয়, তা পঞ্চম অধ্যায়ে।)

এর কোনো উপায়েই যদি প্রত্যাশিত উত্তর বের করতে না পারেন, সেটা টেস্টিংয়ের সমস্যা নয়। এর মানে, কোডটির কী করা উচিত তা আপনি এখনো জানেন না — কন্ট্র্যাক্টে ফিরে যান।

৫. কী কী জায়গামতো থাকতে হবে

এই অধ্যায়ের জন্য কেবল পাইথন 3। আপনার কোনটি আছে দেখে নিন:

text
$ python3 --version
Python 3.12.3

এই কোর্স পাইথন 3.12 ব্যবহার করে, আর 3.10 বা তার পরের যেকোনো ভার্সনে প্রায় সবটাই চলবে। উইন্ডোজে কমান্ডটি সাধারণত py --version।

পরের অধ্যায় থেকে, টেস্ট চলার আগে আরও তিনটি জিনিস জায়গামতো থাকতে হবে, আর প্রথম অধ্যায় প্রতিটি সেট আপ করে:

  • প্রজেক্টের জন্য একটি ভার্চুয়াল এনভায়রনমেন্ট, যাতে টেস্টিং টুলটি সেই পাইথনেই ইনস্টল হয় যেটা আপনার কোড চালায়।
  • তাতে pytest ইনস্টল করা।
  • টেস্টের জন্য একটি জায়গা। টেস্ট ফাইল থাকে কোডের পাশে, অথবা একটি tests/ ফোল্ডারে, নাম শুরু হয় test_ দিয়ে। প্রথম অধ্যায় শুরু করে প্রথমটি দিয়ে; তৃতীয় অধ্যায় দ্বিতীয়টিতে যায় আর কারণ ব্যাখ্যা করে।

পরের অধ্যায়গুলো নিজেদের প্রয়োজন যোগ করে — একটি কনফিগারেশন ফাইল, একটি প্লাগইন, ডিস্কে নমুনা ডেটা — আর প্রতিটি অধ্যায় সেগুলো তার নিজের "টেস্ট লেখার আগে" অংশে তালিকা করে।

৬. কী টেস্ট করবেন না, আর কখন থামবেন

যে টেস্ট আপনার কোডের কোনো ভুল ধরতে পারে না, সেটা লিখতে সময় নেয়, চালাতে সময় নেয়, রক্ষণাবেক্ষণে সময় নেয়, আর বিনিময়ে কিছুই দেয় না। এগুলো বাদ দিন:

  • পাইথন নিজে। sorted সাজায় কি না বা round রাউন্ড করে কি না — তা নয়। টেস্ট করুন যে আপনার ফাংশন ঠিক জিনিসটা ঠিক ক্রমে সাজায়।
  • অন্যের লাইব্রেরি। requests রিকোয়েস্ট পাঠায় কি না বা pandas CSV পড়ে কি না — তা নয়। টেস্ট করুন ফলটা নিয়ে আপনার কোড কী করে — আর কোনো লাইব্রেরির আচরণ নিয়ে নিশ্চিত হওয়ার দরকার হলে, আপনার ধারণাটি লিখে রাখে এমন একটি ছোট যাচাই-ই যথেষ্ট।
  • ভুল হওয়ার মতো জটিলতাই নেই এমন কোড। যে ফাংশন একটি ধ্রুবক ফেরত দেয়, বা যে ক্লাস কেবল তার আর্গুমেন্টগুলো জমা রাখে। ভুলটা কল্পনাই করতে না পারলে, টেস্টও তা ধরতে পারবে না।
  • যে আচরণের প্রতিশ্রুতি কেউ দেয়নি। কন্ট্র্যাক্ট যদি drink_price-কে স্ট্রিং দেওয়া নিয়ে কিছু না বলে, টেস্ট লিখলে সে আজ ঘটনাচক্রে যা করে কেবল সেটাকেই স্থির করে দেবে। হয় এটা কন্ট্র্যাক্টে যোগ করুন — তারপর টেস্ট করুন — নয়তো বাদ দিন।
  • কী-র বদলে কীভাবে। ফাংশনটি ভেতরে কোন সহায়ক ফাংশন ডাকল, বা কোন ক্রমে ধাপগুলো করল — তা নয়। টেস্ট করুন কী ঢুকল আর কী বেরোল; তাহলে ভেতরটা স্বাধীনভাবে নতুন করে লেখা যায়, আর টেস্টগুলো প্রমাণ করে যে তখনো কাজ করছে।

আর কতগুলো যথেষ্ট? কোনো নির্দিষ্ট সংখ্যা নেই, তবে একটা নিয়ম আছে। যথেষ্ট হয়েছে যখন:

  • প্রতিটি সমতুল্য শ্রেণির অন্তত একটি ঘটনা আছে,
  • প্রতিটি সীমানার দুই পাশেই একটি করে ঘটনা আছে,
  • কন্ট্র্যাক্ট যে ত্রুটির প্রতিশ্রুতি দেয়, তার প্রতিটির একটি ঘটনা আছে,
  • আর যত বাগ আপনি কখনো সারিয়েছেন, প্রতিটির নিজস্ব একটি ঘটনা আছে, যাতে সেটা চুপিচুপি ফিরে আসতে না পারে।

তারপর থামুন। যে নতুন ঘটনা যোগ করতে মন চায়, তার জন্য জিজ্ঞেস করুন: এটা এমন কোন ভুল ধরবে যা বাকিগুলো ধরত না? একটারও নাম বলতে না পারলে এটা পুনরাবৃত্তি। drink_price(10, 12)-এর পাশে drink_price(10, 11) নতুন কিছুই ধরে না।


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

এবার পুরো প্রক্রিয়াটি, শুরু থেকে শেষ, একটি বাস্তবসম্মত ফাংশনের ওপর।

চাহিদা, যেভাবে এসেছিল: "লোকাল পার্সেলের শিপিং 60, ন্যাশনালের 120। ভারী পার্সেলে খরচ বেশি — প্রতি বাড়তি কেজিতে লোকালে 20, ন্যাশনালে 30। 30 কেজির বেশি কিছু আমরা নিই না।"

প্রশ্নগুলো, আর তাদের যে উত্তর মিলল:

| প্রশ্ন | উত্তর | | --- | --- | | মূল দামে কতটুকু ধরা? | প্রথম কিলোগ্রাম — ঠিক 1 কেজিও মূল দামেই। | | 1.2 কেজির দাম কি 1 কেজির, নাকি 2 কেজির? | প্রতিটি শুরু হওয়া কিলোগ্রাম গণ্য: 1.2 কেজির দাম 2 কেজির। | | 30 কেজি নিজে কি গ্রহণযোগ্য? | হ্যাঁ। 30 চলবে; তার ওপরে যেকোনো কিছু প্রত্যাখ্যাত। | | 0 কেজি, বা ঋণাত্মক ওজন হলে? | প্রত্যাখ্যাত — ওটা ডেটা এন্ট্রির ভুল। | | জোন কীভাবে লেখা হয়? | হুবহু "local" বা "national"। অন্য যেকোনো কিছু প্রত্যাখ্যাত, "Local"-ও। | | কী ফেরত আসে? | খরচ, একটি পূর্ণসংখ্যা হিসেবে। কিছু প্রিন্ট বা সেভ হয় না। |

কন্ট্র্যাক্ট আর টেস্টযোগ্য ফাংশন, shipping.py। এটা সবকিছু আর্গুমেন্ট হিসেবে নেয় আর একটি সংখ্যা ফেরত দেয়; আলাদা করার মতো কোনো খোলস নেই, কারণ এতে কোনো ইনপুট বা আউটপুটই নেই:

python
import math

BASE = {"local": 60, "national": 120}       # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30


def shipping_cost(weight_kg, zone):
    """Return the shipping cost of one parcel, as a whole number.

    - weight_kg: a number. At or below 0, or above 30: ValueError.
    - zone: "local" or "national", exactly. Anything else: ValueError.
    - The base price covers the first kilogram (1 kg included).
    - Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
    - No side effects: reads nothing, prints nothing, changes nothing.
    """
    if zone not in BASE:
        raise ValueError(f"unknown zone: {zone!r}")
    if not 0 < weight_kg <= MAX_WEIGHT_KG:
        raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
    extra_kg = math.ceil(weight_kg) - 1
    return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]

math.ceil পরের পূর্ণসংখ্যায় ওপরের দিকে রাউন্ড করে — math.ceil(1.2) হলো 2 — যা ঠিক "প্রতিটি শুরু হওয়া কিলোগ্রাম গণ্য"।

ঘটনার টেবিল। ওজনের শ্রেণিগুলো: অবৈধ (0 ও তার নিচে), মূল কিলোগ্রাম (0-র ওপরে, 1 পর্যন্ত), বাড়তি কিলোগ্রাম (1-এর ওপরে, 30 পর্যন্ত), অবৈধ (30-এর ওপরে)। জোনগুলো: "local", "national", আর অন্য যেকোনো কিছু। প্রতিটি প্রত্যাশিত মান হাতে হিসাব করা, কন্ট্র্যাক্ট থেকে, কোড থেকে নয়:

| ঘটনা | ইনপুট | প্রত্যাশিত | কেন | | --- | --- | --- | --- | | হালকা পার্সেল | (0.5, "local") | 60 | মূল কিলোগ্রামের ভেতরে | | ঠিক 1 কেজি | (1, "local") | 60 | মূল দামের ওপরের প্রান্ত: 1 কেজি অন্তর্ভুক্ত | | 1 কেজির একটু বেশি | (1.2, "local") | 80 | শুরু হওয়া কিলোগ্রাম গণ্য: 60 + 1 × 20 | | পূর্ণ কেজি, লোকাল | (3, "local") | 100 | 60 + 2 × 20 | | পূর্ণ কেজি, ন্যাশনাল | (3, "national") | 180 | 120 + 2 × 30 — অন্য জোন | | ঠিক সীমায় | (30, "national") | 990 | 120 + 29 × 30; 30 গ্রহণযোগ্য | | শূন্য | (0, "local") | ValueError | নিচের প্রান্ত: 0 প্রত্যাখ্যাত | | ঋণাত্মক | (-2, "local") | ValueError | অবৈধ ইনপুট | | সীমার বেশি | (30.5, "local") | ValueError | 30-এর ঠিক ওপরে | | ভুল জোন | (2, "Local") | ValueError | হুবহু "local" নয় |

টেবিলে ইচ্ছে করেই যা নেই: ওজন None বা "2"। কন্ট্র্যাক্ট বলে ওজন একটি সংখ্যা, তাই এগুলো তার বাইরে — 0-এর সাথে তুলনা করার সময় পাইথন নিজেই TypeError তোলে, আর এর বাইরে আমরা কোনো প্রতিশ্রুতি দিই না। math.ceil-ও নয়: ওটা পাইথনের, আমাদের নয়। আর (3, "local")-এর পাশে (2, "local")-ও নয়: একই শ্রেণি, কোডের একই লাইন, ধরার মতো নতুন কিছু নেই।

যাচাইগুলো, check_shipping.py — টেবিলটি, সারির পর সারি:

python
from shipping import shipping_cost


def raises_value_error(weight_kg, zone):
    """True if shipping_cost raises ValueError for these arguments."""
    try:
        shipping_cost(weight_kg, zone)
    except ValueError:
        return True
    return False


# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990

# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")

print("all 10 checks passed")
text
$ python check_shipping.py
all 10 checks passed

এবার ভাঙুন। কয়েক মাস পরে কেউ shipping.py গুছিয়ে লিখল, import math বাদ দিল আর math.ceil(weight_kg)-এর জায়গায় লিখল int(weight_kg)। পড়তে ভালো লাগে, আর কোনো ত্রুটি ছাড়াই চলে। যাচাইগুলো আবার চালান:

text
$ python check_shipping.py
Traceback (most recent call last):
  File "/home/you/shop/check_shipping.py", line 14, in <module>
    assert shipping_cost(0.5, "local") == 60
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

পরিকল্পনা তার কাজ করেছে: যাচাইগুলো চালানোর মুহূর্তেই পরিবর্তনটি ধরা পড়েছে। int ওপরে রাউন্ড না করে দশমিক অংশ কেটে ফেলে, তাই 0.5 কেজি হয়ে গেল int(0.5) - 1, অর্থাৎ -1 বাড়তি কিলোগ্রাম।

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

  • আসল মান কত ছিল? রিপোর্ট লাইনটি দেখায়, সংখ্যাটি নয়। এটা যে 40 ছিল তা জানতে আপনাকে নিজে shipping_cost(0.5, "local") চালাতে হবে।
  • আর কী ভেঙেছে? স্ক্রিপ্টটি প্রথম ব্যর্থতাতেই থেমে গেছে। 1.2 কেজির ঘটনাটিও ভুল — এখন 80-এর বদলে দেয় 60 — কিন্তু সেই যাচাইটি চলেইনি।
  • কোন প্রতিশ্রুতিগুলো টিকে আছে? ত্রুটির ঘটনাগুলো যে এখনো পাস করে, তা কিছুই জানায় না।
  • আর শুধু "এটা ত্রুটি তুলবে" বলার জন্য ত্রুটির যাচাইগুলোতে try/except-সহ একটি সহায়ক ফাংশন লেগেছে।

ভাবনাটাই ছিল কঠিন অংশ, আর সেটা হয়ে গেছে: কন্ট্র্যাক্ট, টেবিল, ওরাকল। যা বাকি তা হলো একটি ভালো রানার — যে নিজে থেকে যাচাইগুলো খুঁজে নেয়, একটি ব্যর্থ হলেও প্রতিটি চালায়, প্রত্যাশিত মানের পাশে আসল মান দেখায়, আর এক লাইনেই ত্রুটি যাচাই করে। সেটাই pytest, আর তা ইনস্টল করা পরের অধ্যায়ে।


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

এগুলো পাইথনের ত্রুটি নয়, পরিকল্পনার ভুল, তাই লক্ষণগুলোও চুপচাপ: সাধারণত এমন একটি যাচাই যা পাস করা উচিত নয়, অথচ পাস করে।

প্রত্যাশিত মান কোডের নিজের সূত্রেরই পুনরাবৃত্তি যাচাইটি লেখা হয়েছে ফাংশন যে হিসাব করে, ঠিক সেটাই করে। এখানে ফাংশনে ওপরের সেই int বাগটি আছে, আর যাচাইয়েও আছে:

python
def shipping_cost(weight_kg, zone):
    # Local zone only, to keep the example short. Bug: int() instead of math.ceil().
    return 60 + (int(weight_kg) - 1) * 20


# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")

# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80
text
passed
Traceback (most recent call last):
  File "/home/you/shop/rederive.py", line 11, in <module>
    assert shipping_cost(1.2, "local") == 80
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

"ইমপ্লিমেন্টেশন টেস্ট করা" দেখতে এমনই: যাচাইটি কোড কী প্রতিশ্রুতি দেয় তা না বলে, কোড কীভাবে কাজ করে তার প্রতিচ্ছবি আঁকে। প্রত্যাশিত মানটি কন্ট্র্যাক্ট থেকে একটি সাদামাটা সংখ্যা হিসেবে লিখুন।

প্রত্যাশিত মান আউটপুট থেকে কপি করা ফাংশনটি চালালেন, দেখলেন 20.0, আর যাচাইয়ে বসিয়ে দিলেন। পাস করে — আর 20.0 ভুল হলে, যাচাইটি এখন বাগটাকে রক্ষা করে। কোড চালানোর আগেই প্রতিটি প্রত্যাশিত মান হিসাব করে রাখুন; আপনার সংখ্যা আর কোডের সংখ্যা না মিললে, কিছু লিখে রাখার আগে খুঁজে বের করুন কোনটি ভুল।

পরিকল্পনায় কোনো সীমানার ঘটনা নেই সব যাচাই পাস করে, আর বাগটা বসে আছে প্রান্তে: পাঁচটা বাজে, ঠিক 1 কেজি, বয়স 12। কেবল "সাধারণ" সারি দিয়ে বানানো টেবিলই এর সাধারণ কারণ। কন্ট্র্যাক্টে যত <, <=, "পর্যন্ত", "থেকে" আর "অন্তত" আছে, প্রতিটির দুই পাশে একটি করে ঘটনা থাকা উচিত।

ফাংশনটিকে যাচাই থেকে ডাকা যায় না EOFError: EOF when reading a line মানে ফাংশনটি কিবোর্ডের অপেক্ষায় থাকে; returned: None মানে সে উত্তর ফেরত না দিয়ে প্রিন্ট করেছে; সকালে পাস আর সন্ধ্যায় ফেল করা যাচাই মানে সে ঘড়ি পড়ে। তিনটিই ডিজাইনের সমস্যা, আর দ্বিতীয় অংশের বিশুদ্ধ-কেন্দ্র-ও-পাতলা-খোলস পুনর্বিন্যাস দিয়ে সারে।

SyntaxWarning: assertion is always true, perhaps remove parentheses? assert আর তার বার্তাকে বন্ধনীতে ঘিরলে দুটো মিলে একটি টাপল হয়ে যায়, আর খালি নয় এমন টাপল সবসময়ই সত্য:

python
def drink_price(price, hour):
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")
text
/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
  assert (drink_price(10, 17) == 999, "happy hour price")
passed?!

999 স্পষ্টতই ভুল, অথচ যাচাইটি পাস করল। লিখুন assert drink_price(10, 17) == 999, "happy hour price" — বন্ধনী ছাড়া — তাহলে যেমন হওয়া উচিত তেমনই ফেল করবে। পাইথন এখানে আপনাকে সতর্ক করে; সতর্কবার্তাটি স্ক্রল করে পার না হয়ে পড়ুন।

দশমিক ফল == দিয়ে মেলানো একটি যাচাই সঠিক উত্তরেও ফেল করে পাইথনে 0.1 + 0.2 == 0.3 হলো False, কারণ 0.1 + 0.2 আসলে 0.30000000000000004। পরিকল্পনার শিক্ষা হলো, ফল কীভাবে রাউন্ড হবে তা কন্ট্র্যাক্টে বলে দেওয়া, যেমন member_discount আর drink_price করে। ফ্লোট মেলানোর জন্য pytest-এর টুল পঞ্চম অধ্যায়ে।