টেস্ট ডিজাইন আর TDD — ভালো টেস্ট কেমন হয়
টেস্ট লেখার আগের পরিকল্পনা, Arrange-Act-Assert, ইমপ্লিমেন্টেশন নয় আচরণ টেস্ট করা, red-green-refactor, Hypothesis দিয়ে property-based টেস্ট, flaky টেস্টের কারণ ও সমাধান, আর প্রতিটি বাগের জন্য একটি regression টেস্ট।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এই কোর্সে যা যা শেখানোর কথা ছিল, তার সবই এখন আপনার জানা: assert, raises, parametrize, fixture, mock, marker, configuration, coverage, plugin। অথচ এর সবকটি ব্যবহার করেও এমন টেস্ট লিখে ফেলা পুরোপুরি সম্ভব, যা উপকারের চেয়ে কষ্টই বেশি দেয়।
এই যে একটি ছোট শপিং কার্ট, সাথে দুটি টেস্ট, দুটোই পাস করে:
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)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.. [100%]
2 passed in 0.01sএবার কেউ একজন কার্টটিকে উন্নত করলেন। একই জিনিস দুবার যোগ করলে সেটি এক লাইনে মিশে যাওয়া উচিত, তাই তালিকাটি হয়ে গেল নাম-ভিত্তিক একটি ডিকশনারি। কার্ট এখনো জিনিস যোগ করে, এখনো ঠিকঠাক মোট হিসাব করে — একজন ক্রেতা টের পাবেন এমন কিছুই বদলায়নি:
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())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 করা যায় এমন কোড:
$ 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 — কী বের হলো তা যাচাই করুন
এই যে কার্টটি আবার টেস্ট করা হলো — এবার সে কী প্রতিশ্রুতি দেয় তা দিয়ে, সে কীভাবে জিনিস রাখে তা দিয়ে নয়:
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তালিকা-ভিত্তিক কার্টের বিরুদ্ধে, আর আবার ডিকশনারি-ভিত্তিক কার্টের বিরুদ্ধে:
... [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 দিয়ে চালান:
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 উপেক্ষা করে):
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$ 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 কখনো চলেইনি, তাই জানেন না সেটি পাস করত কি না। একই বাগ, তিনটি নির্দিষ্ট-লক্ষ্যের টেস্টের বিরুদ্ধে:
$ 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) চলে একটি আঁটসাঁট চক্রে:
- Red — এমন আচরণের জন্য একটি ছোট টেস্ট লিখুন, যা এখনো নেই, আর সেটিকে ফেল করতে দেখুন
- Green — সেটি পাস করানোর জন্য যতটুকু কম কোড লাগে, ততটুকুই লিখুন
- Refactor — কোড গুছিয়ে নিন, পুরো সময় টেস্টগুলো সবুজ রেখে
ফেল করতে দেখা কেন? কারণ যে টেস্টকে কখনো ফেল করতে দেখেননি, সেটি হয়তো কিছুই টেস্ট করছে না। red ধাপটি প্রমাণ করে যে ফিচারের অনুপস্থিতি টেস্টটি ধরতে পারে।
টেবিলের পরিকল্পনা ধরে আমরা এগোব, এক সারি করে।
Red. প্রথম সারি, slugs.py তৈরির আগেই:
from slugs import slugify
def test_lowercases_and_joins_words_with_hyphens():
assert slugify("Hello World") == "hello-world"==================================== 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. পাস করানোর মতো সবচেয়ে কম কোড — আর সত্যিই সবচেয়ে কম:
def slugify(title):
return title.lower().replace(" ", "-"). [100%]
1 passed in 0.01sমনে হতে পারে ফাঁকি দেওয়া হচ্ছে। এটি ইচ্ছাকৃত: কোডটি কেবল ততটুকুই করে, যতটুকু এ পর্যন্ত কোনো টেস্ট দাবি করেছে। এর বেশি যা কিছু, তা এমন কোড, যা কোনো টেস্ট যাচাই করছে না।
Red. টেবিলের দ্বিতীয় সারি:
def test_drops_punctuation():
assert slugify("Hello, World!") == "hello-world".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. এখন একটি স্পেস বদলানোর কৌশল আর যথেষ্ট নয়। «অক্ষর বা অঙ্ক নয়» এমন প্রতিটি টানা অংশকে একটি হাইফেন দিয়ে বদলে দিন, তারপর দুই প্রান্ত থেকে হাইফেন ছেঁটে ফেলুন:
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-").. [100%]
2 passed in 0.01sবাড়তি স্পেস আর অঙ্কের সারিগুলো এই কোডেই পাস করত। তবুও সেগুলো যোগ করুন — সেগুলো contract-এর অংশ — কিন্তু জেনে রাখুন, প্রথমবার চালিয়েই সবুজ হওয়া একটি টেস্ট এইমাত্র লেখা কোডের ব্যাপারে কিছুই প্রমাণ করেনি। প্রমাণ করে কেবল আগে-লাল-পরে-সবুজ টেস্ট।
Red. অ্যাকসেন্টযুক্ত অক্ষর:
def test_turns_accented_letters_into_plain_ones():
assert slugify("Café au lait") == "cafe-au-lait"..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 থেকে যায়:
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("-")... [100%]
3 passed in 0.01sRefactor. কাজ করছে, কিন্তু ফাংশনের ভেতরটা একটি ঘন লাইন। প্রতিটি ধাপকে একটি নাম দিন, pattern একবারই compile করুন, আর type hint যোগ করুন — কোনো আচরণ না বদলে:
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("-")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 করা হয়) তাদের গড়ন বর্ণনা করে:
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. [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 করলে কিছুই বদলায় না:
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.. [100%]
2 passed in 0.01sst.text() তৈরি করে ইমোজি, চীনা লেখা, control character, খালি স্ট্রিং — এমন সব ইনপুট, যা আপনি কখনো পরিকল্পনার টেবিলে লিখতেন না।
একটি round trip একটি সত্যিকারের বাগ খুঁজে পায়
সবচেয়ে ফলপ্রসূ property হলো round trip: কোনো কিছু encode করে আবার decode করা গেলে, encode করা জিনিসটি decode করলে মূলটিই ফিরে আসতে হবে। এই যে একটি run-length encoder — "aaab" হয়ে যায় "3a1b" — সাথে দুটি উদাহরণ-টেস্ট, যেগুলো পাস করে:
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))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..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 করেছে: ছোট আর সরল ইনপুট চেষ্টা করেছে, যেগুলো তখনো ফেল করে সেগুলো রেখে, যতক্ষণ না আর কোনো সরলতর ইনপুট ফেল করে। প্রতিটি ফেল-করা ইনপুট রেকর্ড করে আপনি পুরো ব্যাপারটা ঘটতে দেখতে পারেন:
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))'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 দিয়ে পাকাপাকিভাবে বসিয়ে দেওয়া হয়, যাতে সেটি প্রতিবার, প্রতিটি মেশিনে চলে:
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-তে যোগ হয় একটি লাইন:
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... [100%]
3 passed in 0.01sProperty উদাহরণের জায়গা নেয় না। দুটি উদাহরণ-টেস্ট ফরম্যাটটিকে এমনভাবে নথিভুক্ত করে, যা একজন পাঠক এক নজরে বোঝেন; property পাহারা দেয় সেই কোণগুলো, যার কথা কেউ ভাবেনি। দুটোই ব্যবহার করুন।
Flaky টেস্ট
একটি flaky টেস্ট একই কোডে কখনো পাস করে, কখনো ফেল করে। এটি কোনো টেস্ট না থাকার চেয়েও খারাপ: মানুষ «re-run» চাপতে শিখে যায় আর লাল রঙে বিশ্বাস হারায়। প্রায় প্রতিটি flaky টেস্ট আসে চারটি কারণের একটি থেকে, আর প্রতিটির এমন একটি সমাধান আছে, যা কারণটিকে লুকিয়ে না রেখে দূর করে।
ভাগ করা state আর টেস্টের ক্রম
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)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$ 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 দিন:
class Registry:
def __init__(self):
self._users = set()
def register(self, name):
self._users.add(name)
def count(self):
return len(self._users)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.. [100%]
2 passed in 0.01sকোড বদলাতে না পারলে বিকল্প হলো একটি autouse fixture, যা প্রতিটি টেস্টের আগে ও পরে state রিসেট করে। প্রতিটি টেস্টের একা পাস করা উচিত, আর যেকোনো ক্রমে।
সময়
from datetime import datetime
def greeting(now=None):
now = now or datetime.now()
return "Good morning" if now.hour < 12 else "Good afternoon"from greeting import greeting
def test_greets_the_morning():
assert greeting() == "Good morning" # true only before noonএকই কোড, একই মুহূর্তে চালানো, দুটি মেশিনে, যাদের time zone আলাদা:
$ 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 বাইরে থেকে দেওয়া যায়। যে টেস্ট ঘড়ি নিয়ন্ত্রণ করে, সে সীমানার দুই দিকই টেস্ট করে, আর যেকোনো সময়ে একই উত্তর দেয়:
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".. [100%]
2 passed in 0.01sসময়টা আর্গুমেন্ট হিসেবে দেওয়া যেকোনো mock-এর চেয়ে সহজ। signature বদলাতে না পারলে দশম অধ্যায়ের monkeypatch দিয়ে ঘড়িটি বদলে দিন।
এলোমেলোতা
import random
def pick_winner(names, rng=random):
return rng.choice(names)from raffle import pick_winner
def test_picks_a_winner():
assert pick_winner(["rahim", "karim", "salma"]) == "rahim"ছয়বার চালানো, কিছুই না বদলে:
$ 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 পাঠিয়ে এলোমেলোতার নিয়ন্ত্রণ নিজের হাতে নিন:
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$ 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-এ হাত দেওয়ার আগে এমন একটি টেস্ট লিখুন, যা রিপোর্টটি পুনরায় ঘটায়, আর সেটিকে ফেল করতে দেখুন। এটি প্রমাণ করে যে টেস্টটি এই বাগটি ধরে:
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("!!!")$ 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-এর শেষটা হয়ে যায়:
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.... [100%]
4 passed in 0.01sregression টেস্ট আর তিনটি উদাহরণ-টেস্ট পাস করে। কিন্তু পুরো suite চালান, আর property টেস্টগুলো আপত্তি তোলে:
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.01sHypothesis খালি স্ট্রিং চেষ্টা করেছে, আর slugify("") এখন exception তোলে। এটি নতুন কোনো বাগ নয় — এটি ইচ্ছাকৃতভাবে contract বদলানো, আর property-গুলো সেটা টের পেয়েছে। ভালো কথা। নতুন contract জানাতে সেগুলো হালনাগাদ করুন: যেসব শিরোনামে অন্তত একটি অক্ষর বা অঙ্ক আছে।
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)-এ বদলে যায়।
...... [100%]
6 passed in 0.01sপ্রতিটি বাগের জন্য একটি টেস্ট কেন? কারণ যে বাগ একবার ঘটেছে, সে প্রমাণ করেছে যে ভুলটা করা সহজ, আর পরবর্তী যিনি ওই কোডে হাত দেবেন, তিনি সম্ভবত আবার সেটা করবেন। কী ভুল হয়েছিল তা বলা কমেন্টটি টেস্টেরই অংশ: এক বছর পরে, এই অদ্ভুত-দেখতে কেসটি কেন এখানে, তার একমাত্র রেকর্ড সেটিই।
একটা সম্পূর্ণ উদাহরণ
একটি পাসওয়ার্ডের শক্তি যাচাইকারী, এই অধ্যায়ের সবকিছু দিয়ে বানানো। আগে পরিকল্পনা:
| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | খালি | "" | weak | | সব ধরনের অক্ষর, কিন্তু খুব ছোট | "Ab1!xyz" (7) | weak | | যথেষ্ট লম্বা, এক ধরন | "abcdefgh" | weak | | যথেষ্ট লম্বা, দুই ধরন | "abcdefg1" | medium | | 12 লম্বা, তিন ধরন | "abcdefghij1!" | strong | | 12 লম্বা, দুই ধরন | "abcdefghijk1" | medium |
passwords.py:
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:
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"$ 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 টেস্ট প্রথমবার চালাতেই পাস করে আপনি যে কোড লিখতে যাচ্ছেন, তার ব্যাপারে এটি কিছুই প্রমাণ করছে না। হয় আচরণটি আগে থেকেই আছে (ঠিক আছে — টেস্টটি নথি হিসেবে রেখে দিন), নয়তো টেস্টটি যা ভাবছেন তা যাচাই করছে না। মুহূর্তের জন্য কোডটি ইচ্ছে করে ভেঙে দিন, আর নিশ্চিত হোন যে টেস্টটি লাল হয়।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
পুরো ফাইল চালালে দ্বিতীয় টেস্টটি ফেল করে। কিন্তু শুধু দ্বিতীয় টেস্টটি আলাদা করে চালালে শেষ লাইনে কী দেখা যাবে?
_users = set()
def register(name):
_users.add(name)
def count():
return len(_users)
def test_registering_a_user_counts_them():
register("rahim")
assert count() == 1
def test_a_new_registry_is_empty():
assert count() == 0
# Run only the second test:
# pytest -q "test_registry.py::test_a_new_registry_is_empty"- A1 failed in 0.01s
- B1 passed in 0.01s
- C1 failed, 1 passed in 0.01s
- D1 error in 0.01s
Cart-এর ভেতরে তালিকার বদলে ডিকশনারি বসানো হলো, আচরণ একই রইল। কোন টেস্টটি লাল হবে?
def test_total_of_two_pens():
cart = Cart()
cart.add("pen", 15.0, 2)
assert cart.total() == 30.0
def test_empty_cart_totals_zero():
assert Cart().total() == 0
def test_one_line_per_item():
cart = Cart()
cart.add("pen", 15.0)
assert len(cart._items) == 1
def test_adding_twice_adds_up():
cart = Cart()
cart.add("pen", 15.0)
cart.add("pen", 15.0)
assert cart.total() == 30.0- A`test_total_of_two_pens`
- B`test_empty_cart_totals_zero`
- C`test_one_line_per_item`
- D`test_adding_twice_adds_up`
এই টেস্টটি সোম থেকে শুক্র পাস করে, শনি আর রবিবার ফেল করে। সবচেয়ে ভালো সমাধান কোনটি?
from datetime import date
def is_weekend(day=None):
day = day or date.today()
return day.weekday() >= 5
def test_weekdays_are_not_weekend():
assert is_weekend() is False- Aব্যর্থ হলে টেস্টটি স্বয়ংক্রিয়ভাবে আবার চালানোর ব্যবস্থা করা
- Bশনি আর রবিবার টেস্টটি skip করা
- C`is False`-এর বদলে `== False` লেখা
- Dএকটি নির্দিষ্ট তারিখ পাঠানো — `is_weekend(date(2026, 10, 7))` — আর শনিবারের জন্য আলাদা একটি টেস্ট লেখা
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
durations.py-এ দুটি ফাংশন বানান, test-first পদ্ধতিতে:
parse_duration(text)"1h30m"-কে5400-এ (সেকেন্ড) পরিণত করে। একক হলোh,m,s, এই ক্রমে, প্রতিটি ঐচ্ছিক;"45s","2h"আর"1h0m5s"সবই বৈধ। আর যা কিছু —"","h","1x","30m1h","1.5h"— বার্তায়not a durationসহValueErrorতোলে।format_duration(seconds)উল্টো দিকে যায়:5400হয়ে যায়"1h30m",3605হয়ে যায়"1h5s",0হয়ে যায়"0s"। ঋণাত্মক সংখ্যা দিলেValueErrorতোলে।
এই ক্রমে কাজ করুন:
- আগে পরিকল্পনার টেবিল লিখুন: কেস, ইনপুট, প্রত্যাশিত — অবৈধ ইনপুট আর সীমানাসহ (যেমন
59আর60সেকেন্ড)। - red-green চক্রে এক সারি করে এগোন। প্রতিটি পরিবর্তনের পরে
pytestচালান, আর সবুজ করার আগে লালটা দেখে নিন। - Hypothesis দিয়ে round-trip property যোগ করুন:
0থেকে উপরের যেকোনো পূর্ণসংখ্যার সেকেন্ডের জন্যparse_duration(format_duration(n)) == n। - একজন ব্যবহারকারী জানালেন যে
"90m"বাতিল করা হয়েছে, যদিও মানুষ এটি সবসময়ই টাইপ করে। কী ভুল হয়েছিল তা বলা একটি কমেন্টসহ একটি regression টেস্ট যোগ করুন।
শেষ হলে একবার refactor করুন, আর নিশ্চিত হোন যে পুরো suite সবুজ থাকে।
সমাধান
durations.py:
import re
UNITS = {"h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?")
def parse_duration(text: str) -> int:
"""'1h30m' -> 5400. Units must appear in the order h, m, s."""
match = PATTERN.fullmatch(text)
if not text or match is None:
raise ValueError(f"not a duration: {text!r}")
hours, minutes, seconds = (int(part or 0) for part in match.groups())
return hours * 3600 + minutes * 60 + seconds
def format_duration(seconds: int) -> str:
"""5400 -> '1h30m'. Zero is '0s'."""
if seconds < 0:
raise ValueError(f"duration cannot be negative: {seconds}")
if seconds == 0:
return "0s"
parts = []
for unit, size in UNITS.items():
amount, seconds = divmod(seconds, size)
if amount:
parts.append(f"{amount}{unit}")
return "".join(parts)test_durations.py:
import pytest
from hypothesis import given
from hypothesis import strategies as st
from durations import format_duration, parse_duration
@pytest.mark.parametrize(
"text, seconds",
[
("45s", 45),
("2h", 7200),
("1h30m", 5400),
("1h0m5s", 3605),
("0s", 0),
],
)
def test_parse_reads_hours_minutes_and_seconds(text, seconds):
assert parse_duration(text) == seconds
@pytest.mark.parametrize("text", ["", "h", "1x", "30m1h", "1.5h"])
def test_parse_rejects_text_that_is_not_a_duration(text):
with pytest.raises(ValueError, match="not a duration"):
parse_duration(text)
@pytest.mark.parametrize(
"seconds, text",
[(0, "0s"), (59, "59s"), (60, "1m"), (3605, "1h5s"), (5400, "1h30m")],
)
def test_format_writes_only_the_units_it_needs(seconds, text):
assert format_duration(seconds) == text
def test_format_rejects_a_negative_duration():
with pytest.raises(ValueError, match="negative"):
format_duration(-1)
@given(st.integers(min_value=0, max_value=10**7))
def test_parsing_a_formatted_duration_gives_back_the_seconds(seconds):
assert parse_duration(format_duration(seconds)) == seconds
def test_minutes_over_sixty_are_accepted():
# Bug: "90m" was rejected, though people type it all the time
assert parse_duration("90m") == 5400$ pytest -q
.................. [100%]
18 passed in 0.01sকেন এভাবে বানানো:
- পরিকল্পনাটিই parametrize টেবিল হয়ে উঠেছে। টেবিলের প্রতিটি সারি
parametrize-এর একটি সারি, তাই পরে একটি কেস যোগ করা মানে একটি লাইন, আর-vআউটপুটে টেস্টের নাম দেখায় কোন সারি ফেল করেছে। - বৈধ আর অবৈধ ইনপুট আলাদা টেস্টে। এগুলো আলাদা আচরণ, ফেল করার কারণও আলাদা। অবৈধ তালিকায় প্রতিটি ধরনের ভুলের একটি করে উদাহরণ: খালি, সংখ্যা ছাড়া একক, অজানা একক, ভুল ক্রম, দশমিক।
match="not a duration"টাইপের সাথে বার্তাটিও যাচাই করে, যাতে অন্য কোথাও দুর্ঘটনাবশত ওঠা একটিValueError— ধরুনint("")— টেস্টটিকে পাস করিয়ে দিতে না পারে।if not textআছে, কারণ pattern-টির প্রতিটি অংশ ঐচ্ছিক হওয়ায় সেটি খালি স্ট্রিংয়ের সাথেও মিলে যায়। ঠিক এই ধরনের কেস নিয়েই পরিকল্পনার টেবিল আপনাকে আগেভাগে ভাবতে বাধ্য করে, regex আপনার হয়ে সেটা ঠিক করে দেওয়ার আগেই।59আর60হলো সীমানা, যেখানে সেকেন্ড গড়িয়ে মিনিটে পরিণত হয়; পরিসরের মাঝখানের কোনো টেস্ট সেখানকার off-by-one কখনো ধরত না।- round-trip property ফাইলের সবচেয়ে শক্তিশালী টেস্ট। এটি একটিও প্রত্যাশিত মান জানে না, তবুও এক কোটি সম্ভাব্য duration-কে একে অপরের বিরুদ্ধে যাচাই করে — দুই ফাংশনের মধ্যে যেকোনো অমিল ধরা পড়ে, shrink হয়ে সেই ক্ষুদ্রতম সংখ্যায়, যা সেটিকে ভাঙে।
- regression টেস্টটি এই implementation-এ আগে থেকেই পাস করে; এটি আছে ভবিষ্যতের এমন কোনো «গোছগাছ» ঠেকাতে, যা মিনিটকে
0-59-এ সীমিত করে বাগটিকে ফিরিয়ে আনবে। এর কমেন্ট বলে দেয় কেন। - কিছুই সরাসরি
PATTERNবাUNITSটেস্ট করে না। সেগুলো implementation, আর কাল regex-এর বদলে হাতে লেখা একটি parser বসিয়ে দেওয়ার স্বাধীনতা আপনার আছে।
এরপর কোথায় যাবেন
এখানেই কোর্সের শেষ। আপনি টেস্ট লিখতে পারেন, তাদের ফেল পড়তে পারেন, সাজাতে পারেন, আলাদা করতে পারেন, মাপতে পারেন, pytest-কেই প্রসারিত করতে পারেন, আর — এই অধ্যায়ের পরে — ঠিক করতে পারেন কোনটি আদৌ টেস্ট করার যোগ্য। এখান থেকে কয়েকটি পথ:
- সত্যিকারের কোডে এটিকে অভ্যাসে পরিণত করুন। আপনার কাছে আগে থেকে থাকা একটি প্রজেক্ট নিন, আর যে অংশটি বদলাতে সবচেয়ে বেশি ভয় পান, সেখানে টেস্ট যোগ করুন। আগে পরিকল্পনার টেবিল লিখুন। টেস্ট যত বাড়ে, ভয় তত কমে।
- Hypothesis নিয়ে আরও এগোন। এর ডকুমেন্টেশনে আছে stateful testing (
RuleBasedStateMachine), যা একটি অবজেক্টের বিরুদ্ধে অপারেশনের গোটা ধারাবাহিকতা তৈরি করে, আর এমন বাগ খুঁজে পায়, যা কোনো একক ইনপুট পেত না। - Mutation testing।
mutmut-এর মতো টুল আপনার কোডে ছোট ছোট পরিবর্তন করে —<থেকে<=,+থেকে-— আর দেখে কোনো টেস্ট ফেল করে কি না। যে mutation টিকে যায়, সেটি এমন একটি লাইন, যা আপনার টেস্টগুলো আসলে যাচাই করে না, coverage-এর সংখ্যা যত উঁচুই হোক। - চতুর্দশ অধ্যায়ের CI ব্যবস্থাটি বাড়িয়ে তুলুন। আপনি যে যে পাইথন সংস্করণ সমর্থন করেন, সবকটিতে suite চালান, আর একটি ফেল-করা টেস্টকে merge আটকে দিতে দিন — যে suite সবুজ রাখতে কেউ বাধ্য নয়, সেটি ধীরে ধীরে সবুজ থাকা বন্ধ করে দেয়।
- ভালো টেস্ট suite পড়ুন।
pytest,requests,attrsআর খোদhypothesis-এর টেস্টগুলো open source। অভিজ্ঞ মানুষেরা কীভাবে টেস্টের নাম দেন, সাজান আর আলাদা করেন, তা পড়ে যা শেখা যায়, কোনো নিয়মের তালিকা তা শেখায় না।
এরপর যা-ই লিখুন, শুরু করুন সেই প্রশ্ন দিয়ে, যা দিয়ে এই অধ্যায় শুরু হয়েছিল: কেউ যদি কাল এই কোডটি উন্নত করেন, অথচ এর কাজ না বদলান, আমার টেস্টগুলো কি সবুজ থাকবে — আর কেউ যদি এটি ভেঙে দেন, সেগুলো কি লাল হবে?
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন