অধ্যায় 16

টেস্ট ডিজাইন আর TDD — ভালো টেস্ট কেমন হয়

টেস্ট লেখার আগের পরিকল্পনা, Arrange-Act-Assert, ইমপ্লিমেন্টেশন নয় আচরণ টেস্ট করা, red-green-refactor, Hypothesis দিয়ে property-based টেস্ট, flaky টেস্টের কারণ ও সমাধান, আর প্রতিটি বাগের জন্য একটি regression টেস্ট।

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

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

এই কোর্সে যা যা শেখানোর কথা ছিল, তার সবই এখন আপনার জানা: assert, raises, parametrize, fixture, mock, marker, configuration, coverage, plugin। অথচ এর সবকটি ব্যবহার করেও এমন টেস্ট লিখে ফেলা পুরোপুরি সম্ভব, যা উপকারের চেয়ে কষ্টই বেশি দেয়।

এই যে একটি ছোট শপিং কার্ট, সাথে দুটি টেস্ট, দুটোই পাস করে:

python
class Cart:
    def __init__(self):
        self._items = []

    def add(self, name, price, quantity=1):
        self._items.append((name, price, quantity))

    def total(self):
        return sum(price * quantity for _, price, quantity in self._items)
python
from cart import Cart


def test_add():
    cart = Cart()
    cart.add("pen", 15.0, 2)
    assert cart._items == [("pen", 15.0, 2)]


def test_total():
    cart = Cart()
    cart.add("pen", 15.0, 2)
    cart.add("bag", 850.0)
    assert cart.total() == 880.0
text
..                                                                       [100%]
2 passed in 0.01s

এবার কেউ একজন কার্টটিকে উন্নত করলেন। একই জিনিস দুবার যোগ করলে সেটি এক লাইনে মিশে যাওয়া উচিত, তাই তালিকাটি হয়ে গেল নাম-ভিত্তিক একটি ডিকশনারি। কার্ট এখনো জিনিস যোগ করে, এখনো ঠিকঠাক মোট হিসাব করে — একজন ক্রেতা টের পাবেন এমন কিছুই বদলায়নি:

python
class Cart:
    def __init__(self):
        self._lines = {}

    def add(self, name, price, quantity=1):
        _, already = self._lines.get(name, (price, 0))
        self._lines[name] = (price, already + quantity)

    def total(self):
        return sum(price * quantity for price, quantity in self._lines.values())
text
F.                                                                       [100%]
=================================== FAILURES ===================================
___________________________________ test_add ___________________________________

    def test_add():
        cart = Cart()
        cart.add("pen", 15.0, 2)
>       assert cart._items == [("pen", 15.0, 2)]
               ^^^^^^^^^^^
E       AttributeError: 'Cart' object has no attribute '_items'

test_cart.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_add - AttributeError: 'Cart' object has no attribut...
1 failed, 1 passed in 0.01s

টেস্ট লাল, অথচ কোনো বাগ নেই। টেস্টটি দেখছিল না কার্ট কী করে; দেখছিল কার্টটি কীভাবে বানানো। এমন টেস্ট কোড উন্নত করলে ফেল করে, আর কোড ভেঙে দিলে চুপ থাকে — তার আসল কাজের ঠিক উল্টো।

এই শেষ অধ্যায়টি আরেকটি টুল নিয়ে নয়। এটি বিচারবুদ্ধি নিয়ে: ভালো টেস্ট দেখতে কেমন, কীভাবে টেস্টকে কোডের পথ দেখাতে দেবেন, কীভাবে কম্পিউটারকে দিয়ে টেস্ট কেস বানিয়ে নেবেন, আর কীভাবে টেস্টের এলোমেলো ফেল করা বন্ধ করবেন।

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

  • টেস্ট লেখার আগে তার পরিকল্পনা করতে: contract, কেসগুলো, আর কী বাদ রাখবেন
  • একটি টেস্টকে Arrange, Act, Assert — এই তিন ভাগে সাজাতে, আর তার নাম একটি বাক্যের মতো করে রাখতে
  • আচরণের টেস্ট আর implementation-এর টেস্ট আলাদা করে চিনতে, আর দ্বিতীয়টিকে প্রথমটিতে রূপান্তর করতে
  • red-green-refactor চক্রে কাজ করতে, প্রতিটি red ধাপে একটি সত্যিকারের ফেল দেখে
  • Hypothesis দিয়ে property-based টেস্ট লিখতে, আর shrink করা ফেল-করা উদাহরণ পড়তে
  • flaky টেস্টের চারটি সাধারণ কারণ খুঁজে বের করে ঠিক করতে
  • প্রতিটি বাগ রিপোর্টকে একটি regression টেস্টে রূপ দিতে

আগে যা জানা লাগবে: async টেস্ট, hook ও plugin।


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

বেশিরভাগ খারাপ টেস্ট খারাপভাবে লেখা নয়। সেগুলো খারাপভাবে পরিকল্পিত — কোড কী প্রতিশ্রুতি দেয়, তা কেউ ঠিক করার আগেই লেখা। তাই টেস্টের কোড লেখার আগে চারটি প্রশ্ন।

এই অধ্যায় জুড়ে আমরা একটি ছোট ফাংশন test-first পদ্ধতিতে বানাব: slugify, যা "Café au lait"-এর মতো একটি পোস্টের শিরোনামকে URL-এর টুকরো "cafe-au-lait"-এ পরিণত করে।

১. contract কী? এক-দুই বাক্যে বলুন, যেভাবে caller দেখে: একটি শিরোনাম (একটি `str`) দিলে একটি slug ফেরত দেবে: ছোট হাতের ASCII অক্ষর আর অঙ্ক, শব্দগুলো একটি করে হাইফেন দিয়ে জোড়া, দুই প্রান্তের কোনোটিতে হাইফেন নেই। অ্যাকসেন্টযুক্ত অক্ষর তার সাধারণ অক্ষরে পরিণত হয়। কোনো side effect নেই — কোনো ফাইল, ঘড়ি বা নেটওয়ার্ক ছোঁয় না। খেয়াল করুন contract কী উল্লেখ করে না: regular expression, Unicode normalisation, helper ফাংশন। ওগুলো হলো কাজটা কীভাবে করা হচ্ছে, আর সেগুলো বদলানোর স্বাধীনতা আছে।

২. আগে থেকে কী প্রস্তুত থাকতে হবে? টুলগুলো ইনস্টল করা একটি virtual environment, আর টেস্ট থেকে import করা যায় এমন কোড:

text
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pytest hypothesis

তারপর পাশাপাশি দুটি ফাইল — slugs.py আর test_slugs.py — আর pytest চালাবেন সেই ফোল্ডার থেকেই, যাতে from slugs import slugify কাজ করে। এই বিষয়ে কোনো fixture, অস্থায়ী ফাইল বা environment variable লাগে না। এটাও লক্ষ করার মতো: pure ফাংশন টেস্ট করা পৃথিবীর সবচেয়ে সহজ কাজ, আর সেটাই লজিককে pure ফাংশনে সরিয়ে রাখার একটি ভালো কারণ।

৩. কোন কোন কেস? আগে স্বাভাবিক পথ, তারপর প্রান্তের কেস, তারপর অবৈধ ইনপুট, তারপর সীমানা:

| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | সাধারণ শব্দ | "Hello World" | "hello-world" | | বিরামচিহ্ন | "Hello, World!" | "hello-world" | | অ্যাকসেন্টযুক্ত অক্ষর | "Café au lait" | "cafe-au-lait" | | দুই প্রান্তে বাড়তি স্পেস | " Hello World " | "hello-world" | | অঙ্কগুলো থেকে যায় | "Top 10 Tips" | "top-10-tips" | | কাজে লাগার মতো কিছুই নেই | "!!!" | ? |

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

৪. কী টেস্ট করবেন না? পাইথনের re মডিউল, unicodedata আর str.lower()-এর নিজেদের টেস্ট আগে থেকেই আছে; সেগুলো আবার টেস্ট করলে কেবল আপনার গতি কমবে। Private helper (_ দিয়ে শুরু যেকোনো কিছু) পৌঁছানো হয় slugify-এর মাধ্যমে, কখনো সরাসরি নয়। আর আপনি হাতে প্রতিটি Unicode অক্ষরের তালিকা করার চেষ্টা করবেন না — সেটি একটি property-based টেস্টের কাজ, এই অধ্যায়ের পরের দিকে।

টেবিলটিই টেস্ট হয়ে ওঠে। নিচের সবকিছু সেই পরিকল্পনারই বাস্তবায়ন।

Arrange, Act, Assert

প্রতিটি ভালো টেস্টে একই তিনটি অংশ থাকে, একই ক্রমে:

  • Arrange — টেস্টের যে পরিবেশ দরকার, তা তৈরি করুন
  • Act — যে একটি কাজ টেস্ট করা হচ্ছে, সেটি করুন
  • Assert — কী বের হলো তা যাচাই করুন

এই যে কার্টটি আবার টেস্ট করা হলো — এবার সে কী প্রতিশ্রুতি দেয় তা দিয়ে, সে কীভাবে জিনিস রাখে তা দিয়ে নয়:

python
from cart import Cart


def test_an_empty_cart_totals_zero():
    cart = Cart()

    assert cart.total() == 0


def test_total_is_price_times_quantity_summed_over_items():
    # Arrange
    cart = Cart()
    cart.add("pen", 15.0, 2)
    cart.add("bag", 850.0)

    # Act
    total = cart.total()

    # Assert
    assert total == 880.0


def test_adding_the_same_item_twice_adds_up_the_quantity():
    cart = Cart()
    cart.add("pen", 15.0, 2)

    cart.add("pen", 15.0, 1)

    assert cart.total() == 45.0

তালিকা-ভিত্তিক কার্টের বিরুদ্ধে, আর আবার ডিকশনারি-ভিত্তিক কার্টের বিরুদ্ধে:

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

একই টেস্ট, দুটি implementation, দুবারই সব সবুজ। ঠিক এই গুণটিই আপনি চান: যে refactor আচরণ অপরিবর্তিত রাখে, সেটি টেস্টগুলোকেও সবুজ রাখা উচিত। মাঝের টেস্টের কমেন্টগুলো কেবল গড়নটা দেখানোর জন্য; বেশিরভাগ মানুষ সেগুলো বাদ দিয়ে তিনটি অংশকে ফাঁকা লাইন দিয়ে আলাদা করেন, যেমনটা তৃতীয় টেস্টে।

Act একটিমাত্র ধাপ হওয়া কেন জরুরি? কারণ টেস্ট ফেল করলে আপনি জানতে চান কোন জিনিসটা ভাঙল। Act-এ পাঁচটি কল থাকলে একটি ফেল পাঁচজন সন্দেহভাজনের দিকে আঙুল তোলে।

আচরণ টেস্ট করুন, implementation নয়

মোটা দাগের নিয়ম: একটি টেস্ট কেবল তা-ই ব্যবহার করতে পারে, যা একজন caller ব্যবহার করতে পারে। কার্টের ক্ষেত্রে সেটি Cart(), .add() আর .total()। _items নয়, _lines নয়। শুরুর আন্ডারস্কোর হলো পাইথনের ভাষায় «এটা আমার নিজের, আর আমি এটা বদলাতে পারি»।

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

একটি টেস্ট যে implementation-এর সাথে বাঁধা, তার দুটি লক্ষণ:

  • সে _ দিয়ে শুরু হওয়া attribute পড়ে, অথবা কোড ভেতরে ভেতরে যে ফাংশন কল করে তা mock করে
  • এমন একটি refactor, যা কোনো ব্যবহারকারী টের পাবেন না, তাকে লাল করে দেয়

এখানে mock নিয়ে একটি কথা বলা দরকার। এগারো অধ্যায়ের mock সঠিক টুল একটি সীমানায় — নেটওয়ার্ক, ঘড়ি, একটি পেমেন্ট সার্ভিস। আপনার নিজের কোডের ভেতরে ব্যবহার করলে («assert করো যে _calculate এই আর্গুমেন্টগুলো দিয়ে কল হয়েছে») সেগুলো অন্য নামে implementation টেস্টই হয়ে দাঁড়ায়।

বাক্যের মতো পড়া যায় এমন নাম, আর ফেল করার একটিমাত্র কারণ

কার্টের তিনটি টেস্ট -v দিয়ে চালান:

text
test_cart_behaviour.py::test_an_empty_cart_totals_zero PASSED            [ 33%]
test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items PASSED [ 66%]
test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity PASSED [100%]

শুধু নামগুলো পড়লেই কার্টের একটি specification পেয়ে যান। test_add আর test_total কেবল বলত কোন মেথড ছোঁয়া হয়েছে। একটি ভালো নাম বলে পরিস্থিতি আর প্রত্যাশিত ফল: একটি খালি কার্টের মোট শূন্য। নামটি লম্বা; তাতে সমস্যা নেই। এটি আপনাকে কখনো টাইপ করতে হয় না, আর প্রতিবার ফেল করলে এটিই পড়েন।

নিয়মের দ্বিতীয় অর্ধেক হলো ফেল করার একটিমাত্র কারণ। এই যে তার উল্টোটা — একটি টেস্ট যা সবকিছু যাচাই করে, চালানো হয়েছে একটি বাগওয়ালা কার্টের বিরুদ্ধে (যেটি quantity উপেক্ষা করে):

python
from cart import Cart


def test_cart():
    cart = Cart()
    assert cart.total() == 0
    cart.add("pen", 15.0, 2)
    cart.add("bag", 850.0)
    assert cart.total() == 880.0
    cart.add("pen", 15.0, 1)
    assert cart.total() == 895.0
text
$ pytest -q --tb=no
F                                                                        [100%]
=========================== short test summary info ============================
FAILED test_one_big.py::test_cart - assert 865.0 == 880.0
1 failed in 0.01s

(--tb=no traceback লুকিয়ে কেবল সারসংক্ষেপটুকু রাখে — যে অংশটি আপনি প্রথমে পড়েন।) ফেল করেছে test_cart — যা কিছুই বলে না — 865.0 == 880.0 নিয়ে, যার মানে এখন আপনাকে মাথা খাটিয়ে বের করতে হবে। আর তৃতীয় assertion কখনো চলেইনি, তাই জানেন না সেটি পাস করত কি না। একই বাগ, তিনটি নির্দিষ্ট-লক্ষ্যের টেস্টের বিরুদ্ধে:

text
$ pytest -q --tb=no
.FF                                                                      [100%]
=========================== short test summary info ============================
FAILED test_cart_behaviour.py::test_total_is_price_times_quantity_summed_over_items
FAILED test_cart_behaviour.py::test_adding_the_same_item_twice_adds_up_the_quantity
2 failed, 1 passed in 0.01s

একটি traceback খোলার আগেই সারসংক্ষেপটি রোগনির্ণয়ের মতো পড়া যায়: খালি কার্ট ঠিক আছে, কিন্তু পরিমাণের হিসাবে গোলমাল হচ্ছে। «ফেল করার একটিমাত্র কারণ» মানে একটিমাত্র assert লাইন নয় — একই ফলের দুটি দিক যাচাই করা দুটি assert ঠিক আছে। এর মানে প্রতিটি টেস্টে একটি আচরণ।

টেস্ট পিরামিড

টেস্ট নানা আকারের হয়:

  • Unit টেস্ট একটি ফাংশন বা ক্লাস যাচাই করে, চারপাশে সত্যিকারের কিছু ছাড়াই। প্রতিটি কয়েক মিলিসেকেন্ডের। এগুলো থাকে হাজারে হাজারে।
  • Integration টেস্ট যাচাই করে টুকরোগুলো ঠিকঠাক জোড়া লাগে কি না: সত্যিকারের ডেটাবেস, সত্যিকারের ফাইল সিস্টেম, test client দিয়ে সত্যিকারের HTTP অ্যাপের বিরুদ্ধে আপনার কোড। ধীরগতির; থাকে কয়েক ডজন থেকে কয়েকশো।
  • End-to-end টেস্ট পুরো সিস্টেমকে চালায় একজন ব্যবহারকারীর মতো করে — একটি ব্রাউজার, একটি deploy করা API। ধীর আর ভঙ্গুর; থাকে হাতে গোনা কয়েকটি, যেগুলো সেই পথগুলো ঢেকে রাখে যেখান থেকে টাকা আসে।

সংখ্যা অনুযায়ী আঁকলে এটি একটি পিরামিড: নিচে unit টেস্টের চওড়া ভিত, উপরে end-to-end টেস্টের সরু চূড়া। কারণটা খরচ। একটি unit টেস্ট ফেল করলে সেটি কয়েকটি লাইনের দিকে আঙুল তোলে। একটি end-to-end টেস্ট ফেল করলে সেটি গোটা সিস্টেমের দিকে আঙুল তোলে। প্রতিটি যাচাইকে পিরামিডের যত নিচে সম্ভব নামিয়ে দিন, আর চূড়াটা রাখুন সেই জিনিসের জন্য, যা কেবল চূড়া থেকেই দেখা যায়।

Red, green, refactor

Test-driven development (TDD) চলে একটি আঁটসাঁট চক্রে:

  1. Red — এমন আচরণের জন্য একটি ছোট টেস্ট লিখুন, যা এখনো নেই, আর সেটিকে ফেল করতে দেখুন
  2. Green — সেটি পাস করানোর জন্য যতটুকু কম কোড লাগে, ততটুকুই লিখুন
  3. Refactor — কোড গুছিয়ে নিন, পুরো সময় টেস্টগুলো সবুজ রেখে

ফেল করতে দেখা কেন? কারণ যে টেস্টকে কখনো ফেল করতে দেখেননি, সেটি হয়তো কিছুই টেস্ট করছে না। red ধাপটি প্রমাণ করে যে ফিচারের অনুপস্থিতি টেস্টটি ধরতে পারে।

টেবিলের পরিকল্পনা ধরে আমরা এগোব, এক সারি করে।

Red. প্রথম সারি, slugs.py তৈরির আগেই:

python
from slugs import slugify


def test_lowercases_and_joins_words_with_hyphens():
    assert slugify("Hello World") == "hello-world"
text
==================================== ERRORS ====================================
________________________ ERROR collecting test_slugs.py ________________________
ImportError while importing test module '/home/you/blog/test_slugs.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugs.py:1: in <module>
    from slugs import slugify
E   ModuleNotFoundError: No module named 'slugs'
=========================== short test summary info ============================
ERROR test_slugs.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

এটি একেবারে ঠিকঠাক একটি red। টেস্টটি এমন কিছু চাইছে, যা নেই।

Green. পাস করানোর মতো সবচেয়ে কম কোড — আর সত্যিই সবচেয়ে কম:

python
def slugify(title):
    return title.lower().replace(" ", "-")
text
.                                                                        [100%]
1 passed in 0.01s

মনে হতে পারে ফাঁকি দেওয়া হচ্ছে। এটি ইচ্ছাকৃত: কোডটি কেবল ততটুকুই করে, যতটুকু এ পর্যন্ত কোনো টেস্ট দাবি করেছে। এর বেশি যা কিছু, তা এমন কোড, যা কোনো টেস্ট যাচাই করছে না।

Red. টেবিলের দ্বিতীয় সারি:

python
def test_drops_punctuation():
    assert slugify("Hello, World!") == "hello-world"
text
.F                                                                       [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________

    def test_drops_punctuation():
>       assert slugify("Hello, World!") == "hello-world"
E       AssertionError: assert 'hello,-world!' == 'hello-world'
E
E         - hello-world
E         + hello,-world!
E         ?      +      +

test_slugs.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_drops_punctuation - AssertionError: assert 'hello,...
1 failed, 1 passed in 0.01s

? লাইনটি ঠিক সেই দুটি অক্ষর চিহ্নিত করে, যেগুলো থাকার কথা নয়।

Green. এখন একটি স্পেস বদলানোর কৌশল আর যথেষ্ট নয়। «অক্ষর বা অঙ্ক নয়» এমন প্রতিটি টানা অংশকে একটি হাইফেন দিয়ে বদলে দিন, তারপর দুই প্রান্ত থেকে হাইফেন ছেঁটে ফেলুন:

python
import re


def slugify(title):
    return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
text
..                                                                       [100%]
2 passed in 0.01s

বাড়তি স্পেস আর অঙ্কের সারিগুলো এই কোডেই পাস করত। তবুও সেগুলো যোগ করুন — সেগুলো contract-এর অংশ — কিন্তু জেনে রাখুন, প্রথমবার চালিয়েই সবুজ হওয়া একটি টেস্ট এইমাত্র লেখা কোডের ব্যাপারে কিছুই প্রমাণ করেনি। প্রমাণ করে কেবল আগে-লাল-পরে-সবুজ টেস্ট।

Red. অ্যাকসেন্টযুক্ত অক্ষর:

python
def test_turns_accented_letters_into_plain_ones():
    assert slugify("Café au lait") == "cafe-au-lait"
text
..F                                                                      [100%]
=================================== FAILURES ===================================
_________________ test_turns_accented_letters_into_plain_ones __________________

    def test_turns_accented_letters_into_plain_ones():
>       assert slugify("Café au lait") == "cafe-au-lait"
E       AssertionError: assert 'caf-au-lait' == 'cafe-au-lait'
E
E         - cafe-au-lait
E         ?    -
E         + caf-au-lait

test_slugs.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_slugs.py::test_turns_accented_letters_into_plain_ones - Assertion...
1 failed, 2 passed in 0.01s

é a-z-এর মধ্যে নেই, তাই বিরামচিহ্নের সাথে সেটিও ফেলে দেওয়া হয়েছে।

Green. Unicode-এর "NFKD" normalisation é-কে ভেঙে e আর একটি আলাদা অ্যাকসেন্ট চিহ্নে পরিণত করে; তারপর "ignore" দিয়ে ASCII-তে encode করলে চিহ্নটি বাদ পড়ে আর e থেকে যায়:

python
import re
import unicodedata


def slugify(title):
    plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
    return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-")
text
...                                                                      [100%]
3 passed in 0.01s

Refactor. কাজ করছে, কিন্তু ফাংশনের ভেতরটা একটি ঘন লাইন। প্রতিটি ধাপকে একটি নাম দিন, pattern একবারই compile করুন, আর type hint যোগ করুন — কোনো আচরণ না বদলে:

python
import re
import unicodedata

NOT_ALLOWED = re.compile(r"[^a-z0-9]+")


def _to_ascii(text: str) -> str:
    """Split accented letters into letter + accent, then drop the accents."""
    decomposed = unicodedata.normalize("NFKD", text)
    return decomposed.encode("ascii", "ignore").decode("ascii")


def slugify(title: str) -> str:
    words = NOT_ALLOWED.sub("-", _to_ascii(title).lower())
    return words.strip("-")
text
test_slugs.py::test_lowercases_and_joins_words_with_hyphens PASSED       [ 33%]
test_slugs.py::test_drops_punctuation PASSED                             [ 66%]
test_slugs.py::test_turns_accented_letters_into_plain_ones PASSED        [100%]

============================== 3 passed in 0.01s ===============================

এই ধাপেই TDD-র লাভটা ঘরে আসে। আপনি স্বাধীনভাবে কোড নতুন করে সাজাতে পেরেছেন, কারণ টেস্টগুলো আচরণ যাচাই করে; সেগুলো যদি _to_ascii বা regex-এর ভেতরে হাত দিত, refactor সেগুলোকে ভেঙে দিত।

Hypothesis দিয়ে property-based টেস্টিং

এ পর্যন্ত প্রতিটি টেস্টে একটি করে বানানো উদাহরণ: "Hello World", "Café au lait"। উদাহরণগুলো আপনার কল্পনাশক্তির চেয়ে ভালো হতে পারে না, আর আপনার কল্পনার অন্ধ জায়গাগুলো এইমাত্র লেখা কোডেরও অন্ধ জায়গা।

একটি property হলো এমন একটি বক্তব্য, যা প্রতিটি ইনপুটের জন্য সত্য। Hypothesis ইনপুটগুলো তৈরি করে — ডিফল্টভাবে প্রতি টেস্টে একশোটি — আর এমন একটি খুঁজে বের করার জোর চেষ্টা করে, যা বক্তব্যটিকে ভেঙে দেয়। @given বলে ইনপুট কোথা থেকে আসবে; strategies (সবসময় st নামে import করা হয়) তাদের গড়ন বর্ণনা করে:

python
from hypothesis import given
from hypothesis import strategies as st


@given(
    prices=st.lists(st.integers(min_value=0, max_value=10_000), max_size=20),
    discount=st.integers(min_value=0, max_value=100),
)
def test_a_discount_never_raises_the_total(prices, discount):
    total = sum(prices)

    discounted = total * (100 - discount) // 100

    assert 0 <= discounted <= total
text
.                                                                        [100%]
1 passed in 0.01s

একটিমাত্র বিন্দু, কিন্তু এর ভেতর দিয়ে গেছে দামের একশোটি তালিকা আর একশোটি ছাড়। অন্য যেসব strategy আপনি প্রায়ই ব্যবহার করবেন: st.text(), st.floats(), st.booleans(), st.sampled_from([...]), st.dictionaries(...), st.builds(...)।

slugify-এর কী কী property আছে? একটি এলোমেলো স্ট্রিংয়ের slug ঠিক কী হবে তা বলতে পারবেন না, কিন্তু বলতে পারবেন সেটি দেখতে কেমন হতে বাধ্য, আর একটি slug-কে আবার slugify করলে কিছুই বদলায় না:

python
import re

from hypothesis import given
from hypothesis import strategies as st

from slugs import slugify


@given(st.text())
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
    slug = slugify(title)

    assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*|", slug)


@given(st.text())
def test_slugifying_a_slug_changes_nothing(title):
    once = slugify(title)

    assert slugify(once) == once
text
..                                                                       [100%]
2 passed in 0.01s

st.text() তৈরি করে ইমোজি, চীনা লেখা, control character, খালি স্ট্রিং — এমন সব ইনপুট, যা আপনি কখনো পরিকল্পনার টেবিলে লিখতেন না।

একটি round trip একটি সত্যিকারের বাগ খুঁজে পায়

সবচেয়ে ফলপ্রসূ property হলো round trip: কোনো কিছু encode করে আবার decode করা গেলে, encode করা জিনিসটি decode করলে মূলটিই ফিরে আসতে হবে। এই যে একটি run-length encoder — "aaab" হয়ে যায় "3a1b" — সাথে দুটি উদাহরণ-টেস্ট, যেগুলো পাস করে:

python
import re


def encode(text: str) -> str:
    """'aaab' -> '3a1b': each run of a character becomes count + character."""
    out = []
    for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
        run = match.group(0)
        out.append(f"{len(run)}{run[0]}")
    return "".join(out)


def decode(encoded: str) -> str:
    return "".join(char * int(count) for count, char in re.findall(r"(\d+)(\D)", encoded))
python
from hypothesis import given
from hypothesis import strategies as st

from rle import decode, encode


def test_encode_counts_each_run():
    assert encode("aaab") == "3a1b"


def test_decode_expands_each_run():
    assert decode("3a1b") == "aaab"


@given(st.text())
def test_decoding_an_encoding_gives_back_the_original(text):
    assert decode(encode(text)) == text
text
..F                                                                      [100%]
=================================== FAILURES ===================================
______________ test_decoding_an_encoding_gives_back_the_original _______________

    @given(st.text())
>   def test_decoding_an_encoding_gives_back_the_original(text):
                   ^^^

test_rle.py:16:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

text = '0'

    @given(st.text())
    def test_decoding_an_encoding_gives_back_the_original(text):
>       assert decode(encode(text)) == text
E       AssertionError: assert '' == '0'
E
E         - 0
E       Failing test case: test_decoding_an_encoding_gives_back_the_original(
E           text='0',
E       )

test_rle.py:17: AssertionError
=========================== short test summary info ============================
FAILED test_rle.py::test_decoding_an_encoding_gives_back_the_original - Asser...
1 failed, 2 passed in 0.01s

"0" লেখাটি encode হয়ে হয় "10" — «একটি শূন্য» — আর decoder 10-কে পড়ে এমন একটি গণনা হিসেবে, যার পরে কোনো অক্ষর নেই। যে লেখাতেই একটি অঙ্ক আছে, সেটিই নষ্ট হয়ে যায়। দুটি উদাহরণ-টেস্টেই কেবল অক্ষর ব্যবহার হয়েছিল, তাই সেগুলো এটি কখনো দেখতেই পেত না।

Shrinking

Hypothesis প্রথমেই "0"-এ হোঁচট খায়নি। সে পেয়েছিল একটি লম্বা, বিদঘুটে স্ট্রিং, তারপর সেটিকে shrink করেছে: ছোট আর সরল ইনপুট চেষ্টা করেছে, যেগুলো তখনো ফেল করে সেগুলো রেখে, যতক্ষণ না আর কোনো সরলতর ইনপুট ফেল করে। প্রতিটি ফেল-করা ইনপুট রেকর্ড করে আপনি পুরো ব্যাপারটা ঘটতে দেখতে পারেন:

python
from hypothesis import given, seed, settings
from hypothesis import strategies as st

from rle import decode, encode

failing = []


@seed(2026)
@settings(database=None)
@given(st.text())
def round_trip(text):
    if decode(encode(text)) != text:
        if text not in failing:
            failing.append(text)
        raise AssertionError


try:
    round_trip()
except AssertionError:
    pass

for text in failing:
    print(repr(text))
text
'wfê\x9f\x0c\U000b6082Ñ/\x879\x9dÂ\n'
'\x03\U000a1acb\U0003c4fc1'
'´\U000cd3dc\U00081acfÔ÷\U0007122f\x89ê½5'
'l𨺮\nÝÛ\x894\x8b'
'\U00083189𤾨«ñï\xa0^ó8A\x96\x88=\x05'
'0000'
'000'
'00'
'0'

(@seed এলোমেলো বাছাইগুলো স্থির করে দেয়, যাতে এই রানটি আবার হুবহু করা যায়; database=None Hypothesis-কে মনে রাখা কোনো ফেল আবার চালাতে দেয় না।) প্রথম ফেলটি তেরোটি অক্ষরের হিজিবিজি, তার ভেতরে একটি 9 লুকানো। আপনি মিনিটের পর মিনিট এর দিকে তাকিয়ে থাকতেন। shrink করা উদাহরণটি একটিমাত্র অক্ষরে পুরো গল্পটা বলে দেয়: একটি অঙ্ক এটিকে ভেঙে দেয়।

Hypothesis ফেল-করা উদাহরণগুলো একটি .hypothesis/ ফোল্ডারেও সংরক্ষণ করে, তাই পরের রানে সে আগে "0" চেষ্টা করে। ফোল্ডারটি .gitignore-এ যোগ করে দিন।

সমাধানটি গণনা আর অক্ষরের মাঝে একটি বিভাজক বসায়, যাতে কোনো অঙ্ককে কখনো গণনার অংশ বলে ভুল না হয়। আর Hypothesis যে উদাহরণটি খুঁজে পেয়েছে, সেটি @example দিয়ে পাকাপাকিভাবে বসিয়ে দেওয়া হয়, যাতে সেটি প্রতিবার, প্রতিটি মেশিনে চলে:

python
import re


def encode(text: str) -> str:
    """'aaab' -> '3:a1:b': each run becomes count, a colon, then the character."""
    out = []
    for match in re.finditer(r"(.)\1*", text, flags=re.DOTALL):
        run = match.group(0)
        out.append(f"{len(run)}:{run[0]}")
    return "".join(out)


def decode(encoded: str) -> str:
    pairs = re.findall(r"(\d+):(.)", encoded, flags=re.DOTALL)
    return "".join(char * int(count) for count, char in pairs)

দুটি উদাহরণ-টেস্ট নতুন ফরম্যাটে ("3:a1:b") বদলে যায়, আর property-তে যোগ হয় একটি লাইন:

python
from hypothesis import example, given
from hypothesis import strategies as st

from rle import decode, encode


@given(st.text())
@example("0")  # the input Hypothesis found; now it is checked on every run
def test_decoding_an_encoding_gives_back_the_original(text):
    assert decode(encode(text)) == text
text
...                                                                      [100%]
3 passed in 0.01s

Property উদাহরণের জায়গা নেয় না। দুটি উদাহরণ-টেস্ট ফরম্যাটটিকে এমনভাবে নথিভুক্ত করে, যা একজন পাঠক এক নজরে বোঝেন; property পাহারা দেয় সেই কোণগুলো, যার কথা কেউ ভাবেনি। দুটোই ব্যবহার করুন।

Flaky টেস্ট

একটি flaky টেস্ট একই কোডে কখনো পাস করে, কখনো ফেল করে। এটি কোনো টেস্ট না থাকার চেয়েও খারাপ: মানুষ «re-run» চাপতে শিখে যায় আর লাল রঙে বিশ্বাস হারায়। প্রায় প্রতিটি flaky টেস্ট আসে চারটি কারণের একটি থেকে, আর প্রতিটির এমন একটি সমাধান আছে, যা কারণটিকে লুকিয়ে না রেখে দূর করে।

ভাগ করা state আর টেস্টের ক্রম

python
_users = set()


def register(name):
    _users.add(name)


def count():
    return len(_users)
python
from registry import count, register


def test_registering_a_user_counts_them():
    register("rahim")

    assert count() == 1


def test_a_new_registry_is_empty():
    assert count() == 0
text
$ pytest -q --tb=no
.F                                                                       [100%]
=========================== short test summary info ============================
FAILED test_registry.py::test_a_new_registry_is_empty - assert 1 == 0
1 failed, 1 passed in 0.01s

$ pytest -q "test_registry.py::test_a_new_registry_is_empty"
.                                                                        [100%]
1 passed in 0.01s

পুরো suite-এ ফেল করে, একা চালালে পাস করে। মডিউল-স্তরের set-টি এক টেস্ট থেকে পরের টেস্টে টিকে থাকে, তাই দ্বিতীয় টেস্ট দেখে প্রথমটি কী রেখে গেছে। ক্রম বদলান, অথবা pytest-xdist দিয়ে টেস্টগুলো সমান্তরালে চালান, আর ফল বদলে যাবে। ভুল সমাধান হলো টেস্টগুলোর ক্রম বদলানো। সঠিক সমাধান ভাগ করা state-টিকেই সরিয়ে দেয়: registry-কে একটি অবজেক্ট বানান, আর একটি fixture দিয়ে প্রতিটি টেস্টকে একটি নতুন registry দিন:

python
class Registry:
    def __init__(self):
        self._users = set()

    def register(self, name):
        self._users.add(name)

    def count(self):
        return len(self._users)
python
import pytest

from registry import Registry


@pytest.fixture
def registry():
    return Registry()  # a fresh one for every test


def test_registering_a_user_counts_them(registry):
    registry.register("rahim")

    assert registry.count() == 1


def test_a_new_registry_is_empty(registry):
    assert registry.count() == 0
text
..                                                                       [100%]
2 passed in 0.01s

কোড বদলাতে না পারলে বিকল্প হলো একটি autouse fixture, যা প্রতিটি টেস্টের আগে ও পরে state রিসেট করে। প্রতিটি টেস্টের একা পাস করা উচিত, আর যেকোনো ক্রমে।

সময়

python
from datetime import datetime


def greeting(now=None):
    now = now or datetime.now()
    return "Good morning" if now.hour < 12 else "Good afternoon"
python
from greeting import greeting


def test_greets_the_morning():
    assert greeting() == "Good morning"  # true only before noon

একই কোড, একই মুহূর্তে চালানো, দুটি মেশিনে, যাদের time zone আলাদা:

text
$ TZ=Europe/London pytest -q
.                                                                        [100%]
1 passed in 0.01s

$ TZ=Asia/Dhaka pytest -q --tb=no
F                                                                        [100%]
=========================== short test summary info ============================
FAILED test_greeting.py::test_greets_the_morning - AssertionError: assert 'Go...
1 failed in 0.01s

সমাধানটি ফাংশনের signature-এই আছে: now বাইরে থেকে দেওয়া যায়। যে টেস্ট ঘড়ি নিয়ন্ত্রণ করে, সে সীমানার দুই দিকই টেস্ট করে, আর যেকোনো সময়ে একই উত্তর দেয়:

python
from datetime import datetime

from greeting import greeting


def test_before_noon_it_says_good_morning():
    assert greeting(now=datetime(2026, 1, 5, 9, 30)) == "Good morning"


def test_from_noon_on_it_says_good_afternoon():
    assert greeting(now=datetime(2026, 1, 5, 12, 0)) == "Good afternoon"
text
..                                                                       [100%]
2 passed in 0.01s

সময়টা আর্গুমেন্ট হিসেবে দেওয়া যেকোনো mock-এর চেয়ে সহজ। signature বদলাতে না পারলে দশম অধ্যায়ের monkeypatch দিয়ে ঘড়িটি বদলে দিন।

এলোমেলোতা

python
import random


def pick_winner(names, rng=random):
    return rng.choice(names)
python
from raffle import pick_winner


def test_picks_a_winner():
    assert pick_winner(["rahim", "karim", "salma"]) == "rahim"

ছয়বার চালানো, কিছুই না বদলে:

text
$ for i in 1 2 3 4 5 6; do pytest -q | tail -1; done
1 failed in 0.01s
1 failed in 0.01s
1 failed in 0.01s
1 passed in 0.01s
1 passed in 0.01s
1 passed in 0.01s

দুই ধরনের প্রশ্নের জন্য দুটি সমাধান। এমন কিছু assert করুন, যা প্রতিটি ফলাফলের জন্য সত্য — বিজয়ী অংশগ্রহণকারীদেরই একজন। অথবা একটি seed দেওয়া generator পাঠিয়ে এলোমেলোতার নিয়ন্ত্রণ নিজের হাতে নিন:

python
import random

from raffle import pick_winner


def test_the_winner_is_one_of_the_entrants():
    names = ["rahim", "karim", "salma"]

    assert pick_winner(names) in names


def test_the_same_seed_picks_the_same_winner():
    names = ["rahim", "karim", "salma"]

    first = pick_winner(names, rng=random.Random(42))
    second = pick_winner(names, rng=random.Random(42))

    assert first == second
text
$ for i in 1 2 3; do pytest -q | tail -1; done
2 passed in 0.01s
2 passed in 0.01s
2 passed in 0.01s

বাইরের জগৎ

চতুর্থ কারণ হলো এমন যেকোনো কিছু, যা আপনার নিয়ন্ত্রণে নেই: সত্যিকারের নেটওয়ার্ক কল, সত্যিকারের সার্ভার, «যথেষ্ট হওয়ার কথা» এমন একটি sleep(0.1)। সমাধানগুলো আগের অধ্যায়গুলো থেকেই — সীমানায় mock করুন, ভাগ করা ফোল্ডারের বদলে tmp_path ব্যবহার করুন, নির্দিষ্ট সময়ের বদলে একটি শর্তের জন্য অপেক্ষা করুন। একটি নিয়মেই চারটি কারণ ঢাকা পড়ে: টেস্ট যার উপর নির্ভর করে, তা টেস্টেরই তৈরি বা নিয়ন্ত্রণ করা উচিত।

প্রতিটি বাগের জন্য একটি regression টেস্ট

ফিরে যাই পরিকল্পনার টেবিলের সেই প্রশ্নচিহ্নে। কেউ উত্তর দেয়নি, আর বাগ রিপোর্ট এসে হাজির: `"!!!"` শিরোনামের একটি পোস্ট `/posts/`-এ সংরক্ষিত হয়েছে, আর index পেজটিকে মুছে তার জায়গা নিয়েছে।

slugify-এ হাত দেওয়ার আগে এমন একটি টেস্ট লিখুন, যা রিপোর্টটি পুনরায় ঘটায়, আর সেটিকে ফেল করতে দেখুন। এটি প্রমাণ করে যে টেস্টটি এই বাগটি ধরে:

python
import pytest

from slugs import slugify


def test_a_title_with_no_letters_or_digits_is_rejected():
    # Bug: slugify("!!!") returned "", and the post was saved at /posts/
    with pytest.raises(ValueError, match="no letters or digits"):
        slugify("!!!")
text
$ pytest -q test_slugs_regressions.py
F                                                                        [100%]
=================================== FAILURES ===================================
______________ test_a_title_with_no_letters_or_digits_is_rejected ______________

    def test_a_title_with_no_letters_or_digits_is_rejected():
        # Bug: slugify("!!!") returned "", and the post was saved at /posts/
>       with pytest.raises(ValueError, match="no letters or digits"):
E       Failed: DID NOT RAISE ValueError

test_slugs_regressions.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugs_regressions.py::test_a_title_with_no_letters_or_digits_is_rejected
1 failed in 0.01s

তারপর ঠিক করুন — slugify-এর শেষটা হয়ে যায়:

python
def slugify(title: str) -> str:
    slug = NOT_ALLOWED.sub("-", _to_ascii(title).lower()).strip("-")
    if not slug:
        raise ValueError(f"title has no letters or digits: {title!r}")
    return slug
text
....                                                                     [100%]
4 passed in 0.01s

regression টেস্ট আর তিনটি উদাহরণ-টেস্ট পাস করে। কিন্তু পুরো suite চালান, আর property টেস্টগুলো আপত্তি তোলে:

text
FAILED test_slugs_properties.py::test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens
FAILED test_slugs_properties.py::test_slugifying_a_slug_changes_nothing - Val...
2 failed, 4 passed in 0.01s

Hypothesis খালি স্ট্রিং চেষ্টা করেছে, আর slugify("") এখন exception তোলে। এটি নতুন কোনো বাগ নয় — এটি ইচ্ছাকৃতভাবে contract বদলানো, আর property-গুলো সেটা টের পেয়েছে। ভালো কথা। নতুন contract জানাতে সেগুলো হালনাগাদ করুন: যেসব শিরোনামে অন্তত একটি অক্ষর বা অঙ্ক আছে।

python
import re
import string

from hypothesis import given
from hypothesis import strategies as st

from slugs import slugify

# Any text, with at least one plain letter or digit somewhere inside it.
titles = st.builds(
    lambda before, word, after: before + word + after,
    st.text(),
    st.text(alphabet=string.ascii_letters + string.digits, min_size=1),
    st.text(),
)


@given(titles)
def test_a_slug_only_holds_lowercase_letters_digits_and_inner_hyphens(title):
    slug = slugify(title)

    assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug)

test_slugifying_a_slug_changes_nothing একইভাবে @given(titles)-এ বদলে যায়।

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

প্রতিটি বাগের জন্য একটি টেস্ট কেন? কারণ যে বাগ একবার ঘটেছে, সে প্রমাণ করেছে যে ভুলটা করা সহজ, আর পরবর্তী যিনি ওই কোডে হাত দেবেন, তিনি সম্ভবত আবার সেটা করবেন। কী ভুল হয়েছিল তা বলা কমেন্টটি টেস্টেরই অংশ: এক বছর পরে, এই অদ্ভুত-দেখতে কেসটি কেন এখানে, তার একমাত্র রেকর্ড সেটিই।


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

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

| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | খালি | "" | weak | | সব ধরনের অক্ষর, কিন্তু খুব ছোট | "Ab1!xyz" (7) | weak | | যথেষ্ট লম্বা, এক ধরন | "abcdefgh" | weak | | যথেষ্ট লম্বা, দুই ধরন | "abcdefg1" | medium | | 12 লম্বা, তিন ধরন | "abcdefghij1!" | strong | | 12 লম্বা, দুই ধরন | "abcdefghijk1" | medium |

passwords.py:

python
def _kinds(password: str) -> int:
    """How many of the four kinds of character the password uses."""
    return sum([
        any(c.islower() for c in password),
        any(c.isupper() for c in password),
        any(c.isdigit() for c in password),
        any(not c.isalnum() for c in password),
    ])


def strength(password: str) -> str:
    """Rate a password as 'weak', 'medium' or 'strong'."""
    if len(password) < 8:
        return "weak"
    kinds = _kinds(password)
    if len(password) >= 12 and kinds >= 3:
        return "strong"
    if kinds >= 2:
        return "medium"
    return "weak"

test_passwords.py:

python
import pytest
from hypothesis import given
from hypothesis import strategies as st

from passwords import strength

RANK = {"weak": 0, "medium": 1, "strong": 2}


@pytest.mark.parametrize(
    "password, expected",
    [
        ("", "weak"),                      # nothing at all
        ("Ab1!xyz", "weak"),               # every kind, but only 7 long
        ("abcdefgh", "weak"),              # 8 long, one kind
        ("abcdefg1", "medium"),            # 8 long, two kinds
        ("abcdefghij1!", "strong"),        # 12 long, three kinds
        ("abcdefghijk1", "medium"),        # 12 long, only two kinds
    ],
)
def test_strength_follows_the_length_and_variety_rules(password, expected):
    assert strength(password) == expected


@given(st.text(), st.text())
def test_adding_characters_never_makes_a_password_weaker(password, extra):
    before = strength(password)

    after = strength(password + extra)

    assert RANK[after] >= RANK[before]


def test_a_long_password_of_one_kind_is_still_weak():
    # Bug: "aaaaaaaaaaaaaaaaaaaa" (20 letters) was rated "medium"
    assert strength("a" * 20) == "weak"
text
$ pytest -v
collected 8 items

test_passwords.py::test_strength_follows_the_length_and_variety_rules[-weak] PASSED [ 12%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[Ab1!xyz-weak] PASSED [ 25%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefgh-weak] PASSED [ 37%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefg1-medium] PASSED [ 50%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghij1!-strong] PASSED [ 62%]
test_passwords.py::test_strength_follows_the_length_and_variety_rules[abcdefghijk1-medium] PASSED [ 75%]
test_passwords.py::test_adding_characters_never_makes_a_password_weaker PASSED [ 87%]
test_passwords.py::test_a_long_password_of_one_kind_is_still_weak PASSED [100%]

============================== 8 passed in 0.01s ===============================

তিনটি সিদ্ধান্ত লক্ষ করার মতো।

টেবিলের সারিগুলো বসানো হয়েছে সীমানার উপর — 7 আর 8 অক্ষর, 11 আর 12, দুই ধরন আর তিন ধরন। বাগ থাকে সীমানায়: যে <-এর জায়গায় <= হওয়া উচিত ছিল, তা একটি পরিসরের মাঝখানে অদৃশ্য।

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

আর কোনো টেস্ট _kinds ছোঁয় না। তাতে পৌঁছানো হয় strength-এর মাধ্যমে; কাল হয়তো সেটি থাকবেই না।


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

refactor-এর পরে AttributeError: 'Cart' object has no attribute '_items' টেস্টটি অবজেক্টের ভেতরটা পড়ছিল। সেটিকে public মেথডগুলোর মাধ্যমে যাচাই করার মতো করে আবার লিখুন — একজন caller যা দেখতে পান — তাহলে পরের refactor-এও সেটি টিকে থাকবে।

একটি টেস্ট একা পাস করে কিন্তু পুরো রানে ফেল করে (বা উল্টোটা) ভাগ করা state: মডিউল-স্তরের একটি তালিকা, ডিকশনারি বা cache; নির্দিষ্ট জায়গায় রাখা একটি ফাইল; এমন একটি environment variable, যা সেট করা হয়েছে কিন্তু আর মোছা হয়নি। টেস্টগুলোর ক্রম না বদলে প্রতিটি টেস্টকে তার প্রয়োজনীয় জিনিস নিজে তৈরি করতে দিন (fixture, tmp_path, monkeypatch)।

hypothesis.errors.FailedHealthCheck: It looks like this test is filtering out a lot of inputs. 0 inputs were generated successfully, while 50 inputs were filtered out. একটি .filter() (বা assume()) Hypothesis-এর তৈরি করা প্রায় সবকিছুই ফেলে দিচ্ছে — যেমন st.integers().filter(lambda n: n % 1000 == 7)। তার বদলে যে মান চান, সেগুলো সরাসরি তৈরি করুন: st.integers().map(lambda n: n * 1000 + 7)।

hypothesis.errors.FlakyFailure: Hypothesis test_depends_on_earlier_runs(n=-25617) produces unreliable results: Failed on the first call but did not on a subsequent one একই ইনপুটের জন্য টেস্টটি ভিন্ন উত্তর দিয়েছে। তৈরি করা আর্গুমেন্টের বাইরের কিছু একটা দুই কলের মাঝে বদলে গেছে — একটি counter, মডিউল-স্তরের একটি তালিকা, ঘড়ি। Hypothesis প্রতিটি ফেল আবার চালিয়ে দেখে, তাই লুকানো state-ওয়ালা টেস্ট shrink হতে পারে না।

hypothesis.errors.DeadlineExceeded: Test took 300.07ms, which exceeds the deadline of 200.00ms. ডিফল্টভাবে প্রতিটি তৈরি করা উদাহরণকে 200 ms-এর মধ্যে শেষ হতে হয়, কারণ একশোটি ধীর উদাহরণ মানে একটি ধীর suite। টেস্টটিকে দ্রুততর করুন, অথবা ধীরগতিটা যদি সত্যিই প্রয়োজনীয় হয়, @settings(deadline=...) দিয়ে সীমাটা বাড়িয়ে দিন।

নতুন একটি TDD টেস্ট প্রথমবার চালাতেই পাস করে আপনি যে কোড লিখতে যাচ্ছেন, তার ব্যাপারে এটি কিছুই প্রমাণ করছে না। হয় আচরণটি আগে থেকেই আছে (ঠিক আছে — টেস্টটি নথি হিসেবে রেখে দিন), নয়তো টেস্টটি যা ভাবছেন তা যাচাই করছে না। মুহূর্তের জন্য কোডটি ইচ্ছে করে ভেঙে দিন, আর নিশ্চিত হোন যে টেস্টটি লাল হয়।