এই পর্বে যা শিখবেন
- টেস্ট কেন লেখা হয় — regression ধরা, নির্ভয়ে কোড বদলানো, আর কোডের আচরণের জীবন্ত দলিল
- Arrange–Act–Assert ছাঁচে একটি টেস্ট লেখা, আর pytest চালিয়ে তার আউটপুট (
.,F,E, diff) পড়া pytest.raises,pytest.approx,parametrize, fixture,tmp_path,capsys,monkeypatch— কোনটি কোন সমস্যার সমাধান- fake বনাম mock,
unittestআরdoctest— পুরোনো আর নতুন কোডে যা দেখবেন - কী টেস্ট করবেন (edge case-এর চেকলিস্ট), coverage-এর সীমা, TDD-র লাল → সবুজ → গোছানো চক্র, আর AI-এর লেখা টেস্ট কীভাবে যাচাই করবেন
১ · টেস্ট কী, আর কেন লিখবেন
ধরুন আপনি একটি দোকানের বিলিং সফটওয়্যার লিখছেন। একটি ফাংশন আছে add_vat(price) — দামের সাথে ১৫% VAT যোগ করে। আপনি একবার চালিয়ে দেখলেন, add_vat(100) দিল 115. চমৎকার। তিন সপ্তাহ পরে ছাড়ের নিয়ম যোগ করতে গিয়ে আপনি একই ফাইলে কিছু লাইন বদলালেন। এখন add_vat(100) কত দেয়? আপনি জানেন না — যতক্ষণ না আবার চালিয়ে দেখছেন। আর বিলিং ফাংশন তো একটি নয়, চল্লিশটি।
এই সমস্যার উত্তর হলো একটি unit test: একটি ছোট প্রোগ্রাম, যা আপনার প্রোগ্রামের একটি প্রতিশ্রুতি যাচাই করে. “add_vat(100) দেয় 115” — এই কথাটি একবার কোডে লিখে রাখলে কম্পিউটার যতবার খুশি, এক সেকেন্ডের ভগ্নাংশে, আপনার হয়ে মিলিয়ে দেবে। এমন শত শত টেস্টের সংগ্রহকে বলে test suite.
ক্রিকেটের DRS-এর কথা ভাবুন। আম্পায়ার “আউট” দিলেন, ব্যাটসম্যান রিভিউ চাইলেন। এখন আর কারও মতামত নয় — বল-ট্র্যাকিং একটি নির্দিষ্ট নিয়মে মিলিয়ে দেখে: বল কি স্টাম্পে লাগত? উত্তর হ্যাঁ বা না, সবার সামনে, প্রতিবার একই পদ্ধতিতে। টেস্টও তাই। “আমার মনে হয় ঠিক আছে” নয় — একটি লিখিত নিয়ম, যা যন্ত্র নিরপেক্ষভাবে, যতবার দরকার ততবার যাচাই করে।
টেস্ট তিনটি কাজ করে
- পুরোনো বাগ ফিরে আসা ধরে: একবার ঠিক করা বাগ পরে অন্য কোনো পরিবর্তনে আবার ফিরে আসাকে বলে regression. পর্ব ২১-এর মাঝপথের প্রজেক্টে নিয়মটি দেখেছেন: প্রতিটি ধরা পড়া বাগের জন্য একটি টেস্ট, চিরকালের জন্য। সেই টেস্ট পাহারাদার — বাগটি ফিরলেই লাল বাতি জ্বলে।
- নির্ভয়ে কোড বদলাতে দেয়: পর্ব ২১-এ refactoring মানে “বাইরের আচরণ একই রেখে ভেতরের গঠন গোছানো”. কিন্তু “আচরণ একই আছে” — এটা আপনি জানবেন কীভাবে? টেস্ট দিয়ে। ভালো test suite থাকলে চল্লিশটি ফাংশনের কোড গুছিয়ে
pytestচালান — সব সবুজ হলে আপনি নিশ্চিন্ত। টেস্ট ছাড়া মানুষ পুরোনো কোড ছুঁতে ভয় পায়, আর কোড ধীরে ধীরে জট পাকায়। - কোডের আচরণ লিখে রাখে:
test_withdraw_more_than_balance_raisesনামের একটি টেস্ট পড়েই নতুন সহকর্মী বোঝেন, ব্যালান্সের বেশি তুললে error আসার কথা। এটি এমন ডকুমেন্টেশন, যা কখনো পুরোনো হয় না — কারণ পুরোনো হলে টেস্টটিই ব্যর্থ হয়।
কত বড় অংশ টেস্ট করছেন?
এই পর্বের বেশিরভাগ জুড়ে আছে unit test — একটি ফাংশন বা class আলাদা করে, নেটওয়ার্ক বা ডেটাবেস ছাড়া, মিলিসেকেন্ডে। এর ওপরে আছে integration test — কয়েকটি অংশ একসাথে (আপনার কোড আর একটি আসল SQLite ফাইল, বা দুটি module), কিছুটা ধীর। আর সবার ওপরে end-to-end টেস্ট — পুরো অ্যাপ, যেন একজন ব্যবহারকারী বোতাম চাপছেন। সাধারণ পরামর্শ: অনেক দ্রুত unit test, তার চেয়ে কম integration test, আর অল্প কয়েকটি end-to-end — একে অনেকে “টেস্ট পিরামিড” বলেন।
টেস্ট আপনার কোডের সঠিকতার প্রমাণ নয় — এটি কিছু নির্দিষ্ট প্রতিশ্রুতির স্বয়ংক্রিয় পাহারা. যত গুরুত্বপূর্ণ প্রতিশ্রুতি লিখে রাখবেন, কোড বদলানো তত নিরাপদ। ভাবনার প্রশ্ন ৩-এ দেখবেন, Dijkstra এই সীমাটি কত স্পষ্ট করে বলেছিলেন।
২ · একটি টেস্টের গঠন — Arrange, Act, Assert
প্রায় প্রতিটি ভালো unit test তিনটি অংশে ভাগ করা যায়। এই ছাঁচের নাম Arrange–Act–Assert (সংক্ষেপে AAA):
- Arrange — সাজানো: টেস্টের জন্য দরকারি ডেটা আর object তৈরি করা। যেমন দুটি wallet, একটিতে ৫০০ টাকা।
- Act — একটি কাজ: যে আচরণ যাচাই করছেন, সেটি একবার ঘটানো। যেমন ২০০ টাকা পাঠানো।
- Assert — মেলানো: ফল যা হওয়ার কথা, তা-ই হয়েছে কি না পরীক্ষা করা। যেমন একজনের ৩০০, অন্যজনের ২০০।
পর্ব ১৩-এ দেখেছেন, assert একটি statement: শর্ত সত্য হলে কিছুই ঘটে না, মিথ্যা হলে AssertionError আসে। টেস্টের Assert অংশ ঠিক এটিই। নিচের স্টেপারে একটি টেস্ট ফাংশনের ভেতর দিয়ে ধাপে ধাপে চলুন — শেষ ধাপে দেখবেন, কোডে বাগ থাকলে কী ঘটে:
1def test_transfer_moves_money():2 bal = {"rahim": 500, "karim": 0} # Arrange3 transfer(bal, "rahim", "karim", 200) # Act4 assert bal == {"rahim": 300, "karim": 200} # Assert
| bal | |
| অংশ | |
| ফল |
- pytest টেস্টটি খুঁজে পেল। নাম
test_দিয়ে শুরু, তাই pytest নিজেই একে ডাকে — আপনাকে কোথাও ডাকতে হয় না। টেস্ট ফাংশনের কোনো argument নেই, কিছু ফেরতও দেয় না; তার একমাত্র ভাষা হলো “শেষ পর্যন্ত চলল” (পাস) বা “exception দিল” (ব্যর্থ)। - Arrange. দুটি অ্যাকাউন্টের একটি dict বানানো হলো। প্রতিটি টেস্ট নিজের ডেটা নিজে বানায় — অন্য টেস্টের রেখে যাওয়া অবস্থার ওপর নির্ভর করে না। নইলে টেস্টগুলোর ক্রম বদলালেই ফল বদলে যেত।
- Act. ঠিক একটি কাজ:
transferডাকা। এই লাইনটিই হলো “যা যাচাই করছি”. একটি টেস্টে কয়েকটি আলাদা Act থাকলে ব্যর্থ হওয়ার সময় বোঝা কঠিন হয় কোনটির দোষ। - Assert — সত্য, পাস।
balএখন ঠিক প্রত্যাশিত dict-এর সমান, তাইassertচুপচাপ পেরিয়ে যায়, ফাংশন স্বাভাবিকভাবে শেষ হয়। pytest একটি.(ডট) ছাপে — মানে একটি টেস্ট পাস। - যদি বাগ থাকত। ধরুন কেউ
transfer-এ প্রাপকের টাকা যোগ করার লাইনটি ভুলে মুছে ফেলেছেন। তখনkarimথাকে 0, শর্ত মিথ্যা,AssertionError. pytest ছাপেF, আর নিচে দুই পাশের আসল মান দেখায় — চোখে পড়ে,karim-এর 200 আসেনি।
pytest ছাড়াও এই ধারণা চলে। নিচের runnable প্রোগ্রামটি একটি ছোট নিজের হাতে বানানো test runner: কয়েকটি টেস্ট ফাংশন একে একে ডাকে, AssertionError ধরে “FAIL” ছাপে, আর শেষে গণনা দেখায়। pytest ভেতরে মূলত এই কাজটিই করে, অনেক বেশি সুবিধাসহ। grade() ফাংশনটি SSC-ধাঁচের লেটার গ্রেড দেয় (৮০–১০০ হলো A+, ৭০–৭৯ হলো A …, ৩৩-এর নিচে F), আর এর ভেতরে একটি বাগ লুকানো আছে। চালানোর আগে অনুমান করুন, কোন টেস্টটি ধরবে:
def grade(marks):
if not 0 <= marks <= 100:
raise ValueError(f"marks must be 0-100, got {marks}")
for cut, letter in [(80, "A+"), (70, "A"), (60, "A-"), (50, "B"), (40, "C"), (33, "D")]:
if marks > cut: # বাগটি এখানেই লুকানো
return letter
return "F"
def test_boundary_80():
assert grade(80) == "A+", f"grade(80) = {grade(80)!r}"
def test_just_below_80():
assert grade(79) == "A"
def test_zero_is_fail():
assert grade(0) == "F"
failed = 0
for test in (test_boundary_80, test_just_below_80, test_zero_is_fail):
try:
test() # Arrange-Act-Assert ভেতরে
except AssertionError as err:
failed += 1
print(f"FAIL {test.__name__}: {err}")
print(f"3 tests, {failed} failed")আউটপুট দুই লাইনের: FAIL test_boundary_80: grade(80) = 'A' আর 3 tests, 1 failed. বাগটি হলো >, যেখানে হওয়ার কথা >=: ঠিক ৮০ পেলে 80 > 80 মিথ্যা, তাই A+ না দিয়ে পরের ধাপে গিয়ে A দেয়। লক্ষ করুন, ৭৯ আর ০ — দুটোই পাস করেছে; শুধু ঠিক সীমার মান (৮০) বাগটি ধরেছে। ৫০, ৬৫, ৯০-এর মতো “মাঝের” সংখ্যা দিয়ে টেস্ট করলে বাগটি কোনোদিন ধরা পড়ত না। assert-এর পরে কমা দিয়ে লেখা অংশটি ব্যর্থতার বার্তা — সেটিই err-এ এসেছে। >-কে >= করে আবার চালালে আউটপুট হবে শুধু 3 tests, 0 failed — আর তখন ৩৩-এর সীমার বাগটিও একসাথে ঠিক হয়ে যায়।
ভালো টেস্টের কয়েকটি গুণ
- দ্রুত: হাজারটি unit test কয়েক সেকেন্ডে শেষ হওয়া উচিত, নইলে কেউ চালাবে না।
- স্বাধীন: যেকোনো ক্রমে, একা বা সবার সাথে চালালে একই ফল।
- পুনরাবৃত্তিযোগ্য: আজকের তারিখ, এলোমেলো সংখ্যা বা নেটওয়ার্কের ওপর নির্ভর করলে কাল ব্যর্থ হতে পারে — এগুলো নিয়ন্ত্রণে আনতে হয় (অংশ ৬ আর ৭)।
- আচরণ যাচাই করে, ভেতরের খুঁটিনাটি নয়: “ফল কী হলো” দেখুন, “কোন private ফাংশন কতবার ডাকা হলো” নয় — নইলে প্রতিটি refactoring-এ টেস্ট ভাঙবে, অথচ আচরণ ঠিকই আছে।
- নাম একটি বাক্য:
test_1নয়,test_zero_marks_gives_f. ব্যর্থ হলে নামটিই প্রথম খবর।
৩ · pytest-এর শুরু — ফাইল, কমান্ড আর আউটপুট পড়া
Python-এর সাথে unittest নামের একটি টেস্ট-কাঠামো আসে (অংশ ৮)। কিন্তু আজকের বেশিরভাগ Python প্রজেক্ট ব্যবহার করে pytest — একটি third-party package, যা কম লিখে বেশি কাজ করায়। পর্ব ২৩-এর মতো virtual environment চালু করে ইনস্টল করুন:
pip install pytest
pytest --versionpytest টেস্ট খোঁজে নামের নিয়মে
আপনি pytest-কে বলেন না কোন ফাংশন টেস্ট; সে নিজেই খুঁজে নেয়। একে বলে test discovery. Default নিয়ম তিনটি: ফাইলের নাম test_*.py (বা *_test.py), ফাংশনের নাম test_ দিয়ে শুরু, আর class থাকলে তার নাম Test দিয়ে শুরু। পর্ব ২২-এর প্রজেক্ট কাঠামো মনে করুন:
shop/
├── pricing.py # আসল কোড: add_vat, apply_discount
└── tests/
├── test_pricing.py # এখানে test_ ফাংশন
└── test_wallet.py
from pricing import add_vat
def test_add_vat_on_round_price():
assert add_vat(100) == 115
def test_add_vat_on_zero():
assert add_vat(0) == 0প্রজেক্টের মূল ফোল্ডার থেকে শুধু pytest চালালেই সব test_*.py ফাইল খুঁজে, প্রতিটি test_ ফাংশন ডেকে ফল জানায়। কয়েকটি কাজের option:
pytest # সব টেস্ট
pytest -v # প্রতিটি টেস্টের নাম আর PASSED/FAILED আলাদা লাইনে
pytest -x # প্রথম ব্যর্থতাতেই থামো
pytest -k vat # যে টেস্টের নামে "vat" আছে, শুধু সেগুলো
pytest tests/test_wallet.py # শুধু একটি ফাইল
pytest --lf # গতবার যেগুলো ব্যর্থ হয়েছিল, শুধু সেগুলোআউটপুট পড়া — ডট, F আর E
ধরা যাক test_grade.py-তে ছয়টি টেস্ট, আর আগের সেই > বনাম >= বাগটি আছে। pytest চালালে আপনি মোটামুটি এমন কিছু দেখবেন (version আর সময় আপনার কম্পিউটারে আলাদা হবে):
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.13.1, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/rahim/shop
collected 6 items
tests/test_grade.py .F.... [100%]
=================================== FAILURES ===================================
_______________________________ test_boundary_80 _______________________________
def test_boundary_80():
> assert grade(80) == "A+"
E AssertionError: assert 'A' == 'A+'
E
E - A+
E + A
tests/test_grade.py:9: AssertionError
=========================== short test summary info ============================
FAILED tests/test_grade.py::test_boundary_80 - AssertionError: assert 'A' == 'A+'
========================= 1 failed, 5 passed in 0.03s ==========================
লাইন ধরে পড়ুন:
collected 6 items: pytest ছয়টি টেস্ট খুঁজে পেয়েছে। সংখ্যাটি আপনার ধারণার চেয়ে কম হলে কোনো ফাইল বা ফাংশনের নামেtest_ভুল লেখা।.F....: প্রতিটি অক্ষর একটি টেস্ট, যে ক্রমে চলেছে।.মানে পাস,Fমানে assert ব্যর্থ (failure),Eমানে টেস্টের প্রস্তুতিতে বা পরিষ্কারে (fixture-এর setup বা teardown) exception (error) — যেমন fixture-এKeyError. টেস্ট ফাংশনের ভেতরে অপ্রত্যাশিত exception হলে সেটিওFহিসেবেই দেখায়। আরও দেখবেনs(skip করা) আরx(জেনেশুনে ব্যর্থ হিসেবে চিহ্নিত)।>চিহ্নের লাইন: ঠিক কোন লাইনে ব্যর্থ হয়েছে। নিচেরEলাইনগুলো বলে কেন: বাঁ পাশে আসল ফল'A', ডান পাশে প্রত্যাশা'A+'.-আর+দেওয়া অংশটি একটি diff — লম্বা string, list বা dict-এ ঠিক কোথায় অমিল, তা দেখায়।- শেষ লাইন: সারসংক্ষেপ। কোনো টেস্ট ব্যর্থ হলে
pytest-এর exit code হয় 1, সব পাস হলে 0 — অংশ ৯-এর CI এই সংখ্যাটি দেখেই সিদ্ধান্ত নেয়।
সাধারণ assert কেন যথেষ্ট — assertion rewriting
সাধারণ Python-এ assert grade(80) == "A+" ব্যর্থ হলে শুধু বলে AssertionError — কোন মান এসেছিল, বলে না। তাহলে pytest দুই পাশের মান জানল কীভাবে? উত্তর: assertion rewriting. টেস্ট ফাইল import করার সময় pytest তার কোড (পর্ব ০৫-এর AST স্তরে) একটু বদলে নেয়: প্রতিটি assert-এর দুই পাশের মান আগে আলাদা ভ্যারিয়েবলে রাখে, যাতে ব্যর্থ হলে সেগুলো ছাপা যায়। ফলে আপনাকে assertEqual, assertIn, assertGreater-এর মতো ডজনখানেক method মনে রাখতে হয় না — ==, in, >, not লিখলেই চলে। ভাবনার প্রশ্ন ২-এ দেখবেন, এই সিদ্ধান্তই pytest-কে জনপ্রিয় করেছে।
আসল কোডে assert দিয়ে ইনপুট যাচাই করবেন না। python -O দিয়ে চালালে Python সব assert মুছে ফেলে (পর্ব ১৩)। টেস্ট ফাইলে assert নিরাপদ, কারণ টেস্ট সাধারণত -O দিয়ে চালানো হয় না। কিন্তু pricing.py-তে “দাম ঋণাত্মক হবে না” যাচাই করতে if price < 0: raise ValueError(...) লিখুন।
প্রত্যাশিত exception — pytest.raises
অনেক প্রতিশ্রুতি হলো “ভুল ইনপুটে error দেবে”: ঋণাত্মক দামে ValueError, ব্যালান্সের বেশি পাঠালে ব্যর্থতা। এটি টেস্ট করতে pytest.raises একটি context manager (পর্ব ৩৬) হিসেবে কাজ করে: with-এর ভেতরে নির্দিষ্ট exception এলে টেস্ট পাস; না এলে টেস্ট ব্যর্থ।
import pytest
from pricing import add_vat
def test_negative_price_is_rejected():
with pytest.raises(ValueError, match="negative"):
add_vat(-5)
def test_vat_on_paisa_price():
assert add_vat(19.99) == pytest.approx(22.9885)
assert 0.1 + 0.2 == pytest.approx(0.3)match= একটি regular expression (পর্ব ২৮), যা error-বার্তার সাথে মেলানো হয় — এতে নিশ্চিত হন যে ঠিক কারণেই error এসেছে, অন্য কোনো ValueError নয়।
Float তুলনা — pytest.approx
পর্ব ০৭-এ দেখেছেন, floating point-এ 0.1 + 0.2 ঠিক 0.3 নয়, বরং 0.30000000000000004. আরও চমকপ্রদ: 100 * 1.15-এর ফল 114.99999999999999! অর্থাৎ add_vat ভেতরে price * 1.15 করলে এই অংশের শুরুর test_add_vat_on_round_price (== 115) আসলে ব্যর্থ হবে — অথচ হিসাবটি কার্যত ঠিক। pytest.approx বলে “প্রায় সমান হলেই চলবে” — default-এ আপেক্ষিক পার্থক্য দশ লাখ ভাগের এক ভাগের (1e-6) মধ্যে হলে সমান ধরে। দরকার হলে approx(x, abs=0.01) দিয়ে নিজের সীমা দিন। (টাকার হিসাবে আসল সমাধান অবশ্য পর্ব ০৭-এর Decimal; তখন সরাসরি == চলে।)
৪ · parametrize — এক টেস্টে অনেক কেস, নজর সীমায়
গ্রেড ফাংশনের জন্য কতগুলো টেস্ট দরকার? প্রতিটি গ্রেডের সীমার দুই পাশ (৮০/৭৯, ৭০/৬৯ …), দুই প্রান্ত (০ আর ১০০), আর দুটি অবৈধ মান (−১ আর ১০১)। এক-একটি আলাদা ফাংশন লিখলে বিশটি প্রায় একই রকম ফাংশন — কপি-পেস্টের কারখানা। সমাধান হলো parametrize: একটি টেস্ট ফাংশন লিখুন, আর তার ওপরে কেসগুলোর একটি তালিকা দিন। pytest প্রতিটি কেসের জন্য আলাদা টেস্ট বানিয়ে চালায়। এটি একটি decorator factory — পর্ব ৩৫-এ যার ছাঁচ দেখেছেন।
ঠিক সীমার মান আর তার ঠিক নিচের মান — boundary value — বেছে নেওয়ার কারণটি আগের runnable প্রোগ্রামেই দেখেছেন: > বনাম >=-এর মতো ভুল শুধু সীমাতেই ধরা পড়ে। নিচের স্টেপারে grade()-এর একটি if/elif সংস্করণ, যার প্রথম শর্তে লেখা marks > 80 (বাকিগুলোয় ঠিকঠাক >=)। একটি টেস্ট কীভাবে ছয়টি হয়ে যায় দেখুন:
1@pytest.mark.parametrize("marks, want", [2 (100, "A+"), (80, "A+"), (79, "A"),3 (33, "D"), (32, "F"), (0, "F"),4])5def test_grade(marks, want):6 assert grade(marks) == want
| marks | |
| want | |
| grade(marks) | |
| এ পর্যন্ত |
- সংগ্রহ — এক থেকে ছয়। pytest ফাইল পড়ার সময়েই decorator-এর তালিকা দেখে
test_grade-কে ছয়টি আলাদা টেস্টে ভাগ করে:test_grade[100-A+],test_grade[80-A+]… বন্ধনীর ভেতরের অংশ (test id) কেসের মানগুলো থেকে বানানো।"marks, want"string-টি বলে দেয়, প্রতিটি tuple-এর প্রথম মান যাবেmarksparameter-এ, দ্বিতীয়টিwant-এ। - কেস ১: ১০০ → A+. উপরের প্রান্ত।
100 > 80সত্য, তাই A+. পাস। প্রান্তের মান টেস্ট করা জরুরি —<= 100-এর জায়গায়< 100লেখা থাকলে এখানেই ধরা পড়ত। - কেস ২: ৮০ → A+? ব্যর্থ। ঠিক সীমায়।
80 > 80মিথ্যা, তাই ফাংশন পরের শাখায় গিয়ে A দেয়।assert 'A' == 'A+'ব্যর্থ,FAILED. লক্ষ করুন, একটি কেস ব্যর্থ হলেও pytest থামে না — বাকি কেসগুলো চলতেই থাকে, কারণ প্রতিটি আলাদা টেস্ট। - কেস ৩: ৭৯ → A. সীমার ঠিক নিচে। পাস। এই জোড়াটি (৭৯ আর ৮০) একসাথে বলে দেয় সীমাটি ঠিক কোথায় বসানো — একটি দিয়ে বোঝা যায় না।
- কেস ৪: ৩৩ → D. ঠিক পাসের সীমায়। এখানে কোড ঠিক (
>= 33), তাই পাস। একই বাগ যে সব সীমায় থাকবে, তা নয় — তাই প্রতিটি সীমা আলাদা করে টেস্ট করা। - কেস ৫: ৩২ → F. পাসের সীমার ঠিক নিচে, পাস। ৩৩ আর ৩২ মিলে নিশ্চিত করে, ফেল-পাসের রেখাটি ঠিক জায়গায় — এক নম্বরের এদিক-ওদিক হলে হাজারো শিক্ষার্থীর ফল বদলে যেত।
- কেস ৬: ০ → F, আর সারসংক্ষেপ। নিচের প্রান্ত, পাস। ফল:
1 failed, 5 passed, আর ব্যর্থ টেস্টের নামtest_grade[80-A+]নিজেই বলে দেয় কোন ইনপুটে সমস্যা। ছয় লাইনের একটি টেস্ট ছয়টি প্রতিশ্রুতি পাহারা দিচ্ছে; নতুন কেস যোগ করা মানে তালিকায় একটি tuple.
অবৈধ মানের জন্য আলাদা একটি parametrize, কারণ এখানে প্রত্যাশা একটি মান নয়, একটি exception:
@pytest.mark.parametrize("marks", [-1, 101])
def test_out_of_range_marks_raise(marks):
with pytest.raises(ValueError):
grade(marks)বাস্তব উদাহরণ — বাংলাদেশি ফোন নম্বর
একটি ফাংশন parse_phone(raw) ব্যবহারকারীর লেখা নম্বর নিয়ে ১১ অঙ্কের একটি নির্দিষ্ট রূপ (01XXXXXXXXX) ফেরত দেয়, আর ভুল নম্বরে ValueError দেয়। মানুষ নম্বর লেখে নানা রূপে — +880 দিয়ে, 880 দিয়ে, মাঝে ফাঁকা রেখে। parametrize এখানে একটি স্পেসিফিকেশনের মতো পড়া যায়:
@pytest.mark.parametrize("raw, want", [
("01712345678", "01712345678"),
("+8801712345678", "01712345678"),
("8801912345678", "01912345678"),
("017 1234 5678", "01712345678"),
])
def test_valid_numbers_are_normalized(raw, want):
assert parse_phone(raw) == want
@pytest.mark.parametrize("raw", [
"", # খালি
"0171234567", # ১০ অঙ্ক — একটি কম
"01212345678", # 012 — কোনো অপারেটর নেই
"017১২৩৪৫৬৭৮", # বাংলা অঙ্ক মেশানো — সিদ্ধান্ত: নেব না
])
def test_invalid_numbers_raise(raw):
with pytest.raises(ValueError):
parse_phone(raw)শেষ কেসটি খেয়াল করুন। পর্ব ২৮-এর regex-এ \d শুধু 0–9 নয়, যেকোনো Unicode অঙ্ক মেলায় — বাংলা ১২৩-ও! তাই r"01[3-9]\d{8}" প্যাটার্নটি "017১২৩৪৫৬৭৮"-কে বৈধ বলে দেবে, অথচ SMS গেটওয়ে সেটি বুঝবে না। এই টেস্টটি না থাকলে বাগটি ধরা পড়ত প্রথম বাংলা কি-বোর্ড ব্যবহারকারীর অভিযোগে। সমাধান [0-9] বা re.ASCII — অথবা সিদ্ধান্ত নিন যে বাংলা অঙ্ক ইংরেজিতে রূপান্তর করে নেবেন। যে সিদ্ধান্তই নিন, টেস্টে লিখে রাখুন; তখন সিদ্ধান্তটি আর কারও স্মৃতির ওপর নির্ভর করে না।
একই টেস্ট, অনেক implementation: পর্ব ৩৩-এ একটি Protocol চুক্তির কথা বলা হয়েছিল — যেমন Notifier, যার SmsNotifier, EmailNotifier আর একটি fake আছে। parametrize দিয়ে একই টেস্ট-সেট সব implementation-এর ওপর চালানো যায় (তালিকায় class-গুলো দিন)। একে কখনো contract test বলা হয়: প্রতিটি নতুন implementation চুক্তি মানছে কি না, স্বয়ংক্রিয়ভাবে যাচাই।
৫ · fixture — টেস্টের প্রস্তুতি আর পরিষ্কার
wallet-এর দশটি টেস্টের প্রতিটির শুরুতে যদি একই তিন লাইন থাকে — “একটি wallet বানাও, মালিক রহিম, ব্যালান্স ৫০০” — তাহলে সেটি একবার লিখে সবাইকে দেওয়া যায়। pytest-এ এর নাম fixture: @pytest.fixture দিয়ে চিহ্নিত একটি ফাংশন, যা টেস্টের জন্য দরকারি জিনিস তৈরি করে দেয়। টেস্ট ফাংশন সেটি চায় শুধু parameter-এর নাম লিখে:
import pytest
from wallet import Wallet
@pytest.fixture
def wallet():
return Wallet(owner="রহিম", balance=500)
def test_withdraw_reduces_balance(wallet): # নাম মিলিয়ে fixture আসে
wallet.withdraw(200)
assert wallet.balance == 300
def test_withdraw_too_much_changes_nothing(wallet):
with pytest.raises(ValueError, match="insufficient"):
wallet.withdraw(900)
assert wallet.balance == 500 # ব্যর্থ লেনদেনে টাকা অক্ষতএটি আসলে dependency injection — পর্ব ৩৩-এ যা দেখেছেন: টেস্ট নিজে wallet বানায় না, বাইরে থেকে হাতে পায়। আর গুরুত্বপূর্ণ কথা: প্রতিটি টেস্ট নতুন একটি wallet পায়. প্রথম টেস্ট ২০০ তুলে নিলেও দ্বিতীয় টেস্ট আবার ৫০০ দিয়ে শুরু করে। টেস্টগুলো একে অপরের ওপর নির্ভর করে না।
yield fixture — প্রস্তুতির পরে পরিষ্কারও
কিছু জিনিস তৈরির পরে গুছিয়ে রাখতে হয়: খোলা ফাইল বন্ধ করা, অস্থায়ী ডেটাবেস মুছে ফেলা। fixture-এ return-এর বদলে yield লিখলে (পর্ব ৩৪-এর generator) yield-এর আগের অংশ প্রস্তুতি (setup), পরের অংশ পরিষ্কার (teardown). সঙ্গে আরেকটি তৈরি fixture: tmp_path — প্রতিটি টেস্টের জন্য একটি নতুন খালি অস্থায়ী ফোল্ডার, পর্ব ২৫-এর pathlib.Path হিসেবে। আসল ফাইলে হাত না দিয়ে ফাইল-I/O টেস্ট করার নিরাপদ উপায়।
- parameter দেখা
ledgerনাম মিলিয়ে খোঁজ - setup
yield-এর আগেফাইল তৈরি - টেস্ট চলে
ledger = fপাস বা ব্যর্থ - teardown
yield-এর পরেসবসময় চলে
def test_add_entry(ledger): # pytest নামটি দেখে ledger.write("1,500\n") # @pytest.fixture def ledger খোঁজে ...
@pytest.fixture def ledger(tmp_path): # tmp_path-ও একটি fixture path = tmp_path / "ledger.csv" f = path.open("w", encoding="utf-8") yield f # এখানে থামে, f দেয় f.close()
def test_add_entry(ledger): ledger.write("1,500\n") assert ... # পাস হোক বা ব্যর্থ — # fixture অপেক্ষায় আছে
yield f f.close() # ফাইল বন্ধ # tmp_path ফোল্ডার pytest নিজে সামলায়; # পরের টেস্ট পায় নতুন, খালি ফোল্ডার
- টেস্ট কী চায়, pytest দেখে। টেস্ট ফাংশনের parameter
ledger. pytest একই নামের fixture খোঁজে — একই ফাইলে, বাconftest.py-তে। fixture নিজেও অন্য fixture চাইতে পারে:ledgerচায়tmp_path, তাই pytest আগে একটি অস্থায়ী ফোল্ডার বানায়। - setup — yield পর্যন্ত চলে। fixture ফাংশনটি generator-এর মতো চলে: ফাইলের পথ বানায়, ফাইল খোলে, আর
yield f-এ এসে থেমে যায়।yield-এর মানটিই টেস্টেরledgerparameter হবে। - টেস্ট চলে। টেস্ট খোলা ফাইলে লেখে, তারপর assert করে। এই সময় fixture-এর বাকি অংশ স্থগিত — পর্ব ৩৪-এর generator যেমন
yield-এ বিরতি নেয়। - teardown — সবসময়। টেস্ট শেষ হলে pytest generator-কে আবার চালায়:
yield-এর পরেরf.close(). টেস্ট ব্যর্থ হলেও এটি চলে — অনেকটাtry/finally-এর মতো (পর্ব ২৪)। তবে setup অংশে (yield-এর আগে) exception হলে teardown চলে না, কারণyield-এ পৌঁছানোই হয়নি — তখন টেস্টের ফল দেখায়E.
fixture-এর scope আর conftest.py
Default-এ fixture প্রতিটি টেস্টের জন্য নতুন করে চলে — এটাই সবচেয়ে নিরাপদ। কিন্তু কিছু প্রস্তুতি ব্যয়বহুল: একটি টেস্ট ডেটাবেস তৈরি করতে দুই সেকেন্ড লাগলে পাঁচশো টেস্টে প্রায় ১৭ মিনিট! তখন fixture scope বাড়ানো যায়: @pytest.fixture(scope="module") মানে একটি ফাইলের সব টেস্ট একই object ভাগ করবে, scope="session" মানে পুরো pytest-চলায় একবার। মূল্য হলো ঝুঁকি — একটি টেস্ট ভাগ করা object বদলে দিলে পরের টেস্ট অন্য অবস্থা দেখবে। তাই বড় scope শুধু এমন জিনিসে, যা টেস্টগুলো বদলায় না (বা প্রতিটি টেস্টের পরে আগের অবস্থায় ফিরিয়ে দেওয়া হয়)।
অনেক ফাইলে একই fixture লাগলে সেটি রাখুন conftest.py নামের ফাইলে। pytest এই ফাইল নিজে খুঁজে পড়ে, আর তার fixture-গুলো সেই ফোল্ডার আর নিচের সব টেস্ট ফাইলে import ছাড়াই পাওয়া যায়।
৬ · print, input আর পরিবেশ — capsys আর monkeypatch
Pure function (পর্ব ১৩) টেস্ট করা সবচেয়ে সহজ: ইনপুট দিন, ফল মেলান। কিন্তু বাস্তবের ফাংশন কখনো print করে, কখনো input() দিয়ে পড়ে, কখনো environment variable থেকে API key নেয়। এগুলোর জন্য pytest দুটি তৈরি fixture দেয়।
capsys — যা ছাপা হলো, তা ধরা
capsys টেস্ট চলার সময় stdout আর stderr-এ লেখা সব কিছু ধরে রাখে। capsys.readouterr() দুটি string ফেরত দেয় — এ পর্যন্ত যা ছাপা হয়েছে:
def print_receipt(name, amount):
print(f"{name}: {amount} টাকা")
def test_receipt_line(capsys):
print_receipt("রহিম", 200)
out, err = capsys.readouterr()
assert out == "রহিম: 200 টাকা\n" # print শেষে \n যোগ করে
assert err == ""monkeypatch — input আর পরিবেশ সাময়িক বদলানো
monkeypatch টেস্ট চলার সময় কোনো ফাংশন, attribute বা environment variable সাময়িকভাবে বদলে দেয়, আর টেস্ট শেষে নিজেই আগের অবস্থায় ফিরিয়ে আনে। টেস্টে তো কেউ কি-বোর্ডে টাইপ করবে না, তাই input-কে একটি ফাংশন দিয়ে বদলে দিন, যা নির্দিষ্ট উত্তর দেয়:
import os
import pytest
def ask_amount():
return int(input("কত টাকা? "))
def load_api_key():
return os.environ["SMS_API_KEY"]
def test_ask_amount(monkeypatch):
monkeypatch.setattr("builtins.input", lambda prompt: "250")
assert ask_amount() == 250
def test_api_key_comes_from_environment(monkeypatch):
monkeypatch.setenv("SMS_API_KEY", "test-key")
assert load_api_key() == "test-key"
def test_missing_api_key_raises(monkeypatch):
monkeypatch.delenv("SMS_API_KEY", raising=False)
with pytest.raises(KeyError):
load_api_key()লক্ষ করুন, টেস্টে কখনো আসল API key নেই — একটি নকল "test-key". আর তৃতীয় টেস্টটি একটি edge case পাহারা দেয়: key না থাকলে কী হয়? এ ধরনের টেস্ট প্রায়ই বাদ পড়ে, অথচ নতুন সার্ভারে deploy করার দিন ঠিক এটিই ঘটে।
নকশার ইঙ্গিত: একটি ফাংশন টেস্ট করতে যত বেশি monkeypatch লাগে, ফাংশনটি তত বেশি বাইরের জগতের সাথে জট পাকানো। প্রায়ই ভালো সমাধান হলো হিসাবের অংশ আলাদা pure function-এ সরিয়ে নেওয়া (parse_amount(text)), আর input() থাকুক একটি পাতলা বাইরের স্তরে। টেস্ট লেখা কঠিন লাগা প্রায়ই নকশার সমস্যার প্রথম সংকেত।
৭ · test double — fake বনাম mock
wallet থেকে টাকা পাঠালে গ্রাহককে SMS যায়। টেস্ট চালালেই কি সত্যিকারের SMS যাবে? না — তাতে টাকা খরচ, নেটওয়ার্ক লাগে, আর নেটওয়ার্ক খারাপ হলে টেস্ট অকারণে ব্যর্থ হয়। সমাধান হলো আসল জিনিসের জায়গায় টেস্টের জন্য একটি বদলি বসানো। সিনেমার stunt double-এর নাম থেকে এদের সাধারণ নাম test double. দুই ধরনের সাথে আপনার বেশি দেখা হবে:
Fake — নিজের হাতে বানানো হালকা বদলি
পর্ব ৩৩-এর fake object: একই চুক্তি মানে, কিন্তু আসল কাজ না করে শুধু লিখে রাখে। যেমন FakeSMS, যার send() বার্তাটি একটি list-এ জমা করে। টেস্ট পরে list দেখে: কী পাঠানো হলো? সরল, পড়তে সহজ, আর ভেতরের খুঁটিনাটির ওপর নির্ভর করে না।
Mock — যেকোনো রূপ নিতে পারা object
mock object (unittest.mock.Mock) যেকোনো method ডাক মেনে নেয়, আর মনে রাখে কে কী argument দিয়ে কতবার ডাকল। পরে জিজ্ঞেস করা যায়: send কি ঠিক একবার এই বার্তা দিয়ে ডাকা হয়েছে? শক্তিশালী, কিন্তু বেশি ব্যবহারে টেস্ট ভঙ্গুর হয়।
Python-এর স্ট্যান্ডার্ড লাইব্রেরিতে unittest.mock আছে (Python 3.3 থেকে)। দুটি ব্যবহার দেখুন:
from unittest.mock import Mock, patch
def test_transfer_sends_one_sms():
sms = Mock()
bal = {"rahim": 500, "karim": 0}
transfer(bal, "rahim", "karim", 200, notify=sms.send)
sms.send.assert_called_once_with("rahim: sent 200 Tk")
@patch("rates.http_get") # rates module যে নামে দেখে, সেটি বদলাও
def test_usd_rate(fake_get):
fake_get.return_value = {"usd": 121.5}
assert rates.fetch_rate("usd") == 121.5দ্বিতীয়টি patch: টেস্ট চলার সময় একটি module-এর একটি নাম সাময়িক mock দিয়ে বদলে দেয় (monkeypatch-এর মতোই)। সবচেয়ে চেনা ফাঁদ: patch করতে হয় যেখানে নামটি খোঁজা হয়, যেখানে সংজ্ঞায়িত হয়েছে সেখানে নয়। rates.py-তে from net import http_get লেখা থাকলে patch করুন "rates.http_get", "net.http_get" নয় — কারণ import-এর পরে rates-এর নিজের namespace-এ নামটির একটি আলাদা বাঁধন আছে (পর্ব ২২)।
কেন প্রথমে fake আর dependency injection
Mock-এর সহজলভ্যতা একটি অভ্যাস তৈরি করে: সব কিছু mock করো, আর পরীক্ষা করো কোন method কতবার ডাকা হলো। সমস্যা হলো, তখন টেস্ট যাচাই করে কোড কীভাবে লেখা, কোড কী করে তা নয়। ভেতরের একটি ডাক রিফ্যাক্টর করলেই দশটি টেস্ট ভাঙে, অথচ আচরণ একই। উল্টো দিকে, এত বেশি mock করা যায় যে টেস্ট আসলে শুধু mock-কেই পরীক্ষা করে — আসল কোডের কিছুই নয়।
ভালো অভ্যাস: ফাংশন যেন বাইরের জিনিস (SMS পাঠানোর উপায়, ঘড়ি, রেট আনার ফাংশন) parameter হিসেবে নেয় — অর্থাৎ dependency injection. তাহলে টেস্টে আসলটির বদলে একটি সাধারণ fake দেওয়া যায়, কোনো patch লাগে না। patch রাখুন সেই জায়গার জন্য, যেখানে কোড আপনার হাতে নেই। পরের অংশের runnable প্রোগ্রামে ঠিক এই কৌশল দেখবেন: transfer একটি notify ফাংশন নেয়, আর টেস্ট তাকে দেয় একটি list-এর append — সবচেয়ে সরল fake.
mock করুন সীমানায় — নেটওয়ার্ক, ঘড়ি, ফাইল সিস্টেম, পেমেন্ট গেটওয়ে। নিজের কোডের ভেতরের ফাংশন mock করবেন না। আর প্রতিটি mock-এর সামনে নিজেকে জিজ্ঞেস করুন: এই টেস্ট কি আচরণ যাচাই করছে, নাকি শুধু আমার কোড কোন ক্রমে কাকে ডাকে তার ছবি তুলছে?
৮ · স্ট্যান্ডার্ড লাইব্রেরির unittest আর doctest
pytest আসার আগে থেকেই Python-এর সাথে আসে unittest — Java-র JUnit-এর আদলে তৈরি (ভাবনার প্রশ্ন ১)। পুরোনো প্রজেক্টে, Python-এর নিজের কোডবেসে আর অনেক কোম্পানির কোডে এটি দেখবেন, তাই পড়তে জানা দরকার। এর ধরন class-ভিত্তিক: টেস্টগুলো একটি TestCase subclass-এর test_ method, প্রস্তুতি setUp-এ, আর যাচাই assertEqual, assertTrue, assertRaises-এর মতো method দিয়ে। ভালো খবর: pytest unittest-এর টেস্টও চালাতে পারে, তাই পুরোনো টেস্ট রেখেই নতুনগুলো pytest-ধাঁচে লেখা যায়।
নিচের runnable প্রোগ্রাম দুটি জিনিস একসাথে দেখায় — unittest-এর গঠন, আর অংশ ৭-এর dependency injection-সহ fake. ব্রাউজারে pytest চলে না, তাই টেস্টগুলো চালানো হয়েছে প্রোগ্রামের ভেতর থেকে, আর ফলের গণনা নিজে ছাপা হয়েছে:
import unittest
def transfer(bal, src, dst, amount, notify):
if amount <= 0 or bal[src] < amount:
raise ValueError("insufficient balance or bad amount")
bal[src] -= amount
bal[dst] += amount
notify(f"{src}: sent {amount} Tk")
class TestTransfer(unittest.TestCase):
def setUp(self): # প্রতিটি টেস্টের আগে নতুন করে
self.bal, self.sms = {"rahim": 500, "karim": 0}, []
def test_moves_money_and_notifies(self):
transfer(self.bal, "rahim", "karim", 200, self.sms.append)
self.assertEqual(self.bal, {"rahim": 300, "karim": 200})
self.assertEqual(self.sms, ["rahim: sent 200 Tk"])
def test_insufficient_balance_changes_nothing(self):
with self.assertRaises(ValueError):
transfer(self.bal, "rahim", "karim", 900, self.sms.append)
self.assertEqual((self.bal["rahim"], self.sms), (500, []))
result = unittest.TestResult()
unittest.defaultTestLoader.loadTestsFromTestCase(TestTransfer).run(result)
print(result.testsRun, "tests,", len(result.failures), "failed, OK =", result.wasSuccessful())আউটপুট এক লাইন: 2 tests, 0 failed, OK = True. defaultTestLoader class-টি থেকে test দিয়ে শুরু দুটি method খুঁজে একটি suite বানিয়েছে, আর TestResult object-এ ফল জমা হয়েছে। প্রতিটি টেস্টের আগে setUp চলেছে, তাই দ্বিতীয় টেস্ট আবার ৫০০ টাকা আর খালি SMS-তালিকা দিয়ে শুরু করেছে — pytest fixture-এর মতোই। fake হলো self.sms.append: transfer যে বার্তা “পাঠায়”, তা শুধু list-এ জমা হয়। দ্বিতীয় টেস্ট দুটি জিনিস যাচাই করে — ValueError এসেছে, আর ব্যালান্স ও SMS-তালিকা অক্ষত। শুধু error এসেছে দেখলে অর্ধেক প্রতিশ্রুতি যাচাই হয়; টাকা কেটে নিয়ে তারপর error দিলেও প্রথম অংশ পাস করত। (সাধারণত unittest চালানো হয় python -m unittest দিয়ে, যা ফল লেখে stderr-এ আর সময়ও দেখায়; এখানে নিজে গণনা ছাপা হয়েছে যাতে আউটপুট প্রতিবার হুবহু এক হয়।) transfer-এর bal[dst] += amount লাইনটি মুছে চালালে দেখবেন 2 tests, 1 failed, OK = False.
একই প্রথম টেস্ট pytest-ধাঁচে কত ছোট, তুলনা করুন — class নেই, self নেই, assertEqual নেই:
def test_moves_money_and_notifies():
bal, sms = {"rahim": 500, "karim": 0}, []
transfer(bal, "rahim", "karim", 200, sms.append)
assert bal == {"rahim": 300, "karim": 200}
assert sms == ["rahim: sent 200 Tk"]doctest — ডকুমেন্টেশনের উদাহরণই টেস্ট
docstring-এ অনেকে REPL-এর মতো উদাহরণ লেখেন। doctest module সেই উদাহরণগুলো চালিয়ে দেখে, লেখা ফল আর আসল ফল মেলে কি না:
def grade(marks):
"""০–১০০ নম্বর থেকে লেটার গ্রেড দেয়।
>>> grade(80)
'A+'
>>> grade(32)
'F'
>>> grade(101)
Traceback (most recent call last):
ValueError: marks must be 0-100, got 101
"""
...python -m doctest grading.py -v (বা pytest --doctest-modules) চালালে উদাহরণগুলো যাচাই হয়। লাভ: ডকুমেন্টেশনের উদাহরণ কখনো মিথ্যা হয়ে পড়ে থাকে না। সীমা: আউটপুট হুবহু মেলাতে হয় (একটি বাড়তি স্পেস, dict-এর ক্রম বা 114.99999999999999-এর মতো float-এর শেষ অঙ্ক বদলালেই ব্যর্থ), আর জটিল প্রস্তুতি লেখা কষ্টকর। তাই doctest ভালো ছোট, উদাহরণধর্মী ফাংশনের জন্য; মূল টেস্ট থাকুক pytest-এ।
৯ · কী টেস্ট করবেন — edge case, coverage, TDD আর CI
pytest শেখা সহজ; কঠিন হলো সিদ্ধান্ত নেওয়া কোন কেসগুলো টেস্ট করবেন। বাগ সাধারণত মাঝখানের সাধারণ ইনপুটে থাকে না — থাকে কিনারায়। প্রতিটি ফাংশনের জন্য এই তালিকাটি মাথায় রাখুন:
| কী ভাববেন | উদাহরণ |
|---|---|
| খালি | খালি list-এর গড় (ZeroDivisionError?), খালি string নাম, খালি ফাইল |
| একটি | মাত্র একজন ছাত্রের ক্লাস, এক অক্ষরের নাম |
| অনেক | দশ হাজার লেনদেন; একই মান বারবার (ডুপ্লিকেট) |
| সীমা | ৭৯/৮০, ০/১০০, ঠিক ব্যালান্সের সমান টাকা তোলা, মাসের শেষ দিন |
| অবৈধ | −১, ১০১, ঋণাত্মক দাম, None, ভুল টাইপ ("500" string হিসেবে) |
| বাংলা ও Unicode | বাংলা নাম, বাংলা অঙ্ক, যুক্তাক্ষর (পর্ব ০৯), ইমোজি, শুরু-শেষে ফাঁকা |
| বাইরের জগৎ | ফাইল নেই, environment variable নেই, নেটওয়ার্ক timeout |
Coverage — কোন লাইন কখনো চলেনি
test coverage মাপে, টেস্ট চলার সময় আপনার কোডের কত শতাংশ লাইন অন্তত একবার চলেছে। pytest-cov plugin ইনস্টল করে:
pip install pytest-cov
pytest --cov=pricing --cov-report=term-missingName Stmts Miss Cover Missing
------------------------------------------
pricing.py 18 2 89% 14, 22
সবচেয়ে কাজের অংশ হলো Missing কলাম: লাইন ১৪ আর ২২ কোনো টেস্টে চলেনি — হয়তো ছাড়ের একটি শাখা, বা একটি error-পথ। সেগুলোর জন্য টেস্ট ভাবুন। কিন্তু সীমাটিও জানুন: coverage বলে কোন লাইন চলেছে, বলে না সেটি ঠিক ফল দিয়েছে কি না. একটি টেস্ট সব লাইন চালিয়ে কোনো assert না করলেও ১০০% coverage আসে। আর if marks > 80 লাইনটি ৯০ দিয়ে চালালেও “covered”, অথচ ৮০-র বাগ অধরা। Coverage কম হলে নিশ্চিতভাবে সমস্যা; বেশি হলে নিশ্চিতভাবে কিছুই প্রমাণ হয় না। (--cov-branch দিলে প্রতিটি if-এর দুই দিকই চলেছে কি না দেখে — লাইনের চেয়ে একটু ভালো মাপ।)
TDD — আগে টেস্ট, তারপর কোড
Test-driven development (TDD) ক্রমটি উল্টে দেয়: কোড লেখার আগে একটি ব্যর্থ টেস্ট লিখুন। চক্রটির তিন রং — লাল (নতুন টেস্ট লিখুন, সেটি ব্যর্থ হোক), সবুজ (টেস্ট পাস করানোর মতো সবচেয়ে সরল কোড লিখুন), গোছানো (টেস্ট সবুজ রেখে কোড পরিষ্কার করুন)। তারপর পরের টেস্ট। ফোন-নম্বর ফাংশনে চক্রটি দেখুন:
- লাল ১
প্রথম টেস্টব্যর্থ হতেই হবে - সবুজ ১
সরলতম কোড১ পাস - লাল ২
+880 টেস্টনতুনটি ব্যর্থ - সবুজ ২
চলার মতো কোড২ পাস - গোছানো
refactorটেস্ট সবুজই থাকে
def test_local_number_unchanged(): assert parse_phone("01712345678") == "01712345678" $ pytest → F NameError: name 'parse_phone' is not defined
def parse_phone(raw): return raw $ pytest → . 1 passed
def test_country_code_removed(): assert parse_phone("+8801712345678") == "01712345678" $ pytest → .F assert '+8801712345678' == '01712345678'
def parse_phone(raw): if raw.startswith("+880"): raw = raw[3:] return raw $ pytest → .. 2 passed
PHONE = re.compile(r"(?:\+?88)?(01[3-9][0-9]{8})") def parse_phone(raw): m = PHONE.fullmatch(raw.replace(" ", "")) if not m: raise ValueError(f"invalid phone: {raw!r}") return m.group(1) $ pytest → .. 2 passed (তারপর নতুন লাল টেস্ট…)
- লাল — ফাংশন লেখার আগেই টেস্ট। প্রথম প্রতিশ্রুতি: স্থানীয় ১১ অঙ্কের নম্বর যেমন আছে তেমনই ফেরত আসবে। ফাংশন এখনো নেই, তাই টেস্ট ব্যর্থ। ব্যর্থ হতে দেখা জরুরি — এটি প্রমাণ করে টেস্টটি সত্যিই কিছু যাচাই করে। কখনো পাস না করা টেস্ট যেমন অকেজো, কখনো ব্যর্থ না হওয়া টেস্টও তেমনি সন্দেহজনক।
- সবুজ — লজ্জাজনকভাবে সরল।
return raw! হাস্যকর মনে হয়, কিন্তু উদ্দেশ্যটি গভীর: টেস্ট যা চায় না, তা লিখবেন না। কোড যত এগোয়, প্রতিটি নতুন টেস্ট কোডকে একটু একটু করে আসল আকৃতির দিকে ঠেলে। - লাল — পরের প্রতিশ্রুতি।
+880দিয়ে লেখা নম্বর থেকে দেশের কোড বাদ যাবে। নতুন টেস্ট ব্যর্থ (F), পুরোনোটি এখনো পাস (.)। একবারে একটিই নতুন আচরণ। - সবুজ — চলে, কিন্তু অগোছালো।
startswithদিয়ে চার অক্ষর দেখে প্রথম তিনটি কেটে ফেলা — দুটি টেস্টই পাস।880(প্লাস ছাড়া), ফাঁকা, ভুল নম্বর — এখনো কিছুই সামলায় না, কিন্তু সে সব নিয়ে এখনো কোনো টেস্ট নেই। - গোছানো — টেস্টের সুরক্ষায়। এখন সাহস করে নকশা বদলানো যায়: পর্ব ২৮-এর একটি regex (
[0-9], যাতে বাংলা অঙ্ক না ঢোকে), ফাঁকা বাদ, আর ভুল হলেValueError. দুটি পুরোনো টেস্ট এখনো সবুজ — আচরণ ভাঙেনি, তার প্রমাণ। তারপর চক্র আবার:880, ফাঁকাসহ নম্বর, অবৈধ নম্বর — প্রতিটির জন্য আগে লাল টেস্ট।
TDD সবাই সব সময় করেন না, আর করতেই হবে এমনও নয়। কিন্তু দুটি জায়গায় এটি প্রায় সবসময় লাভজনক: বাগ ঠিক করার সময় (আগে বাগটি দেখায় এমন একটি ব্যর্থ টেস্ট, তারপর সমাধান — তাতে নিশ্চিত হন যে আপনি সত্যিই সেই বাগই ঠিক করেছেন), আর যখন প্রতিশ্রুতি পরিষ্কার কিন্তু কোড জটিল (যেমন ফোন নম্বর, VAT-এর নিয়ম, গ্রেডিং)।
Property-based testing — হাজারো ইনপুট নিজে বানানো
এ পর্যন্ত প্রতিটি কেস আপনি নিজে বেছেছেন। property-based testing-এ আপনি লেখেন একটি সাধারণ নিয়ম (property), আর একটি লাইব্রেরি শত শত এলোমেলো ইনপুট বানিয়ে নিয়মটি ভাঙার চেষ্টা করে। Python-এ এর প্রধান লাইব্রেরি hypothesis. যেমন নিয়ম: “যেকোনো বৈধ নম্বর parse_phone-এ দিলে ফল সবসময় ১১ অঙ্কের, 01 দিয়ে শুরু”, বা “parse_phone-এর ফলকে আবার parse_phone-এ দিলে একই ফল”. নিয়ম ভাঙলে hypothesis সবচেয়ে ছোট যে ইনপুটে ভাঙে, সেটি খুঁজে দেখায় (একে বলে shrinking). মানুষ যে অদ্ভুত কেস ভাবতে ভুলে যায় — খালি string, বিশাল সংখ্যা, অদ্ভুত Unicode — তা সে নিজেই খুঁজে আনে।
CI — প্রতিটি পরিবর্তনে টেস্ট নিজে থেকে চলে
টেস্ট শুধু তখনই কাজের, যখন চালানো হয়। continuous integration (CI) মানে প্রতিবার কেউ কোড push করলে বা pull request খুললে একটি সার্ভার নিজে থেকে পুরো test suite চালায়। GitHub-এ এর জন্য আছে GitHub Actions: প্রজেক্টের .github/workflows/ ফোল্ডারে একটি YAML ফাইল, যার মূল কথা মোটামুটি এমন:
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.14" }
- run: pip install -e . pytest
- run: pytest
pytest-এর exit code 1 হলে পুরো কাজটি লাল হয়, আর অনেক দল নিয়ম রাখে — লাল থাকলে কোড মূল শাখায় মেশানো যাবে না। এতে “আমার কম্পিউটারে তো চলছিল” সমস্যাও কমে, কারণ টেস্ট চলে একটি পরিষ্কার, নতুন পরিবেশে।
C-তে টেস্টিং ভাষার অংশ নয় — assert.h-এর assert আর নিজের হাতে লেখা ছোট runner, বা Unity/CMocka-র মতো লাইব্রেরি (C-তে টেস্টিং)। C++-এ GoogleTest আর Catch2 প্রচলিত; সেখানে EXPECT_EQ(a, b)-এর মতো macro লাগে, কারণ ভাষা নিজে থেকে দুই পাশের মান ছাপতে পারে না (C++-এ টেস্টিং ও ডিবাগিং)। Python-এ pytest import-এর সময় কোড বদলে সাধারণ assert-কেই এই ক্ষমতা দেয়। আরেকটি পার্থক্য: C/C++-এ mock করতে প্রায়ই interface বা link-time কৌশল লাগে; Python-এ dynamic হওয়ায় চলার সময় যেকোনো নাম বদলে দেওয়া যায় — সুবিধাও, অপব্যবহারের ঝুঁকিও।
- Unit test আর integration test-এর পার্থক্য? — unit test একটি অংশ আলাদা করে দ্রুত যাচাই করে; integration test কয়েকটি অংশ (যেমন কোড আর ডেটাবেস) একসাথে কাজ করছে কি না দেখে।
- pytest কীভাবে টেস্ট খুঁজে পায়? — নামের নিয়মে:
test_*.pyফাইল,test_দিয়ে শুরু ফাংশন,Testদিয়ে শুরু class. - Fixture কী? — টেস্টের জন্য প্রস্তুতি (আর
yield-এর পরে পরিষ্কার) করা একটি ফাংশন, যা টেস্ট parameter-এর নাম দিয়ে চায়। - Mock আর fake-এর পার্থক্য? — fake হলো সরল কিন্তু কাজ করা বদলি (যেমন list-এ বার্তা জমা); mock ডাকগুলো রেকর্ড করে, পরে “কীভাবে ডাকা হলো” যাচাই করা যায়।
- ১০০% coverage মানে কি বাগ নেই? — না; coverage শুধু বলে কোন লাইন চলেছে, ফল ঠিক কি না নয়। সীমার মান না থাকলে বাগ থেকে যায়।
১০ · AI-এর যুগে — AI-এর লেখা টেস্ট যাচাই করা
“এই ফাংশনের জন্য টেস্ট লিখে দাও” — AI কয়েক সেকেন্ডে বিশটি টেস্ট দেয়, সবগুলো সবুজ। দেখে মনে হয় কাজ শেষ। কিন্তু সবুজ টেস্ট মানেই ভালো টেস্ট নয়। একটি টেস্টের মূল্য হলো বাগ থাকলে সে লাল হবে কি না — আর AI-এর লেখা টেস্টে ঠিক এই গুণটি প্রায়ই অনুপস্থিত। বারবার দেখা যায় এমন সমস্যাগুলো:
| AI প্রায়ই যা করে | সমস্যা | কী চাইবেন |
|---|---|---|
| শুধু happy path — সব ইনপুট সুন্দর, বৈধ | খালি, সীমা, অবৈধ, বাংলা — যেখানে বাগ থাকে, সেখানে কোনো টেস্ট নেই | অংশ ৯-এর চেকলিস্টের প্রতিটি সারির জন্য অন্তত একটি কেস |
| কোড এখন যা ফেরত দেয়, সেটিকেই প্রত্যাশা হিসেবে লেখে | কোডে বাগ থাকলে টেস্ট বাগটিকেই “সঠিক” বলে পাহারা দেয় — grade(80) == "A" | প্রত্যাশা আসবে স্পেসিফিকেশন থেকে, কোড চালিয়ে নয়; আপনি নিজে মিলিয়ে নিন |
| সব কিছু mock করে | টেস্ট শুধু mock-কেই যাচাই করে; আসল কোডের একটি লাইনও অর্থপূর্ণভাবে পরীক্ষা হয় না | শুধু সীমানায় (নেটওয়ার্ক, ঘড়ি) fake/mock; নিজের হিসাবের কোড আসল চালান |
| ভেতরের খুঁটিনাটি যাচাই — private method, ডাকের সংখ্যা | প্রতিটি refactoring-এ টেস্ট ভাঙে, অথচ আচরণ একই | বাইরের আচরণ যাচাই: কী ফেরত এল, কী বদলাল, কী error এল |
assert result is not None বা assert len(x) > 0 | দুর্বল assert — প্রায় যেকোনো ভুল ফলেও পাস করে | সুনির্দিষ্ট মান মেলানো: assert result == "01712345678" |
| একটি টেস্টে দশটি আলাদা জিনিস | ব্যর্থ হলে বোঝা যায় না কোনটির দোষ | এক আচরণ = এক টেস্ট; একই ধাঁচের অনেক কেস হলে parametrize |
অন্যদিকে AI একটি কাজে সত্যিই চমৎকার: edge case ভাবতে সাহায্য করা. “এই ফাংশন কোন কোন ইনপুটে ভাঙতে পারে?” — এই প্রশ্নে AI প্রায়ই এমন কেস তোলে, যা আপনার মাথায় আসেনি। কেসের তালিকা AI-এর কাছ থেকে নিন, প্রত্যাশিত ফল নিজে ঠিক করুন।
AI-কে টেস্ট লিখতে বলার সময় স্পেসিফিকেশন দিন, আর কোড দেখে প্রত্যাশা বানাতে নিষেধ করুন। নিচের কাঠামোটি কপি করে নিজের ফাংশন বসান:
AI-এর লেখা টেস্ট পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলো আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Python-এর unittest দেখতে Java-র মতো কেন — setUp, assertEqual, camelCase নাম? Smalltalk-এর SUnit থেকে JUnit হয়ে PyUnit পর্যন্ত এই পরিবারের গল্পটি কী?
গল্পের শুরু Smalltalk ভাষায়, ১৯৯০-এর দশকের শুরুতে। Kent Beck তখন Smalltalk প্রজেক্টে পরামর্শক হিসেবে কাজ করতেন, আর দেখতেন প্রোগ্রামাররা টেস্ট করেন হাতে — চালিয়ে, চোখে দেখে। তিনি একটি ছোট কাঠামো লেখেন, যা পরে SUnit নামে পরিচিত হয়, আর “Simple Smalltalk Testing: With Patterns” নামের একটি লেখায় এর ধারণা ব্যাখ্যা করেন। মূল ভাবনাগুলো আজও প্রায় অপরিবর্তিত: প্রতিটি টেস্ট একটি method, কয়েকটি টেস্ট মিলে একটি “test case” class, প্রস্তুতি setUp-এ আর পরিষ্কার tearDown-এ (একে বলা হতো fixture), আর ফল জমা হয় একটি result object-এ।
১৯৯৭ সালে Kent Beck আর Erich Gamma (Design Patterns বইয়ের “Gang of Four”-এর একজন) একটি বিমানযাত্রায় — প্রচলিত গল্প অনুযায়ী OOPSLA সম্মেলনে যাওয়ার পথে — একই নকশা Java-তে লেখেন: JUnit. Java তখন দ্রুত জনপ্রিয় হচ্ছিল, আর JUnit-এর সাথে ছড়িয়ে পড়ে একটি পুরো সংস্কৃতি — Extreme Programming, “টেস্ট লিখে কোড লেখা”, আর assertEquals-এর মতো নাম। এরপর প্রায় প্রতিটি ভাষায় একই ছাঁচের কাঠামো আসে — NUnit, CppUnit, PHPUnit… সবগুলোকে একসাথে বলা হয় “xUnit পরিবার”.
Python-এ এই ছাঁচ আনেন Steve Purcell, PyUnit নামে। এটি Python 2.1 (২০০১)-এ স্ট্যান্ডার্ড লাইব্রেরিতে ঢোকে unittest নামে। Purcell সচেতনভাবে JUnit-এর নকশা আর নাম রেখেছিলেন, যাতে Java থেকে আসা প্রোগ্রামাররা সঙ্গে সঙ্গে চিনতে পারেন। এজন্যই আজও Python-এর snake_case রীতির (PEP 8) মধ্যে setUp, assertEqual, assertRaises-এর মতো camelCase নাম দাঁড়িয়ে আছে — পরে বদলালে লাখো পুরোনো টেস্ট ভাঙত।
এই নকশার সুবিধা ছিল বাস্তব: একটি পরিষ্কার কাঠামো, যেকোনো ভাষায় একই ধারণা, আর স্ট্যান্ডার্ড লাইব্রেরিতে থাকায় কিছু ইনস্টল করতে হয় না। কিন্তু Python-এর মতো ভাষায় এর কিছু অংশ ভারী লাগে: প্রতিটি টেস্টের জন্য একটি class বানাতে হয়, self লিখতে হয়, আর প্রতিটি তুলনার জন্য আলাদা method মনে রাখতে হয় (assertIn, assertIsNone, assertAlmostEqual…), কারণ সাধারণ assert ব্যর্থ হলে দুই পাশের মান দেখায় না। Java-তে প্রতিটি কাজের জন্য class লাগে, তাই সেখানে এটি স্বাভাবিক; Python-এ সাধারণ ফাংশনই যথেষ্ট হতে পারত। পরের প্রশ্নে দেখবেন, pytest ঠিক এই ফাঁকটি ধরেছিল।
তবু unittest বাতিল হয়নি। এটি এখনো Python-এর নিজের পরীক্ষার ভিত্তি, আর এর unittest.mock (Python 3.3 থেকে, Michael Foord-এর mock লাইব্রেরি থেকে আসা) pytest ব্যবহারকারীরাও নিয়মিত ব্যবহার করেন।
মূল উপলব্ধি: unittest-এর চেহারা Python-এর নয়, একটি ভাষা-পরিবারের — SUnit থেকে JUnit হয়ে আসা xUnit ঐতিহ্যের। এর নামগুলো ইতিহাসের ছাপ; আর এর ভারী অংশগুলোই পরের প্রজন্মের টুলের জন্য জায়গা তৈরি করেছিল।
প্র ০২pytest স্ট্যান্ডার্ড লাইব্রেরিতে না থেকেও কীভাবে Python-এর প্রধান টেস্ট-টুল হয়ে উঠল? সাধারণ assert বেছে নেওয়ার সিদ্ধান্তটি কেন এত গুরুত্বপূর্ণ ছিল?
pytest-এর শুরু Holger Krekel-এর হাতে, ২০০৪ সালের দিকে, py নামের একটি বড় সহায়ক লাইব্রেরির অংশ হিসেবে — তখন নাম ছিল py.test. লক্ষ্য ছিল unittest-এর আনুষ্ঠানিকতা ঝেড়ে ফেলা: class লাগবে না, self লাগবে না, বিশেষ assert-method লাগবে না। একটি ফাংশন লিখুন যার নাম test_ দিয়ে শুরু, ভেতরে সাধারণ Python-এর assert, ব্যাস। পরে, ২০১০ সালের দিকে, এটি py থেকে আলাদা হয়ে নিজের pytest প্যাকেজ হয়, আর এখন একটি স্বেচ্ছাসেবী দল এটি রক্ষণাবেক্ষণ করে।
কিন্তু সাধারণ assert-এর একটি বড় দুর্বলতা আছে: ব্যর্থ হলে শুধু AssertionError বলে, কোন মান ছিল বলে না। assertEqual(a, b) এই সমস্যা সমাধান করে, কারণ সে দুটি মানই হাতে পায়। pytest-এর প্রথম দিকের সমাধান ছিল ব্যর্থতার পরে expression-টি আবার হিসাব করে মানগুলো বের করা (“reinterpretation”), যা কখনো কখনো ভুল ফল দিত — বিশেষ করে যখন expression-টির কোনো side effect থাকত। পরে, ২০১১ সালের দিকে, আসে assertion rewriting: টেস্ট module import হওয়ার সময় pytest তার syntax tree (AST) বদলে দেয়, যাতে প্রতিটি অংশের মান আগেই ধরে রাখা হয়। ফলে assert user.balance == expected - fee ব্যর্থ হলে pytest দেখাতে পারে user.balance, expected, fee — প্রতিটির মান আলাদাভাবে।
কেন এই সিদ্ধান্ত জিতল? প্রথমত, শেখার খরচ শূন্য — যিনি Python জানেন, তিনি assert জানেন; নতুন কোনো API নেই। দ্বিতীয়ত, পড়ার সুবিধা — assert x in items একটি সাধারণ বাক্যের মতো পড়া যায়, self.assertIn(x, items)-এর চেয়ে অনেক সহজ। তৃতীয়ত, ভালো বার্তা — dict আর list-এর পার্থক্য diff আকারে দেখানো ছিল অনেক unittest বার্তার চেয়ে কাজের।
তবে শুধু assert নয়, pytest-এর আরও দুটি নকশা তাকে এগিয়ে দিয়েছে। একটি হলো fixture-এর ধারণা: setUp-এর মতো একটি বড় প্রস্তুতি-ফাংশনের বদলে ছোট ছোট, নাম দিয়ে চাওয়া, একে অপরকে ব্যবহার করতে পারা fixture — এটি dependency injection-এর একটি সুন্দর রূপ। অন্যটি হলো plugin ব্যবস্থা: pytest-cov, pytest-xdist (সমান্তরালে চালানো), pytest-django-র মতো হাজারখানেক plugin একটি পুরো ইকোসিস্টেম তৈরি করেছে। আর pytest পুরোনো unittest টেস্টও চালাতে পারে, তাই কোনো দলকে সব একবারে বদলাতে হয়নি — ধীরে ধীরে সরে আসা গেছে।
লক্ষ করার মতো ব্যাপার, pytest কখনো স্ট্যান্ডার্ড লাইব্রেরিতে ঢোকেনি, তবু কার্যত মান হয়ে গেছে। এর একটি কারণ উল্টো দিক থেকে: স্ট্যান্ডার্ড লাইব্রেরির বাইরে থাকায় এটি Python-এর ধীর প্রকাশচক্রের সাথে বাঁধা নয়, দ্রুত বদলাতে পারে।
মূল উপলব্ধি: pytest-এর সাফল্যের মূলে একটি নকশা-দর্শন — ব্যবহারকারীকে নতুন কিছু শিখতে বাধ্য না করে টুলটি নিজে কঠিন কাজটি করবে। সাধারণ assert রেখে তাকে বুদ্ধিমান বানানো ছিল সেই দর্শনের সবচেয়ে স্পষ্ট প্রয়োগ।
প্র ০৩Edsger Dijkstra বলেছিলেন, টেস্টিং বাগের উপস্থিতি দেখাতে পারে, অনুপস্থিতি কখনো নয়। কথাটির মানে কী, আর এরপরও আমরা টেস্ট লিখি কেন?
ডাচ কম্পিউটার বিজ্ঞানী Edsger W. Dijkstra — যাঁর নামে shortest path অ্যালগরিদম, আর যিনি structured programming-এর অন্যতম প্রবক্তা — ১৯৬৯-৭০ সালের দিকে কথাটি লেখেন, পরে তা “Notes on Structured Programming”-এ প্রকাশিত হয়। মূল ভাব: প্রোগ্রাম টেস্ট করে বাগের উপস্থিতি দেখানো যায়, কিন্তু অনুপস্থিতি দেখানো যায় না কখনো।
যুক্তিটি সহজ হিসাবেই বোঝা যায়। দুটি ৩২-বিটের পূর্ণসংখ্যা নেয় এমন একটি ফাংশনের সম্ভাব্য ইনপুট-জোড়া প্রায় ১.৮ × ১০১৯টি। প্রতি সেকেন্ডে একশো কোটি টেস্ট চালালেও সব শেষ করতে কয়েকশো বছর লাগবে। আর একটি string নেয় এমন ফাংশনের ইনপুট তো কার্যত অসীম। তাই টেস্ট সবসময় একটি নমুনা। এই পর্বের গ্রেড ফাংশনে ছয়টি কেস পাস করা মানে ওই ছয়টিতে ঠিক — ১০১টি সম্ভাব্য বৈধ নম্বরের বাকি ৯৫টিতে কী হয়, তা টেস্ট নিজে বলে না।
Dijkstra-র সমাধান ছিল ভিন্ন: প্রোগ্রাম লেখার সময়েই গাণিতিক যুক্তি দিয়ে প্রমাণ করা যে সেটি সঠিক — যাকে আজ formal verification বলা হয়। এই পথ বাস্তবে আছে, বিশেষ করে যেখানে ভুলের দাম অনেক: বিমানের নিয়ন্ত্রণ, চিপ ডিজাইন, cryptography লাইব্রেরি। কিন্তু সাধারণ সফটওয়্যারে প্রতিটি ফাংশনের প্রমাণ লেখা অত্যন্ত ব্যয়বহুল, আর প্রমাণ নিজেও ঠিক ততটাই ভালো, যতটা ভালো তার স্পেসিফিকেশন।
তাহলে টেস্ট কেন? কারণ বাস্তবে টেস্ট সুযোগের একটি বুদ্ধিমান বণ্টন। বাগ সমানভাবে ছড়িয়ে থাকে না — তারা ভিড় করে সীমায়, খালি ইনপুটে, শাখার মোড়ে। তাই ৭৯ আর ৮০ বাছাই করা ছয়টি কেস এলোমেলো একশোটির চেয়ে বেশি বাগ ধরে। একে বলে equivalence partitioning আর boundary value analysis: ইনপুট-জগৎকে এমন ভাগে ভাগ করা, যার ভেতরে প্রোগ্রাম একই রকম আচরণ করার কথা, তারপর প্রতিটি ভাগ থেকে আর প্রতিটি সীমা থেকে প্রতিনিধি নেওয়া। এ ছাড়া টেস্টের আসল কাজ প্রায়ই প্রমাণ নয়, পাহারা: গতকাল যা ঠিক ছিল, আজকের পরিবর্তনে তা ভাঙল কি না।
Dijkstra-র কথা থেকে কয়েকটি বাস্তব শিক্ষা: সবুজ test suite দেখে অতিরিক্ত আত্মবিশ্বাসী হবেন না; coverage ১০০% হলেও না। type checker (পর্ব ৩৭) আর property-based testing-এর মতো কৌশল নমুনাকে বড় করে — প্রথমটি একটি ধরনের সব ভুল একসাথে বাদ দেয়, দ্বিতীয়টি হাজারো ইনপুট নিজে বানায়। আর সবচেয়ে বড় কথা, সরল কোড লিখুন — যত সরল, তত সহজে যুক্তি দিয়ে বোঝা যায়, টেস্ট তত কম লাগে।
মূল উপলব্ধি: টেস্ট একটি নমুনা, প্রমাণ নয়। এর শক্তি আসে বুদ্ধিমান নমুনা বাছাই থেকে — সীমা, কিনারা, পুরোনো বাগ — আর স্বয়ংক্রিয় পাহারা থেকে। Dijkstra-র সতর্কবাণী আমাদের বিনয়ী রাখে, টেস্ট লেখা থেকে বিরত রাখে না।
প্র ০৪টেস্ট-সেট নিজে কতটা ভালো, তা মাপবেন কীভাবে? Mutation testing কী, আর coverage-এর চেয়ে এটি বেশি কী বলে?
Coverage বলে কোন লাইন চলেছে। কিন্তু অংশ ৯-এ দেখেছেন, একটি টেস্ট সব লাইন চালিয়ে কোনো assert না করলেও ১০০% coverage পায়। তাহলে টেস্টগুলো সত্যিই বাগ ধরতে পারে কি না, জানার উপায় কী? একটি সোজা ভাবনা: কোডে ইচ্ছে করে বাগ ঢুকিয়ে দেখুন, টেস্ট ধরে কি না। এটাই mutation testing.
ধারণাটি পুরোনো। Richard Lipton ১৯৭১ সালের দিকে একটি ছাত্র-প্রবন্ধে এটি প্রস্তাব করেন বলে ধরা হয়, আর ১৯৭৮ সালে DeMillo, Lipton ও Sayward-এর “Hints on Test Data Selection” প্রবন্ধ এটিকে পরিচিত করে। পদ্ধতি: একটি টুল আপনার কোডের অনেকগুলো সামান্য বদলানো কপি বানায় — প্রতিটিকে বলে mutant. যেমন >=-কে >, +-কে -, True-কে False, একটি সংখ্যায় ১ যোগ, একটি return-এর মান বদল। তারপর প্রতিটি mutant-এর ওপর পুরো test suite চালায়। কোনো টেস্ট লাল হলে mutant টি “killed” — ভালো খবর। সব টেস্ট সবুজ থাকলে mutant “survived” — মানে এমন একটি বাগ আছে, যা আপনার টেস্ট ধরতে পারে না।
এই পর্বের গ্রেড ফাংশনেই ভাবুন। টেস্টে শুধু ৯০, ৬৫, ২০ থাকলে mutation টুল marks >= 80-কে marks > 80 বানাবে, সব টেস্ট পাস করবে, mutant বেঁচে যাবে — আর রিপোর্ট আপনাকে বলবে, “লাইন ৫-এর সীমা কেউ পাহারা দিচ্ছে না”. ৮০-র টেস্ট যোগ করলে সেটি মারা যায়। অর্থাৎ mutation testing সরাসরি সেই প্রশ্নের উত্তর দেয়, যা coverage দিতে পারে না: আমার টেস্ট কি এই ধরনের ভুল ধরবে? এর ফলকে বলা হয় mutation score — মোট mutant-এর কত শতাংশ মারা গেল।
Python-এ mutmut আর cosmic-ray-এর মতো টুল আছে। কিন্তু খরচও বড়: একশোটি mutant মানে test suite একশোবার চালানো, তাই বড় প্রজেক্টে ঘণ্টা লাগতে পারে। আরেকটি সমস্যা “equivalent mutant” — কিছু বদল আচরণই বদলায় না (যেমন এমন একটি লুপের সীমা, যা কখনো সেখানে পৌঁছায় না), সেগুলো কোনো টেস্টেই মরবে না, আর মানুষকে বসে বাছাই করতে হয়। তাই অনেক দল এটি পুরো কোডে না চালিয়ে শুধু সবচেয়ে গুরুত্বপূর্ণ module-এ (পেমেন্ট, হিসাব) চালায়, বা শুধু বদলানো লাইনগুলোতে।
আপনি mutation টুল ব্যবহার না করলেও এর ভাবনাটি হাতে-কলমে প্রয়োগ করতে পারেন — অংশ ১০-এর চেকলিস্টের দ্বিতীয় লাইন ঠিক এটিই: AI বা নিজের লেখা টেস্ট পেলে কোডে একটি ছোট বাগ ঢুকিয়ে দেখুন, কোনো টেস্ট লাল হয় কি না। এটাই TDD-র “আগে লাল দেখুন” নিয়মের পেছনের যুক্তি।
মূল উপলব্ধি: টেস্টের মূল্য হলো বাগ ধরার ক্ষমতা। Coverage মাপে কোড কতটা ছোঁয়া হলো; mutation testing মাপে কতটা পাহারা দেওয়া হলো। “টেস্টকে কে টেস্ট করবে?” — এর উত্তর: ইচ্ছাকৃত বাগ।
প্র ০৫কোনো কোড না বদলালেও যে টেস্ট কখনো পাস, কখনো ব্যর্থ — সেই flaky test কোথা থেকে আসে, আর এটি এত ক্ষতিকর কেন?
সকালে CI লাল, দুপুরে আবার চালালেন — সবুজ। কোডে কিছুই বদলায়নি। এমন টেস্টকে বলে flaky test. বড় কোম্পানিগুলো এ নিয়ে প্রকাশ্যে লিখেছে; Google-এর টেস্টিং দলের ২০১৬ সালের একটি ব্লগ-লেখায় জানানো হয়েছিল, তাদের বিপুল সংখ্যক টেস্টের একটি উল্লেখযোগ্য অংশে কখনো না কখনো flaky আচরণ দেখা গেছে, আর এটি তাদের নিয়মিত মাথাব্যথা।
Flakiness-এর সাধারণ কারণগুলো চিনে রাখুন:
- সময় আর তারিখ: “আজ থেকে ৩০ দিন পরে মেয়াদ শেষ” টেস্টটি মাসের শেষ দিনে, বা লিপ ইয়ারে, বা অন্য time zone-এর CI সার্ভারে ব্যর্থ হয়। সমাধান: ঘড়ি parameter হিসেবে নিন (dependency injection), বা নির্দিষ্ট তারিখ দিন।
- এলোমেলো সংখ্যা:
randomseed ছাড়া — হাজারে একবার এমন মান আসে, যাতে বাগ দেখা দেয়। সমাধান: নির্দিষ্ট seed (পর্ব ২৭), বা এলোমেলোতা বাইরে থেকে দেওয়া। - ক্রমের ওপর নির্ভরতা: একটি টেস্ট একটি global অবস্থা বদলায়, পরের টেস্ট তার ওপর নির্ভর করে। ক্রম বদলালেই (বা সমান্তরালে চালালে) ব্যর্থ। সমাধান: প্রতিটি টেস্ট নিজের অবস্থা নিজে বানাবে — fixture.
- অপেক্ষা আর race condition:
time.sleep(1)দিয়ে ধরে নেওয়া “এর মধ্যে কাজ শেষ হবে” — ধীর CI সার্ভারে হয় না। thread আর asyncio-তে (পর্ব ৪১-৪২) এটি আরও সাধারণ। - বাইরের জগৎ: আসল নেটওয়ার্ক, আসল API, আসল ফাইল-পথ। নেটওয়ার্ক একটু ধীর হলেই timeout. সমাধান: সীমানায় fake.
- ক্রমহীন সংগ্রহ: set-এর ক্রম বা ফোল্ডারের ফাইল-তালিকার ক্রমকে নির্দিষ্ট ধরে নেওয়া — এক কম্পিউটারে মেলে, আরেকটিতে মেলে না।
ক্ষতিটি শুধু সময় নষ্ট নয়, তার চেয়ে গভীর — এটি আস্থা ধ্বংস করে। একটি টেস্ট যদি মাঝে মাঝে অকারণে লাল হয়, দল শেখে “লাল হলে আবার চালাও”. তারপর একদিন সত্যিকারের বাগে টেস্ট লাল হয়, আর কেউ গুরুত্ব দেয় না — রাখাল বালক আর বাঘের গল্প। পুরো test suite-এর মূল্যই নির্ভর করে এই বিশ্বাসের ওপর যে লাল মানে সত্যিই কিছু ভেঙেছে।
তাই পেশাদার দলগুলো flaky test-কে বাগ হিসেবে দেখে: খুঁজে বের করে আলাদা করে (quarantine), কারণ খোঁজে, ঠিক করে, বা মুছে ফেলে। “আবার চালালে পাস করে” দিয়ে ঢেকে রাখা সবচেয়ে খারাপ সমাধান, কারণ flakiness অনেক সময় আসল কোডের একটি race condition-এর লক্ষণ — যা একদিন গ্রাহকের সামনেও দেখা দেবে।
মূল উপলব্ধি: একটি টেস্টের সবচেয়ে মূল্যবান গুণ নির্ভরযোগ্যতা। সময়, এলোমেলোতা, ক্রম আর নেটওয়ার্ক নিয়ন্ত্রণে আনুন — এই পর্বের fixture, monkeypatch, fake আর dependency injection মূলত এই কাজেরই হাতিয়ার।
অনুশীলন
বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — অনুমান করা, পড়া আর মিলিয়ে নেওয়াই আসল কাজ।
-
আউটপুট পড়ুন: pytest চালিয়ে এই লাইন দেখলেন —
tests/test_cart.py ..F.E., আর শেষে1 failed, 4 passed, 1 error. কয়টি টেস্ট চলেছে?FআরE-এর পার্থক্য কী, আর কোনটি আগে দেখবেন?ছয়টি টেস্ট (ছয়টি অক্ষর)। তৃতীয়টি
F— টেস্টের ভেতরে একটিassertমিথ্যা হয়েছে, অর্থাৎ কোড চলেছে কিন্তু ফল প্রত্যাশামতো নয়। পঞ্চমটিE— টেস্ট আসল যাচাই পর্যন্ত পৌঁছানোর আগেই অপ্রত্যাশিত exception, সাধারণত fixture-এর setup-এ (যেমন ফাইল নেই,KeyError), বা fixture-এর teardown-এ। টেস্ট ফাংশনের নিজের কোডে টাইপো থাকলে কিন্তু সেটিFহিসেবেই দেখায়।Eআগে দেখা ভালো, কারণ এটি প্রায়ই প্রস্তুতির সমস্যা, যা একাধিক টেস্টকে প্রভাবিত করতে পারে; আরFএকটি আসল আচরণগত অমিল, যার জন্য নিচেরEলাইনের diff পড়ুন।pytest -x -vদিলে প্রথম সমস্যায় থামবে আর প্রতিটি টেস্টের নাম দেখাবে। -
Edge case তালিকা: একটি ফাংশন
average_marks(marks)নম্বরের একটি list নিয়ে গড় দেয়। কোড না দেখে, অংশ ৯-এর চেকলিস্ট ধরে অন্তত ছয়টি টেস্ট-কেস লিখুন — প্রতিটির ইনপুট আর প্রত্যাশিত ফল (বা প্রত্যাশিত error).একটি সম্ভাব্য তালিকা: (১) খালি
[]→ValueError(নইলেZeroDivisionError— কোনটি চান, সিদ্ধান্ত নিয়ে লিখে রাখুন); (২) এক উপাদান[75]→ 75; (৩) সাধারণ[70, 80, 90]→ 80; (৪) দশমিক ফল[70, 71]→pytest.approx(70.5); (৫) সীমা[0, 100]→ 50; (৬) অবৈধ মান[50, -5]বা[50, 101]→ValueError; (৭) ভুল টাইপ["80", 90]→TypeErrorবাValueError; (৮) অনেক উপাদান — দশ হাজারটি 60 → 60. লক্ষ করুন, কয়েকটি কেসে প্রত্যাশা নিজেই একটি নকশা-সিদ্ধান্ত। টেস্ট লিখতে গিয়ে এই প্রশ্নগুলো ওঠে বলেই টেস্ট স্পেসিফিকেশনকে পরিষ্কার করে। -
AI-এর টেস্ট বিচার করুন: AI
apply_discount(price, percent)-এর জন্য তিনটি টেস্ট দিয়েছে —assert apply_discount(1000, 10) is not None,assert apply_discount(500, 20) == apply_discount(500, 20), আরassert apply_discount(200, 50) == 100. কোনটি মূল্যবান, কোনটি নয়? কী যোগ করবেন?প্রথমটি প্রায় অকেজো — ফাংশন
0,-999বা"hello"ফেরত দিলেও পাস করে; শুধুNoneধরে। দ্বিতীয়টি সম্পূর্ণ অকেজো — একটি মানকে নিজের সাথে তুলনা; ফাংশন যা-ই দিক, সবসময় সত্য (একমাত্র ব্যতিক্রম ফাংশন এলোমেলো ফল দিলে)। তৃতীয়টিই আসল টেস্ট — নির্দিষ্ট ইনপুট, স্পেসিফিকেশন থেকে আসা নির্দিষ্ট প্রত্যাশা। যোগ করুন: সীমা —percent0 (দাম অপরিবর্তিত) আর 100 (শূন্য); অবৈধ — −10 আর 110-এValueError; দশমিক —apply_discount(99.99, 15)-এpytest.approx; আর ঋণাত্মক দাম। সবগুলো একটি parametrize-এ রাখা যায়। তারপর কোডে/ 100-কে/ 10বানিয়ে দেখুন — অন্তত একটি টেস্ট লাল হওয়া উচিত। -
একটি parametrize লিখুন:
add_vat(price)১৫% VAT যোগ করে, আর ঋণাত্মক দামেValueErrorদেয়। pytest-ধাঁচে টেস্ট লিখুন: বৈধ কেসের একটি parametrize (অন্তত চারটি কেস, একটি দশমিক সহ), আর ঋণাত্মক দামের জন্য একটিpytest.raisesটেস্ট।একটি সম্ভাব্য সমাধান:
import pytest from pricing import add_vat @pytest.mark.parametrize("price, want", [ (0, 0), (100, 115), (1, 1.15), (19.99, 22.9885), (1_000_000, 1_150_000), ]) def test_add_vat(price, want): assert add_vat(price) == pytest.approx(want) @pytest.mark.parametrize("price", [-1, -0.01]) def test_negative_price_raises(price): with pytest.raises(ValueError): add_vat(price)শূন্য হলো নিচের প্রান্ত, এক টাকা সবচেয়ে ছোট পূর্ণ দাম, ১৯.৯৯ দশমিকের কেস, দশ লাখ বড় সংখ্যার কেস। সবগুলোতে
approxব্যবহার করা নিরাপদ, কারণ1 * 1.15-এর মতো হিসাবেও float-এর ক্ষুদ্র ত্রুটি আসতে পারে। ঋণাত্মক দিকে −০.০১ সীমার একদম কাছের অবৈধ মান —< 0-এর জায়গায়< -1লেখার মতো বাগ এটিই ধরবে। চাইলেmatch="negative"যোগ করে error-বার্তাও যাচাই করুন।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমটার্মিনালে pytest লেখার পর থেকে একটি fixture-ওয়ালা টেস্টের ফল দেখা পর্যন্ত ঘটনাগুলো ক্রমে সাজান (▲▼ চেপে সরান)
- pytest test_*.py নামের ফাইলগুলো খোঁজে
- ফাইলের test_ ফাংশনগুলো সংগ্রহ করে
- টেস্টের parameter দেখে fixture-এর yield পর্যন্ত চালায়
- টেস্ট ফাংশন চলে আর assert যাচাই হয়
- fixture-এর yield-এর পরের পরিষ্কারের অংশ চলে
- ডট বা F ছাপে, শেষে সারসংক্ষেপ দেখায়
pytest-এ একটি সাধারণ assert a == b ব্যর্থ হলে দুই পাশের আসল মান দেখা যায় কেন?
- Python 3-এর সব assert ব্যর্থ হলে নিজে থেকেই মান দেখায়
- pytest টেস্ট ফাইল import করার সময় assert-গুলো বদলে নেয় (assertion rewriting)
- pytest ব্যর্থ টেস্টটি debugger-এ আবার চালিয়ে মান বের করে
- শুধু তখনই দেখায়, যখন assert-এর পরে কমা দিয়ে বার্তা লেখা থাকে
একটি গ্রেড ফাংশনে ৮০ বা তার বেশি নম্বর হলে A+. নিচের কোন জোড়া ইনপুট >=-এর জায়গায় > লেখার বাগটি সবচেয়ে নিশ্চিতভাবে ধরবে?
- ৯০ আর ৫০
- ১০০ আর ৮৫
- ৭৯ আর ০
- ৮০ আর ৭৯
pytest-এর একটি fixture-এ yield-এর পরে লেখা কোড কখন চলে?
- টেস্ট শেষ হওয়ার পরে, টেস্ট ব্যর্থ হলেও
- টেস্ট শুরুর আগে, প্রস্তুতি হিসেবে
- শুধু টেস্ট ব্যর্থ হলে
- সব টেস্ট শেষ হলে একবারই, scope যা-ই হোক
Python-এ assert 0.1 + 0.2 == 0.3 ব্যর্থ হয়। pytest-এ এই ধরনের float তুলনার সঠিক উপায় কোনটি?
assert str(0.1 + 0.2) == "0.3"assert int(0.1 + 0.2) == int(0.3)assert 0.1 + 0.2 == pytest.approx(0.3)assert 0.1 + 0.2 is 0.3
rates.py ফাইলে from net import http_get লেখা আছে, আর rates.fetch_rate() সেটি ডাকে। টেস্টে unittest.mock.patch দিয়ে http_get বদলাতে কোন লক্ষ্য দেবেন?
"net.http_get""rates.http_get""builtins.http_get""rates.fetch_rate"
একটি প্রজেক্টের test coverage ১০০%. এ থেকে নিশ্চিতভাবে কী বলা যায়?
- কোডে কোনো বাগ নেই
- প্রতিটি সীমার মান টেস্ট করা হয়েছে
- প্রতিটি if-এর সত্য আর মিথ্যা দুই দিকই চলেছে
- টেস্ট চলার সময় কোডের প্রতিটি লাইন অন্তত একবার চলেছে
TDD-র “লাল → সবুজ → গোছানো” চক্রে নতুন টেস্ট লেখার পরে সেটি একবার ব্যর্থ হতে দেখা জরুরি কেন?
- pytest প্রথমবার ব্যর্থ না হলে টেস্টটি নথিভুক্ত করে না
- ব্যর্থ টেস্ট পরের বার দ্রুত চলে
- প্রমাণ হয় যে টেস্টটি সত্যিই কিছু যাচাই করে — ভুল কোডে সে লাল হয়
- ব্যর্থতা ছাড়া coverage হিসাব করা যায় না
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- টেস্ট = কোডের একটি প্রতিশ্রুতির স্বয়ংক্রিয় পাহারা; regression ধরে, নির্ভয়ে refactor করতে দেয়, আচরণের দলিল রাখে। প্রমাণ নয়, বুদ্ধিমান নমুনা।
- প্রতিটি টেস্ট Arrange → Act → Assert; দ্রুত, স্বাধীন, পুনরাবৃত্তিযোগ্য, আর বাইরের আচরণ যাচাই করে।
- pytest:
test_*.py-তেtest_ফাংশন, সাধারণassert(assertion rewriting মান দেখায়);-v,-x,-k;.পাস,Fটেস্ট ব্যর্থ,Efixture-এর setup/teardown-এ error. pytest.raises,approx,parametrize(সীমার দুই পাশ!), fixture (yield-এ teardown,tmp_path, scope,conftest.py),capsys,monkeypatch.- fake আর dependency injection আগে,
Mock/patchশুধু সীমানায় (patch করুন যেখানে নাম খোঁজা হয়)। Coverage কম হলে সমস্যা, বেশি হলে প্রমাণ নয়। AI-এর টেস্টে একটি বাগ ঢুকিয়ে দেখুন — লাল হয় কি না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪০ · ডিবাগিং আর loggingপরবর্তী পর্বtraceback পড়া, debugger দিয়ে ধাপে ধাপে চলা, আর logging দিয়ে সিস্টেমের খবর রাখা।
- Python প্রোগ্রামিং · কোর্স হোম৫৫টি পর্বপুরো কোর্সের রোডম্যাপ — ৮টি মডিউল, কোন পর্ব কখন।
- C++ · টেস্টিং ও ডিবাগিংঅন্য ভাষায় একই ধারণাGoogleTest-ধাঁচের টেস্ট, sanitizer আর debugger — একই নীতি, compile করা ভাষায়।