pytest দিয়ে টেস্ট লেখা
assert থেকে শুরু করে pytest, ফেলের রিপোর্ট পড়া, pytest.raises, parametrize, tmp_path আর fixture — আর কোন ধরনের কোড টেস্ট করা সহজ।
কীভাবে শিখতে চান?
যে সমস্যাটা আমরা সমাধান করছি
এতদিন আমরা কোড “টেস্ট” করেছি একভাবেই: চালিয়ে দেখেছি, আর আউটপুট পড়ে ঠিক মনে হচ্ছে কিনা ভেবেছি।
সেটা কাজ করে, একবার। সমস্যাটা শুরু হয় দ্বিতীয়বার।
২৫ নম্বর অধ্যায়ের প্রোগ্রামটায় 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 একটা সহজ বাক্য: “এটা সত্য হওয়ার কথা”। সত্য হলে কিছুই ঘটে না; মিথ্যা হলে প্রোগ্রাম AssertionError দিয়ে থেমে যায়।
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorএটাই ভিত্তি। এই অধ্যায়টা সেই ভিত্তির উপরে বানানো একটা টুল নিয়ে — pytest — যে এরকম শত শত assert চালায়, আর কোনোটা ফেল করলে কেন ফেল করল তা বলে।
এই অধ্যায় শেষে আপনি পারবেন
- একটা টেস্ট ফাইল লিখতে আর
pytestচালাতে - একটা ফেল করা টেস্টের রিপোর্ট পড়ে সমস্যাটা খুঁজে বের করতে
pytest.raisesদিয়ে এরর টেস্ট করতে@pytest.mark.parametrizeদিয়ে এক টেস্টে অনেকগুলো কেস চালাতেtmp_pathদিয়ে ফাইল-নির্ভর কোড টেস্ট করতে- বলতে কোন কোড টেস্ট করা সহজ আর কেন
আগে যা জানা লাগবে: ডেটাক্লাস আর টাইপ হিন্ট।
প্রথম টেস্ট
দুটো নিয়ম, ব্যস: ফাইলের নাম শুরু হবে test_ দিয়ে, আর টেস্ট ফাংশনের নামও। (pytest _test.py দিয়ে শেষ হওয়া ফাইলও খোঁজে, তবে এই কোর্সে আমরা 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.00sদুটো ডট, দুটো পাস করা টেস্ট। pytest নিজেই ফাইলগুলো খুঁজে নেয়, ফাংশনগুলো খুঁজে নেয়, আর চালায় — কোথাও আলাদা করে রেজিস্টার করতে হয় না। শেষের সময়টা (0.00s) আপনার মেশিনে একটু আলাদা হতে পারে।
ফেলের রিপোর্টটাই আসল জিনিস
test_pricing.py-এর দ্বিতীয় টেস্টে ইচ্ছা করে ভুল সংখ্যা বসাই — 51.75-এর জায়গায় 51.0 — আর আবার pytest -q চালাই:
.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:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01sপ্রথম লাইনের .F মানে প্রথম টেস্ট পাস করেছে, দ্বিতীয়টা ফেল। সাধারণ assert দিয়ে আমরা পেয়েছিলাম কেবল AssertionError — একটা শব্দ, কোনো তথ্য ছাড়া। এখানে পাওয়া যাচ্ছে:
- কোন টেস্টটা (
test_three_items) আর কোন লাইনে (test_pricing.py:9) >চিহ্ন দিয়ে ঠিক কোনassert-টা ভেঙেছেassert 51.75 == 51.0— আসলে কী পাওয়া গেছে, আর কী আশা করা হয়েছিল- শেষ লাইনে মোট হিসাব:
1 failed, 1 passed 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 ব্লকটা বলছে “এর ভিতরে এই এররটা হওয়ার কথা”। না হলে টেস্ট ফেল করে। যেমন, test_one_is_rejected নামে একটা টেস্ট যদি line_total(15.0, 1)-এর জন্য ValueError আশা করে — অথচ পরিমাণ 1 তো বৈধ:
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
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%]
============================== 4 passed in 0.01s ===============================চারটা আলাদা টেস্ট, একটা ফাংশন থেকে। প্রতিটার নামে তার ভ্যালুগুলো বসানো, তাই কোনটা ফেল করলে সাথে সাথেই বোঝা যায়। লুপ দিয়ে লিখলে প্রথম ফেলেই বাকিগুলো থেমে যেত, আর কোন ভ্যালুটা দোষী তাও বলা যেত না।
দ্বিতীয় কেসে ইচ্ছা করে 51.75-এর জায়গায় 51.0 লিখে pytest -q চালালে রিপোর্টের শুরুটা এরকম — ভ্যালুগুলোও দেখা যায়:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0ফাইল লাগলে — tmp_path
২৩ নম্বর অধ্যায়ের মতো ফাইল পড়ার কোড টেস্ট করতে একটা ফাইল লাগে। সেটা রিপোজিটরিতে রেখে দেওয়া খারাপ অভ্যাস: টেস্টগুলো তখন একে অপরের সাথে জড়িয়ে যায়, আর একটা টেস্ট ফাইলটা বদলালে অন্যটা ভাঙে।
ধরুন reader.py-তে এই ফাংশনটা আছে — একটা টেক্সট ফাইল থেকে ফাঁকা নয় এমন লাইনগুলো ফেরত দেয়:
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]pytest প্রতিটা টেস্টকে তার নিজের একটা খালি ফোল্ডার দিতে পারে। test_reader.py:
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
একই ইনপুট কয়েকটা টেস্টে লাগলে সেটা একবার লিখে একটা নাম দেওয়া যায়। এখানে order_total হলো pricing.py-র আরেকটা ফাংশন, যেটা পুরো অর্ডারের মোট হিসাব করে — পুরো ফাইলটা নিচের সম্পূর্ণ উদাহরণে আছে:
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
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01s৫ নম্বর অধ্যায়ের সেই পুরোনো ব্যাপারটা, আর টেস্টে এসেই এটা প্রায়ই প্রথমবার সমস্যা করে।
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx বলে “প্রায় সমান হলেই চলবে”। ফ্লোট নিয়ে যেকোনো টেস্টে এটাই ব্যবহার করুন — যদি না round() দিয়ে সংখ্যাটা আগেই নির্দিষ্ট করে ফেলেন, যেমন line_total করে।
কী টেস্ট করবেন
একটা নিয়ম বাকি সব নিয়মের চেয়ে বেশি কাজে আসে: যে ফাংশন ভ্যালু নেয় আর ভ্যালু ফেরত দেয়, তাকে টেস্ট করা সহজ।
২১ নম্বর অধ্যায়ের pure ফাংশনগুলোর কথা মনে আছে? এই অধ্যায়ে তাদের সুবিধাটা দেখা যায়। 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_ দিয়ে শুরু হয়নি (বা _test.py দিয়ে শেষ হয়নি), নয়তো ফাংশনের নাম 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)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed 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- Aএকটা `AssertionError`
- B1 failed — যেমন হওয়ার কথা
- Cno tests ran
- D1 passed — টেস্টটা পাস করে
প্রথম টেস্টটা ঝুড়িতে একটা জিনিস রেখেছে। দ্বিতীয়টা কী দেখবে?
# 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- Afixture 'basket' not found
- B2 failed
- C2 passed — দ্বিতীয় টেস্ট একটা নতুন খালি লিস্ট পায়
- D1 failed, 1 passed — দ্বিতীয়টা একটা জিনিস দেখে
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
২৭ নম্বর অধ্যায়ের library.py নিন — Book আর Shelf — আর তার পাশে test_library.py লিখুন।
অন্তত এগুলো থাকবে:
parametrizeদিয়েtotal_pages()-এর কয়েকটা কেস, খালি তাকসহpytest.raisesদিয়েpages=0বাতিল হয় কিনা,match=সহ- খালি তাকে
longest()যেNoneদেয় by_authorযে ঠিক লেখকের বইগুলোই দেয়, আর না থাকলে খালি লিস্টtmp_pathব্যবহার করে একটা টেস্ট, যেখানে তাকটা JSON-এ লিখে আবার পড়া হয়
তারপর পাঁচটা পরীক্ষা:
- একটা টেস্ট থেকে
assertসরিয়ে দিন, কেবল ফাংশনটা ডাকুন। টেস্টটা পাস করে? - টেস্ট ফাইলের নাম বদলে
check_library.pyকরুন আরpytestচালান। কী হয়? (library_test.pyনাম দিলে কিন্তু খুঁজে পায় — কেন?) Book-এরpages < 1যাচাইটা সরিয়ে দিন। কোন টেস্টগুলো ফেল করে, আর রিপোর্টটা কি বলে দেয় কেন?total_pages()-এ ইচ্ছা করে একটা ভুল ঢোকান — যেমনsum(...) + 1। ফেলের রিপোর্টে আসল আর প্রত্যাশিত সংখ্যা দুটোই দেখা যায়?- একটা টেস্ট লিখুন যা
0.1 + 0.2 == 0.3দাবি করে। তারপরpytest.approxদিয়ে ঠিক করুন।
তৃতীয় পরীক্ষাটাই সবচেয়ে বেশি শেখায়। একটা ভালো টেস্ট স্যুট ভাঙলে সে আপনাকে বলে কোন প্রতিশ্রুতি ভাঙা হলো — আর সেটাই একটা প্রোগ্রামকে ছয় মাস পরেও নিশ্চিন্তে বদলানোর মতো রাখে।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন