pytest দিয়ে টেস্ট লেখা
assert থেকে শুরু করে pytest, ব্যর্থতার রিপোর্ট পড়া, pytest.raises, parametrize, tmp_path আর fixture — আর কোন আকারের কোড পরীক্ষা করা সহজ।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এতদিন আমরা কোড «পরীক্ষা» করেছি একভাবেই: চালিয়ে দেখেছি, আর আউটপুটটা পড়ে ঠিক মনে হয়েছে কিনা ভেবেছি।
সেটা কাজ করে, একবার। সমস্যাটা শুরু হয় দ্বিতীয়বার।
পঞ্চবিংশ অধ্যায়ের প্রোগ্রামটায় TAX_RATE ছিল 0.15। ধরুন সেটা বদলে 0.18 করা হলো। প্রোগ্রামটা চলবে, সব সংখ্যা ছাপা হবে, কোথাও কোনো ত্রুটি আসবে না — আর আপনি টেরও পাবেন না কোনো কিছু বদলেছে কিনা, যদি না আগের আউটপুটটা আপনার মনে থাকে।
আর যা মনে রাখতে হয়, তা একসময় মনে থাকে না।
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert একটা সহজ বাক্য: «এটা সত্য হওয়ার কথা»। সত্য হলে কিছুই ঘটে না; মিথ্যা হলে প্রোগ্রাম থেমে যায়।
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorএটাই ভিত্তি। এই অধ্যায়টা সেই ভিত্তির উপরে একটা টুল — pytest — যে এরকম শত শত দাবি চালায়, আর ব্যর্থ হলে কেন ব্যর্থ হলো তা বলে।
এই অধ্যায় শেষে আপনি পারবেন
- একটা টেস্ট ফাইল লিখতে আর
pytestচালাতে - একটা ব্যর্থ টেস্টের রিপোর্ট পড়ে সমস্যাটা খুঁজে বের করতে
pytest.raisesদিয়ে ত্রুটি পরীক্ষা করতে@pytest.mark.parametrizeদিয়ে এক টেস্টে অনেক ক্ষেত্র চালাতেtmp_pathদিয়ে ফাইল-নির্ভর কোড পরীক্ষা করতে- বলতে কোন কোডটা পরীক্ষা করা সহজ আর কেন
পূর্বশর্ত: ডেটাক্লাস আর টাইপ হিন্ট।
প্রথম টেস্ট
দুটো নিয়ম, আর ব্যস: ফাইলের নাম শুরু হবে test_ দিয়ে, আর ফাংশনের নামও।
pricing.py:
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)test_pricing.py:
from pricing import line_total
def test_one_item():
assert line_total(15.0, 1) == 17.25
def test_three_items():
assert line_total(15.0, 3) == 51.75তারপর pytest -q:
.. [100%]
2 passed in 0.01sদুটো বিন্দু, দুটো টেস্ট। pytest নিজেই ফাইলগুলো খুঁজে নেয়, ফাংশনগুলো খুঁজে নেয়, আর চালায় — কিছু নিবন্ধন করতে হয় না।
ব্যর্থতার রিপোর্টটাই আসল জিনিস
একটা টেস্টে ইচ্ছে করে ভুল সংখ্যা বসালে:
F [100%]
================================== FAILURES ===================================
______________________________ test_three_items _______________________________
def test_three_items():
> assert line_total(15.0, 3) == 51.0
E assert 51.75 == 51.0
E + where 51.75 = line_total(15.0, 3)
test_pricing.py:5: AssertionError
=========================== short test summary info ===========================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed in 0.01sসাধারণ assert দিয়ে আমরা পেয়েছিলাম কেবল AssertionError — একটা শব্দ, কোনো তথ্য ছাড়া। এখানে পাওয়া যাচ্ছে:
- কোন টেস্টটা (
test_three_items) আর কোন লাইনে (test_pricing.py:5) >চিহ্ন দিয়ে ঠিক কোন দাবিটা ভেঙেছেassert 51.75 == 51.0— আসলে কী পাওয়া গেছে, আর কী আশা করা হয়েছিলwhere 51.75 = line_total(15.0, 3)— সংখ্যাটা কোথা থেকে এলো
এটাকে বলে assertion introspection, আর এটাই pytest-এর মূল কারণ। অন্য অনেক ভাষায় এর জন্য assertEqual(a, b) ধরনের আলাদা মেথড শিখতে হয়; এখানে সাধারণ == লিখলেই চলে।
ত্রুটিও পরীক্ষা করতে হয়
চতুর্বিংশ অধ্যায়ে আমরা শিখেছি একটা ফাংশনের ভুল ইনপুটে raise করা উচিত। সেই আচরণটাও একটা প্রতিশ্রুতি, আর তাই পরীক্ষাযোগ্য:
import pytest
from pricing import line_total
def test_zero_is_rejected():
with pytest.raises(ValueError):
line_total(15.0, 0)
def test_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3).. [100%]
2 passed in 0.01spytest.raises ব্লকটা বলছে «এর ভিতরে এই ত্রুটিটা হওয়ার কথা»। না হলে টেস্ট ব্যর্থ:
F [100%]
================================== FAILURES ===================================
____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE <class 'ValueError'>
test_pricing.py:7: Failed
=========================== short test summary info ===========================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE <class '...
1 failed in 0.01smatch= অংশটা বার্তার ভিতরে ওই লেখাটা আছে কিনা দেখে। এটা লেখা মূল্যবান, কারণ চতুর্বিংশ অধ্যায়ের নিয়মটা — যে মানটা সমস্যা করেছে সেটা বার্তায় রাখুন — তখন নিজেই একটা পরীক্ষিত প্রতিশ্রুতি হয়ে যায়।
একই টেস্ট, অনেক ক্ষেত্র
চারটে ক্ষেত্রের জন্য চারটে ফাংশন লেখা একঘেয়ে, আর কপি-পেস্টের ভুল ডেকে আনে।
import pytest
from pricing import line_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(100.0, 2, 230.0),
],
)
def test_line_total(price, quantity, expected):
assert line_total(price, quantity) == expectedpytest -v দিলে:
============================= test session starts =============================
collecting ... collected 4 items
test_pricing.py::test_line_total[15.0-1-17.25] PASSED [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED [100%]চারটে আলাদা টেস্ট, একটা ফাংশন থেকে। আর প্রতিটার নামে তার মানগুলো বসে আছে, তাই কোনটা ব্যর্থ হলে সাথে সাথেই বোঝা যায় — লুপ দিয়ে লিখলে প্রথম ব্যর্থতাতেই বাকিগুলো থেমে যেত আর কোন মানটা দোষী তাও বলা যেত না।
একটা ভুল থাকলে রিপোর্টটা মানগুলোও দেখায়:
________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0ফাইল লাগলে — tmp_path
তেইশতম অধ্যায়ের কোড পরীক্ষা করতে একটা ফাইল লাগে। সেটা রেপোজিটরিতে রেখে দেওয়া খারাপ অভ্যাস: টেস্টগুলো তখন একে অপরের সাথে জড়িয়ে যায়, আর একটা টেস্ট ফাইলটা বদলালে অন্যটা ভাঙে।
pytest প্রতিটা টেস্টকে তার নিজের একটা খালি ফোল্ডার দিতে পারে:
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one\n\ntwo\n", encoding="utf-8")
assert read_names(path) == ["one", "two"]
def test_empty_file_gives_empty_list(tmp_path):
path = tmp_path / "names.txt"
path.write_text("", encoding="utf-8")
assert read_names(path) == [].. [100%]
2 passed in 0.01stmp_path একটা pathlib.Path — তেইশতম অধ্যায়ের সেই Path, আর / দিয়ে পথ জোড়াও সেখানকারই। প্যারামিটারের নামটাই pytest-কে বলে দেয় কী দিতে হবে, আর প্রতিটা টেস্ট একটা নতুন ফোল্ডার পায় — তাই দুটো টেস্টে একই নামের ফাইল থাকলেও তারা আলাদা।
পুনরাবৃত্ত সাজানো — fixture
একই ইনপুট কয়েকটা টেস্টে লাগলে সেটা একবার লিখে নাম দেওয়া যায়:
import pytest
from pricing import order_total
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1)]
def test_total(order):
assert order_total(order) == 1029.25
def test_one_line_removed(order):
assert order_total(order[:1]) == 51.75.. [100%]
2 passed in 0.01stmp_path-এর মতোই: প্যারামিটারের নাম দেখে pytest মিলিয়ে দেয়। আর ফাংশনটা প্রতিটা টেস্টের জন্য আবার চলে, তাই একটা টেস্ট লিস্টটা বদলালে পরেরটা তবু একটা তাজা লিস্ট পায় — একবিংশ অধ্যায়ের ভাগ করা অবস্থার সমস্যাটা এখানে নকশা দিয়েই এড়ানো।
ভাসমান সংখ্যার ফাঁদ
def test_addition():
assert 0.1 + 0.2 == 0.3F [100%]
================================== FAILURES ===================================
________________________________ test_addition ________________________________
def test_addition():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_money.py:2: AssertionErrorপঞ্চম অধ্যায়ের সেই পুরোনো ব্যাপারটা, আর টেস্টে সেটা প্রায়ই প্রথমবার কামড়ায়।
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.01spytest.approx বলে «প্রায় সমান হলেই চলবে»। ভাসমান সংখ্যা নিয়ে যেকোনো টেস্টে এটাই ব্যবহার করুন — যদি না আপনি round() দিয়ে সংখ্যাটা আগেই বেঁধে ফেলেন, যেমন line_total করে।
কী পরীক্ষা করবেন
একটা নিয়ম বাকি সব নিয়মের চেয়ে বেশি কাজে আসে: যে ফাংশন মান নেয় আর মান ফেরত দেয়, সে পরীক্ষা করা সহজ।
একবিংশ অধ্যায়ের পিওর ফাংশনগুলোর কথা মনে আছে? এই অধ্যায়টাই তাদের পুরস্কার। line_total পরীক্ষা করতে কোনো ফাইল লাগেনি, কোনো ইনপুট লাগেনি, কিছু সাজাতে হয়নি — কেবল ডেকে ফলটা দেখা।
উল্টোদিকে, যে ফাংশন ছাপে, ইনপুট চায়, বা ফাইল লেখে — তাকে পরীক্ষা করতে ব্যবস্থা করতে হয়। সেজন্যই তেইশতম অধ্যায়ের প্রোগ্রামটায় report কোনো ফাইল নেয়নি: সে একটা লিস্ট নিয়েছিল আর একটা লিস্ট ফেরত দিয়েছিল।
টেস্ট লেখা কঠিন হওয়া মানে সাধারণত টেস্টের দোষ নয় — কোডের আকারের দোষ।
কী পরীক্ষা করবেন, তিন ধরনের ক্ষেত্র: যা সাধারণত ঘটে, প্রান্তের ক্ষেত্রগুলো (শূন্য, খালি, এক), আর যা ব্যর্থ হওয়ার কথা।
একটা সম্পূর্ণ উদাহরণ
pricing.py:
"""Prices and tax. Pure functions, which is what makes them testable."""
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)
def order_total(lines: list[tuple[str, float, int]]) -> float:
return round(sum(line_total(price, qty) for _, price, qty in lines), 2)test_pricing.py:
"""Tests for pricing. Each one names the behaviour it protects."""
import pytest
from pricing import line_total, order_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(0.01, 1, 0.01),
],
)
def test_line_total_applies_tax(price, quantity, expected):
assert line_total(price, quantity) == expected
@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
with pytest.raises(ValueError, match="at least 1"):
line_total(15.0, quantity)
def test_error_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3)
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]
def test_order_total_sums_the_lines(order):
assert order_total(order) == 1305.25
def test_empty_order_totals_zero():
assert order_total([]) == 0
def test_one_bad_line_stops_the_order(order):
with pytest.raises(ValueError):
order_total(order + [("clip", 5.0, 0)])........... [100%]
11 passed in 0.01sচারটে জিনিস দেখার মতো।
এগারোটা টেস্ট, সাতটা ফাংশন। parametrize পার্থক্যটা বানিয়ে দিয়েছে, আর প্রতিটা ক্ষেত্র আলাদাভাবে গোনা হচ্ছে।
নামগুলো আচরণ বলে, কোড নয়। test_quantity_below_one_is_rejected পড়ে জানা যায় প্রোগ্রামটা কী প্রতিশ্রুতি দেয়; test_line_total_2 পড়ে কিছুই জানা যায় না। ব্যর্থতার রিপোর্টে এই নামটাই প্রথমে দেখা যাবে, তাই সে একটা বাক্য হওয়াই ভালো।
0.01 ক্ষেত্রটা দৈব নয়। সবচেয়ে ছোট সম্ভাব্য দাম — একটা প্রান্তের ক্ষেত্র, আর round()-এর আচরণ ঠিক ওখানেই সবচেয়ে সন্দেহজনক। প্রান্তগুলোতেই বাগ থাকে, মাঝখানে নয়।
শেষ টেস্টটা একটা পরোক্ষ প্রতিশ্রুতি ধরে রাখছে। order_total নিজে কোনো যাচাই করে না — সে line_total-এর উপর নির্ভর করে। কেউ যদি একদিন order_total-এ একটা try/except বসিয়ে খারাপ লাইনগুলো নীরবে বাদ দিতে চায়, এই টেস্টটা ব্যর্থ হয়ে জিজ্ঞেস করবে — «তুমি কি সত্যিই এটা চাও?»
কিছু ভাঙা অবস্থা ও তার সমাধান
no tests ran ফাইলের নাম test_ দিয়ে শুরু হয়নি, নয়তো ফাংশনের নাম। দুটোই লাগে।
ModuleNotFoundError: No module named 'pricing' যে ফোল্ডারে ফাইলগুলো আছে সেখান থেকেই pytest চালান।
টেস্ট পাস করে, কিন্তু কিছুই পরীক্ষা করে না assert লিখতে ভুলে গেছেন। একটা ফাংশন কেবল ডাকলে সে ত্রুটি না দিলেই পাস।
fixture 'order' not found @pytest.fixture লিখতে ভুলে গেছেন, নয়তো নামটা প্যারামিটারের নামের সাথে মিলছে না।
ভাসমান সংখ্যার তুলনা ব্যর্থ হচ্ছে, যদিও সংখ্যাগুলো ঠিক pytest.approx নিন, নয়তো ফাংশনটাতেই round() করুন।
DID NOT RAISE pytest.raises ব্লকের ভিতরের কোডটা ত্রুটি দেয়নি। হয় কোডটা ভুল, নয়তো টেস্টের প্রত্যাশাটা।
একটা টেস্ট একা চালালে পাস করে, সবার সাথে চালালে ফেল টেস্টগুলো কিছু একটা ভাগ করছে — প্রায়ই একটা মডিউল-স্তরের লিস্ট বা ডিকশনারি। দ্বাবিংশ আর ষড়বিংশ অধ্যায়ের সেই ভাগ করা অবস্থা। fixture ব্যবহার করুন।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
pytest -q চালালে রিপোর্টের শেষ লাইনটা কী হবে?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A1 failed, 1 passed in 0.01s
- B2 failed in 0.01s
- C2 passed in 0.01s
- D1 failed in 0.01s
add(2, 2) তো 5 নয়। pytest -q কী বলবে?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- A1 passed — টেস্টটা পাস করে
- B1 failed — যেমন হওয়ার কথা
- Cno tests ran
- Dএকটি `AssertionError`
প্রথম টেস্টটা ঝুড়িতে একটা জিনিস রেখেছে। দ্বিতীয়টা কী দেখবে?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- A2 passed — দ্বিতীয় টেস্ট একটা তাজা খালি লিস্ট পায়
- B1 failed, 1 passed — দ্বিতীয়টা একটা জিনিস দেখে
- C2 failed
- Dfixture 'basket' not found
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
সপ্তবিংশ অধ্যায়ের library.py নিন — Book আর Shelf — আর তার পাশে test_library.py লিখুন।
অন্তত এগুলো থাকবে:
parametrizeদিয়েtotal_pages()-এর কয়েকটা ক্ষেত্র, খালি তাকসহpytest.raisesদিয়েpages=0প্রত্যাখ্যাত হয় কিনা,match=সহ- খালি তাকে
longest()যেNoneদেয় by_authorযে ঠিক লেখকের বইগুলোই দেয়, আর না থাকলে খালি লিস্টtmp_pathব্যবহার করে একটা টেস্ট, যেখানে তাকটা JSON-এ লিখে আবার পড়া হয়
তারপর পাঁচটা পরীক্ষা:
- একটা টেস্ট থেকে
assertসরিয়ে দিন, কেবল ফাংশনটা ডাকুন। টেস্টটা পাস করে? - ফাইলের নাম বদলে
library_test.pyকরুন আরpytestচালান। কী হয়? Book-এরpages < 1যাচাইটা সরিয়ে দিন। কোন টেস্টগুলো ব্যর্থ হয়, আর রিপোর্টটা কি বলে দেয় কেন?total_pages()-এ ইচ্ছে করে একটা ভুল ঢোকান — যেমনsum(...) + 1। ব্যর্থতার রিপোর্টে আসল আর প্রত্যাশিত সংখ্যা দুটোই দেখা যায়?- একটা টেস্ট লিখুন যা
0.1 + 0.2 == 0.3দাবি করে। তারপরpytest.approxদিয়ে ঠিক করুন।
তৃতীয় পরীক্ষাটাই সবচেয়ে বেশি শেখায়। একটা ভালো টেস্ট স্যুট ভাঙলে সে আপনাকে বলে কী প্রতিশ্রুতি ভাঙা হলো — আর সেটাই একটা প্রোগ্রামকে মাস ছয়েক পরেও বদলানোর যোগ্য রাখে।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন