টেস্ট খুঁজে পাওয়া আর চালানো — pytest কোনটি চালায়, আর কীভাবে বাছবেন
pytest কোন ফাইল, ফাংশন আর ক্লাসকে টেস্ট ধরে, src/ আর tests/ বিন্যাসে কোড import করা, node ID, আর -k, -x, --lf, --ff, --co দিয়ে ঠিক যা দরকার তা চালানো।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এতদিন প্রতিটি টেস্ট একটিই ফাইলে ছিল, যে কোড টেস্ট করছে তার ঠিক পাশে, আর শুধু pytest লিখলেই কাজ হয়ে যেত। আসল প্রজেক্ট এত ছোট থাকে না। কোড চলে যায় একটি প্যাকেজে, টেস্টগুলো যায় নিজেদের আলাদা ফোল্ডারে, আর ঠিক পরের রানটি দেখতে হয় এরকম:
$ pytest -q
==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_cart.py ______________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_cart.py:1: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sএকটি টেস্টও চলেনি। কোডে কোনো ভুল নেই; pytest কেবল সেটিকে খুঁজে পায়নি।
এটি ঠিক করার পর আসে দ্বিতীয় সমস্যা: এখন কয়েক ডজন টেস্ট, তার একটি ব্যর্থ হচ্ছে, আর প্রতিবার আবার চালালে সবগুলোই আবার চলে। আপনি চান কেবল ওই একটি চালাতে — অথবা কেবল সেগুলো, যেগুলো গতবার ব্যর্থ হয়েছিল।
দুটি সমস্যার উৎস একই জায়গায়। কিছু চালানোর আগে pytest সংগ্রহ (collect) করে: ফোল্ডারগুলো ঘুরে দেখে, ফাইল import করে, আর টেস্টের একটি তালিকা বানায়। এই অধ্যায় সেই তালিকা কীভাবে তৈরি হয়, আর তা থেকে কীভাবে বেছে নিতে হয়, তা নিয়ে।
এই অধ্যায় শেষে আপনি পারবেন
- কোনটি টেস্ট, তা ঠিক করতে
pytestকোন নিয়মগুলো মানে, তা বলতে src/আরtests/দিয়ে একটি প্রজেক্ট সাজাতে, আর টেস্ট যেন কোড import করতে পারে তার ব্যবস্থা করতে- টেস্টগুলোকে ক্লাসে দলবদ্ধ করতে, আর
tests/test_cart.py::TestCart::test_total_adds_tax-এর মতো একটি node ID পড়তে - একটি ফাইল, একটি ক্লাস, একটি টেস্ট, বা
-kদিয়ে বেছে নেওয়া কয়েকটি টেস্ট চালাতে - রোজকার কাজে
-x,--maxfail,--lf,--ff,--coআর--durationsব্যবহার করতে pytest-এর exit code পড়ে তার অর্থ বলতে
আগে যা জানা লাগবে: assert আর ব্যর্থতার রিপোর্ট।
টেস্ট লেখার আগে
এই অধ্যায়ে টেস্ট কোডের প্রথম লাইনের আগের প্রশ্নটি কেবল কী টেস্ট করব তা নয়, বরং টেস্টটি কোথায় থাকবে আর `pytest` তাকে কীভাবে খুঁজে পাবে। যে টেস্ট কখনো সংগ্রহই হয় না, সেটি টেস্ট না থাকার চেয়েও খারাপ: দেখতে সুরক্ষার মতো, কিন্তু আসলে কোনো সুরক্ষা নয়।
১. প্রতিশ্রুতিটি জানুন। আমরা যে কোড টেস্ট করব, সেটি একটি ছোট দোকানের প্যাকেজ:
apply_discount(price, percent)দামটিpercentপরিমাণ কমিয়ে, দুই দশমিক ঘর পর্যন্ত রাউন্ড করে ফেরত দেয়। 0–100-এর বাইরের percent দিলেValueErrorওঠে।add_tax(price, rate=0.15)দামের সাথে কর যোগ করে, দুই দশমিক ঘর পর্যন্ত রাউন্ড করে ফেরত দেয়।Cart(name, price, quantity)আইটেম জমা রাখে;subtotal()পরিমাণ দিয়ে গুণ করে;total()হলো কর-সহ subtotal।
২. প্রস্তুতি সেরে রাখুন। pytest ইনস্টল করা একটি virtual environment; src/shop/-এ সাজানো কোড; আর — এই অধ্যায় যে অংশ নিয়ে — টেস্ট যেন import shop করতে পারে তার একটি উপায়। শেষেরটি না থাকলে বাকি কিছুই কাজে আসে না।
৩. কেসগুলোর পরিকল্পনা করুন, আর ঠিক করুন কোনটি কোথায় থাকবে। প্রতিটি মডিউলের জন্য একটি টেস্ট ফাইল (tests/test_pricing.py হলো src/shop/pricing.py-এর প্রতিচ্ছবি), আর কার্টের টেস্টগুলো একটি ক্লাসে। পরিকল্পনায় node ID লিখে রাখলে এই সিদ্ধান্তটা আগেভাগেই নিতে হয়:
| কেস | ইনপুট | প্রত্যাশিত | Node ID | |---|---|---|---| | সাধারণ ছাড় | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | সীমা: কোনো ছাড় নেই | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | ডিফল্ট কর | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | প্রান্তিক: খালি কার্ট | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | পরিমাণ হিসাবে ধরা হয় | 15.0 দামের ৩টি কলম | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | মোটে কর যুক্ত | 100.0 দামের ১টি ব্যাগ | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | অবৈধ percent | apply_discount(200.0, 150) | ValueError | পরে — exception টেস্ট করার আলাদা অধ্যায় আছে |
৪. কী টেস্ট করবেন না, তা ঠিক করুন। round() বা list.append টেস্ট করবেন না — পাইথনের নিজের আচরণ আপনার প্রতিশ্রুতি নয়। Cart.items যে tuple-এর একটি তালিকা, তা টেস্ট করবেন না; ওটা ভেতরের খুঁটিনাটি, কালই বদলাতে পারেন। যা ব্যবহারকারী দেখে তা টেস্ট করুন: subtotal() আর total()।
এবার অধ্যায়টি ঠিক এই পরিকল্পনাটাই তৈরি করবে, আর পথের প্রতিটি সমস্যাই হবে খুঁজে পাওয়ার (discovery) সমস্যা।
খুঁজে পাওয়ার নিয়ম
pytest যা পায় তা-ই চালায় না। সে চারটি নিয়ম মানে, আর তার বাইরের সবকিছু একটি কথাও না বলে উপেক্ষা করে:
- ফাইল, যার নাম
test_*.pyবা*_test.py - সেই ফাইলের ফাংশন, যার নাম
testদিয়ে শুরু - ক্লাস, যার নাম
Testদিয়ে শুরু — আর যার কোনো__init__মেথড নেই - এমন ক্লাসের ভেতরের মেথড, যার নাম
testদিয়ে শুরু
"সব ফাংশন চালাও" না বলে নিয়ম কেন? কারণ আপনার প্রজেক্ট এমন ফাংশনে ভরা যেগুলো টেস্ট নয় — সহায়ক ফাংশন, টেস্ট করা কোডটি নিজেই — আর আপনাকে দিয়ে কিছু নিবন্ধন না করিয়েই pytest-এর এগুলো আলাদা করার একটি উপায় দরকার।
নিয়মগুলো বিশ্বাস করার দ্রুততম উপায় হলো ইচ্ছে করে সেগুলো ভাঙা। test_rules.py:
def test_found():
assert True
def check_not_found():
assert False
class TestGroup:
def test_method_found(self):
assert True
def helper(self):
assert False
class CartTests:
def test_ignored(self):
assert False
class TestWithInit:
def __init__(self):
self.value = 1
def test_never_runs(self):
assert self.value == 1এর পাশে rules_test.py-তে আছে একটি পাস করা test_suffix_style, আর helpers.py-তে আছে একটি test_in_helpers, যা assert False করে। এবার pytest -v:
collecting ... collected 3 items
rules_test.py::test_suffix_style PASSED [ 33%]
test_rules.py::test_found PASSED [ 66%]
test_rules.py::TestGroup::test_method_found PASSED [100%]
=============================== warnings summary ===============================
test_rules.py:22
/home/you/disc/test_rules.py:22: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: test_rules.py)
class TestWithInit:
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.01s =========================ফোল্ডারে পাঁচটি assert False লাইন, অথচ রানটি সবুজ। কী বাদ পড়ল, আর কেন:
check_not_found— নামটিtestদিয়ে শুরু হয়নি।TestGroup.helper— টেস্ট ক্লাসের ভেতরে, কিন্তু নিজের নামtestদিয়ে শুরু নয়। সহায়ক মেথড রাখা যায়; সেগুলো কেবল টেস্ট নয়।CartTests— ক্লাসের নামTestদিয়ে শুরু হতে হবে।TestWithInit—pytest-কে নিজেই, কোনো আর্গুমেন্ট ছাড়া, প্রতিটি টেস্টের জন্য একবার করে ক্লাসটি তৈরি করতে হয়। নিজস্ব__init__থাকা ক্লাস সেটা হতে দেবে কি না ভরসা করা যায় না, তাই সেটি বাদ পড়ে — একটি সতর্কবার্তাসহ, পুরো রানে একমাত্র আওয়াজ।helpers.py— ফাইলের নাম কোনো প্যাটার্নের সাথেই মেলে না, তাই সেটি import-ও হয় না।
নীরব অংশটাই বিপজ্জনক অংশ। ভুল করেdef tset_total():লিখলে টেস্ট স্যুট চিরকাল সবুজ থাকবে। নতুন কোনো টেস্ট সন্দেহজনক রকম সহজে "পাস" করলে দেখে নিন সেটি আদৌ সংগ্রহ হয়েছে কি না — নিচের--coদিয়ে সেটাই দেখা হয়।
একটি আসল বিন্যাস: src/ আর tests/
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py
│ └── pricing.py
└── tests/
├── test_cart.py
└── test_pricing.pyকোড থাকে src/shop/-এ; টেস্ট থাকে tests/-এ, আর সেগুলো প্যাকেজের সাথে কখনো যায় না। একে বলে src layout। এর উদ্দেশ্য হলো, আপনি প্রজেক্ট ফোল্ডারে দাঁড়িয়ে আছেন বলেই কোডটি "দুর্ঘটনাবশত" import হয়ে যাবে না — তাই টেস্টগুলো সেটি import করে ঠিক যেভাবে একজন আসল ব্যবহারকারী করত।
src/shop/pricing.py:
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError(f"percent must be between 0 and 100: {percent}")
return round(price * (100 - percent) / 100, 2)
def add_tax(price, rate=0.15):
return round(price * (1 + rate), 2)src/shop/cart.py:
from shop.pricing import add_tax
class Cart:
def __init__(self):
self.items = []
def add(self, name, price, quantity=1):
self.items.append((name, price, quantity))
def subtotal(self):
return sum(price * quantity for _, price, quantity in self.items)
def total(self):
return add_tax(self.subtotal())tests/test_pricing.py — পরিকল্পনার প্রথম তিনটি সারি:
from shop.pricing import add_tax, apply_discount
def test_discount_ten_percent():
assert apply_discount(200.0, 10) == 180.0
def test_discount_zero():
assert apply_discount(200.0, 0) == 200.0
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0এই from shop.pricing import ... লাইনটিই অধ্যায়ের শুরুর ModuleNotFoundError-টি তৈরি করেছিল।
shop কেন খুঁজে পাওয়া যায় না
import shop তখনই সফল হয়, যখন sys.path-এর কোনো ফোল্ডারে একটি shop ফোল্ডার থাকে। প্যাকেজটি আছে src/-এ, আর src/ sys.path-এ নেই। pytest প্রতিটি টেস্ট ফাইলের ফোল্ডার (এখানে tests/) যোগ করে, যাতে টেস্ট তার প্রতিবেশীদের import করতে পারে — কিন্তু src/ কেউ যোগ করে না।
ভুল উপায় হলো টেস্ট ফাইলের ভেতরে sys.path জোড়াতালি দেওয়া:
import sys
sys.path.insert(0, "src")
from shop.cart import Cart
def test_empty_cart_subtotal():
assert Cart().subtotal() == 0প্রজেক্ট ফোল্ডার থেকে এটি পাস করে। তারপর কেউ এটি tests/-এর ভেতর থেকে চালায়:
$ cd tests
$ pytest -q
==================================== ERRORS ====================================
________________________ ERROR collecting test_cart.py _________________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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_cart.py:5: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" হলো আপনি যেখানেই থাকুন সেখান থেকে ধরা একটি আপেক্ষিক পথ, তাই টেস্টটি এখন নির্ভর করছে আপনি কোন ফোল্ডারে বসে কমান্ড লিখছেন তার ওপর। তার ওপর প্রতিটি টেস্ট ফাইলে একই তিনটি লাইন লাগবে। সঠিক উপায় দুটি।
সঠিক উপায় ১: pytest-কে বলে দিন কোড কোথায়। pyproject.toml-এ:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]pythonpath যেকোনো টেস্ট import হওয়ার আগেই src/-কে sys.path-এ যোগ করে — আপনি যেখানে দাঁড়িয়ে আছেন সেখান থেকে নয়, প্রজেক্টের মূল ফোল্ডার থেকে হিসাব করে। testpaths বলে দেয় শুধু pytest লিখলে কোথায় খুঁজতে হবে। এবার:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_cart.py ... [ 50%]
tests/test_pricing.py ... [100%]
============================== 6 passed in 0.01s ===============================হেডারের configfile: pyproject.toml লাইনটিই প্রমাণ যে pytest আপনার সেটিংস পড়েছে। কনফিগারেশন ফাইলের জন্য পরে আলাদা একটি অধ্যায় আছে; এখন এই দুটি লাইনই যথেষ্ট।
সঠিক উপায় ২: প্যাকেজটি ইনস্টল করুন। pyproject.toml যদি একটি আসল প্যাকেজ বর্ণনা করে (একটি [build-system] আর একটি [project] টেবিল), তাহলে একবার pip install -e . চালান। এটি প্যাকেজটিকে editable মোডে ইনস্টল করে: import shop এখন সবখানে কাজ করে — টেস্টে, REPL-এ, স্ক্রিপ্টে — আর src/-এ বদল করলে আবার ইনস্টল না করেই তা ধরা পড়ে। এরপর কোনো pythonpath ছাড়াই pytest -q একই 6 passed দেয়। প্যাকেজ আকারে থাকা বেশিরভাগ প্রজেক্ট এটাই করে; প্রজেক্টটি প্যাকেজ না হলে pythonpath দ্রুততর পথ।
টেস্টগুলোকে ক্লাসে দলবদ্ধ করা
tests/test_cart.py পরিকল্পনার কার্ট-সংক্রান্ত সারিগুলো একটি ক্লাসে রাখে:
from shop.cart import Cart
class TestCart:
def test_empty_cart_subtotal(self):
assert Cart().subtotal() == 0
def test_subtotal_counts_quantity(self):
cart = Cart()
cart.add("pen", 15.0, quantity=3)
assert cart.subtotal() == 45.0
def test_total_adds_tax(self):
cart = Cart()
cart.add("bag", 100.0)
assert cart.total() == 115.0$ pytest -q tests/test_cart.py
... [100%]
3 passed in 0.01sকোনো base class থেকে উত্তরাধিকার নিতে হয় না — TestCart একটি সাধারণ ক্লাস, আর self কেবল আবশ্যিক প্রথম প্যারামিটার। তাহলে ক্লাস কেন? কারণ ক্লাস সম্পর্কিত টেস্টগুলোকে একটি নাম দেয়, আর সেই নামটি বেছে নেওয়ার মতো একটি জিনিস হয়ে ওঠে: একটি আর্গুমেন্টে "কার্টের সব টেস্ট" চালানো যায়।
ক্লাস যে কাজের জন্য নয়, তা হলো state ভাগাভাগি। pytest প্রতিটি টেস্ট মেথডের জন্য নতুন একটি instance তৈরি করে, তাই এক টেস্টে self-এ রাখা মান পরের টেস্টে আর থাকে না। এটা ইচ্ছাকৃত — যে টেস্টগুলো একে অপরের ফেলে যাওয়া জিনিসের ওপর নির্ভর করে, সেগুলো পাস বা ফেল করে কোন ক্রমে চলল তার ওপর। ওপরের প্রতিটি টেস্ট নিজের Cart() নিজে বানায়। ভাগাভাগি করা প্রস্তুতির সঠিক হাতিয়ার fixture, পরের একটি অধ্যায়ে।
Node ID: একটি টেস্টের ঠিকানা
কিছু না চালিয়েই pytest-কে বলুন সে কী সংগ্রহ করেছে তার তালিকা দিতে — --collect-only, সংক্ষেপে --co:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
6 tests collected in 0.01sপরিকল্পনার শেষ কলামের সাথে মিলিয়ে দেখুন: প্রতিটি সারি আছে, আপনার বেছে নেওয়া ঠিকানাতেই। প্রতিটি লাইন একটি node ID — ফাইলের পথ, তারপর ::, তারপর ক্লাস থাকলে সেটি, তারপর :: আর টেস্টের নাম। ব্যর্থতার সারাংশে FAILED-এর পরে ঠিক এই লেখাটিই আসে, তাই ব্যর্থতা থেকে কপি করে সরাসরি কমান্ড লাইনে বসিয়ে দেওয়া যায়।
কী চালাবেন, তা বেছে নেওয়া
পথ বা node ID দিয়ে
একটি টেস্ট চালানোর ভুল উপায় হলো বাকিগুলো কমেন্ট করে রাখা, বা নাম বদলে দেওয়া যাতে pytest সেগুলো বাদ দেয় — আর তারপর ফিরিয়ে আনতে ভুলে যাওয়া। সঠিক উপায় হলো pytest-কে একটি ঠিকানা দেওয়া:
$ pytest -q tests/test_pricing.py
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart::test_total_adds_tax
. [100%]
1 passed in 0.01sএকটি ফোল্ডার, একটি ফাইল, একটি ক্লাস, একটি টেস্ট — node ID-র প্রতিটি স্তর বাছাইকে আরও সরু করে, আর কোডে হাতই দিতে হয় না।
নাম দিয়ে: -k
-k একটি expression নেয়, আর যে টেস্টগুলোর নাম তার সাথে মেলে সেগুলো রাখে:
$ pytest -q -k discount
.. [100%]
2 passed, 4 deselected in 0.01sমেলানোটা হয় বড়-ছোট হাতের অক্ষর না মেনে, নামের অংশ খুঁজে, আর শব্দগুলো and, or, not আর বন্ধনী দিয়ে জোড়া যায়। expression-টি উদ্ধৃতিচিহ্নে রাখুন, যাতে শেল এটিকে একটিই আর্গুমেন্ট হিসেবে পাঠায়:
$ pytest -q -k "discount or tax"
.... [100%]
4 passed, 2 deselected in 0.01s
$ pytest -q -k "TestCart and not tax"
.. [100%]
2 passed, 4 deselected in 0.01sএই কারণেই টেস্টের নাম যত্ন করে বাছা উচিত: test_discount_zero-কে পাওয়া যায় discount দিয়ে, zero দিয়ে, বা দুটো দিয়েই। test_2-এর মতো নামকে কিছু দিয়েই পাওয়া যায় না।
একটি খুঁটিনাটি সবাইকে একবার না একবার ধরে। -k টেস্টের নাম আর তার ওপরের নামগুলোর সাথেও মেলায় — তার ক্লাস আর তার ফাইল:
$ pytest --co -q -k "not cart"
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
3/6 tests collected (3 deselected) in 0.01sতিনটি TestCart টেস্টের কোনোটির নিজের নামে cart নেই, তবু তিনটিই বাদ পড়ল — কারণ সেগুলো থাকে test_cart.py-তে। কোনো -k বাছাই অবাক করলে চালানোর আগে --co যোগ করে দেখে নিন।
নামের বদলে নিজের লাগানো একটি লেবেল দিয়ে বাছাই করা হলো marker আর -m-এর কাজ, বারো নম্বর অধ্যায়ে।
যখন কিছু ব্যর্থ হয়
ইচ্ছে করে কোডটি ভাঙুন: add_tax-এর ডিফল্ট rate 0.15 থেকে 0.18 করে দিন। এখন দুটি টেস্ট ব্যর্থ। (--tb=no ব্যর্থতার রিপোর্টগুলো লুকিয়ে রাখে, যাতে রানের চেহারাটা দেখা যায়; সেই রিপোর্ট পড়া আপনি আগের অধ্যায়ে শিখেছেন।)
$ pytest -q --tb=no
..F..F [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01s-x প্রথম ব্যর্থতাতেই থেমে যায়। একটি ভাঙা ফাংশন যখন দশটি টেস্ট ফেল করায়, তখন প্রথম রিপোর্টটিই পড়ার মতো; বাকি নয়টি তার প্রতিধ্বনি:
$ pytest -q -x --tb=no
..F
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 2 passed in 0.01s--maxfail=N N-টি ব্যর্থতার পর থামে। -x হুবহু --maxfail=1।
--lf (last failed) কেবল গতবার যা ব্যর্থ হয়েছিল তা আবার চালায়। pytest ব্যর্থতাগুলো প্রজেক্টের একটি .pytest_cache ফোল্ডারে মনে রাখে:
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 2 deselected in 0.01sকেবল দুটি ব্যর্থ টেস্টই চলল। rate আগের মতো করে দিন, --lf ওই দুটি আবার চালাবে আর সেগুলো পাস করবে। কোনো কিছু ব্যর্থ না থাকা অবস্থায় আরেকবার --lf চালালে pytest ছাপে run-last-failure: no previously failed tests, not deselecting items. আর সবকিছু চালায়।
--ff (failed first) আগে ব্যর্থগুলো চালায়, তারপর বাকি সব। দ্রুত উত্তর আসে ওপরে, পুরো নিরাপত্তা যাচাই আসে পরে:
$ pytest -q --ff --tb=no
FF.... [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01sFF.... — দুটি ব্যর্থ টেস্ট আগে চলেছে, পাস করা চারটির আগে।
বাগ ঠিক করার সময় একটি ভালো চক্র: সবুজ না হওয়া পর্যন্ত pytest --lf -x, তারপর একবার শুধু pytest। শেষ ধাপটি কেন? কারণ --lf কেবল লালগুলো আবার চালায় — আপনার সমাধান আগে সবুজ থাকা কিছু ভেঙে দিয়েছে কি না, তা সে বলতে পারে না।
টেস্টের আউটপুট: ক্যাপচার আর -s
টেস্টের ভেতরের print যেন কিছুই করে না। (নিচের --tb=short কেবল ব্যর্থতার রিপোর্টকে ছোট করে।) একটি টেস্ট পর্দায় যা লেখে, pytest সব ক্যাপচার করে রাখে, আর কেবল সেই টেস্ট ব্যর্থ হলে দেখায়:
def test_quiet():
print("subtotal is", 45.0)
assert 45.0 == 45.0
def test_loud():
print("subtotal is", 40.0)
assert 40.0 == 45.0$ pytest -q --tb=short
.F [100%]
=================================== FAILURES ===================================
__________________________________ test_loud ___________________________________
test_print.py:8: in test_loud
assert 40.0 == 45.0
E assert 40.0 == 45.0
----------------------------- Captured stdout call -----------------------------
subtotal is 40.0
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sপাস করা টেস্টের আউটপুট উধাও; ব্যর্থ টেস্টের আউটপুট তার রিপোর্টে Captured stdout call-এর নিচে জোড়া আছে। শত শত টেস্ট থাকলে ঠিক এটাই চাই — সব ঠিক থাকলে নীরবতা, না থাকলে প্রমাণ। ঘটার সাথে সাথে সবকিছু দেখতে চাইলে -s ক্যাপচার বন্ধ করে দেয়:
$ pytest -q -s --tb=no
subtotal is 45.0
.subtotal is 40.0
F
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sExit code
কাজ শেষ করে pytest শেলকে একটি সংখ্যা দেয়। CI সিস্টেম কেবল ওই সংখ্যা দেখেই ঠিক করে "সবুজ" না "লাল":
| কোড | অর্থ | |---|---| | 0 | সংগ্রহ করা সব টেস্ট পাস করেছে | | 1 | টেস্ট চলেছে, আর অন্তত একটি ব্যর্থ হয়েছে | | 2 | রানটি বাধাপ্রাপ্ত — সংগ্রহের সময় ত্রুটি, বা Ctrl+C | | 3 | pytest বা কোনো plugin-এর ভেতরের ত্রুটি | | 4 | ব্যবহারের ভুল — অচেনা অপশন, বা অস্তিত্বহীন পথ বা node ID | | 5 | কোনো টেস্ট সংগ্রহ হয়নি |
5-এর দিকে নজর দেওয়া দরকার। যে -k কিছুর সাথেই মেলে না, বা যে ফোল্ডারে ঠিক নামের কোনো ফাইল নেই, সেটি পাস নয় — আর CI একে ব্যর্থতা হিসেবেই ধরে, যা একদম ঠিক:
$ pytest -q -k refund
6 deselected in 0.01s
$ echo $?
5অধ্যায়ের শুরুর ModuleNotFoundError-টি শেষ হয়েছিল Interrupted: 1 error during collection দিয়ে — exit code 2। সংগ্রহের ত্রুটি ব্যর্থ টেস্ট নয়; pytest টেস্ট চালানো পর্যন্ত পৌঁছায়ইনি।
একটা সম্পূর্ণ উদাহরণ
পরিকল্পনাটি তৈরি, আর সবুজ। এবার tests/test_cart.py-তে একটি ধীর টেস্ট যোগ হলো, নিজের আলাদা ক্লাসে:
from shop.cart import Cart
class TestBigCart:
def test_many_items(self):
cart = Cart()
for _ in range(300_000):
cart.add("pen", 1.0)
assert cart.subtotal() == 300_000.0ফাইলটিতে এই ক্লাসের ওপরে TestCart এখনো আছে। প্রথমে, কিছু চালানোর আগে, দেখুন pytest কী দেখছে:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_cart.py::TestBigCart::test_many_items
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
7 tests collected in 0.01sসাতটি টেস্ট, প্রত্যেকটির একটি ঠিকানা। সবগুলো চালান, আর সবচেয়ে ধীর তিনটি দেখতে চান:
$ pytest -q --durations=3
....... [100%]
============================= slowest 3 durations ==============================
0.04s call tests/test_cart.py::TestBigCart::test_many_items
(2 durations < 0.005s hidden. Use -vv to show these durations.)
7 passed in 0.12sতালিকায় আসার মতো ধীর কেবল একটি টেস্ট। আসল প্রজেক্টে --durations=10 দিয়েই খুঁজে পাওয়া যায় সেই গুটিকয় টেস্ট, যেগুলোর জন্য পুরো স্যুট ধীর মনে হয়। কার্ট নিয়ে কাজ করার সময় ধীরটিকে বাদ রাখুন:
$ pytest -q -k "cart and not many"
... [100%]
3 passed, 4 deselected in 0.01sতিনটি জিনিস এটি সম্ভব করেছে। pythonpath = ["src"] থাকায় from shop.cart import Cart আদৌ সফল হয়। TestBigCart সংগ্রহ হয়, কারণ তার নাম Test দিয়ে শুরু আর তার কোনো __init__ নেই। আর -k "cart and not many" ঠিক TestCart-এর টেস্টগুলোই রেখেছে: ফাইলের নামের মাধ্যমে cart মিলেছে test_cart.py-এর সবকিছুর সাথে, আর not many ধীরটিকে বাদ দিয়েছে।
কিছু ভাঙা অবস্থা ও তার সমাধান
সংগ্রহের সময় ModuleNotFoundError: No module named 'shop' টেস্ট করা কোড sys.path-এ নেই। src/ বিন্যাসে [tool.pytest.ini_options]-এর নিচে pythonpath = ["src"] যোগ করুন, অথবা pip install -e . দিয়ে প্যাকেজটি ইনস্টল করুন। রানটি শেষ হয় Interrupted: 1 error during collection আর exit code 2 দিয়ে।
python -m pytest-এ চলে, কিন্তু pytest-এ চলে না python -m pytest বর্তমান ফোল্ডারটিও sys.path-এ রাখে; শুধু pytest রাখে না। তাই প্রজেক্টের মূল ফোল্ডারে থাকা একটি মডিউল এক উপায়ে import হয়, অন্য উপায়ে হয় না। এই পার্থক্যের ওপর ভরসা করবেন না — pythonpath দিন, বা প্যাকেজটি ইনস্টল করুন।
PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor টেস্ট ক্লাসে __init__ থাকা চলবে না। সেটি সরিয়ে দিন, আর প্রতিটি টেস্টের যা লাগে তা টেস্টের ভেতরেই বানান।
no tests ran, exit code 5 খুঁজে পাওয়ার নিয়মের সাথে কিছুই মেলেনি। ফাইলের নাম (test_*.py বা *_test.py), ফাংশনের নাম (test...), ক্লাসের নাম (Test...) দেখুন। -k ব্যবহার করলে হয়তো সেটি কিছুর সাথেই মেলে না।
ERROR: not found: .../tests/test_cart.py::test_total_adds_tax node ID ভুল। টেস্টটি TestCart-এর ভেতরে থাকে, তাই এর ঠিকানা tests/test_cart.py::TestCart::test_total_adds_tax। node ID হাতে টাইপ না করে pytest --co -q থেকে কপি করুন। Exit code 4।
ERROR: file or directory not found: tests/test_carts.py পথে বানান ভুল। Exit code 4।
pytest: error: unrecognized arguments: --maxfial=2 অপশনের বানান ভুল; আসলগুলোর তালিকা দেয় pytest --help। Exit code 4।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
এই ফাইলটি test_rules.py নামে রাখা হয়েছে। pytest -q চালালে শেষ লাইনে কয়টি পাস দেখাবে?
def test_found():
assert True
def check_total():
assert True
class TestGroup:
def test_method(self):
assert True
def helper(self):
assert True
class CartTests:
def test_ignored(self):
assert True- A3 passed
- B4 passed
- C2 passed
- D1 passed
কোড আছে src/shop/pricing.py-তে। pyproject.toml-এ [tool.pytest.ini_options]-এর নিচে লেখা pythonpath = ["shop"]। তবু pytest বলছে ModuleNotFoundError: No module named 'shop'। ভুলটা কোথায়?
# tests/test_pricing.py
from shop.pricing import add_tax
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0- Aটেস্ট ফাইলের নাম `_test.py` দিয়ে শেষ হতে হবে
- B`pythonpath`-কে সেই ফোল্ডার দেখাতে হবে যার *ভেতরে* `shop` প্যাকেজ আছে: `["src"]`
- Cফাংশনের নাম `Test` দিয়ে শুরু হতে হবে
- Dকিছু import করার আগে `tests/`-এ একটি `__init__.py` লাগবে
দুটি টেস্ট ব্যর্থ হচ্ছিল, আপনি বাগটি ঠিক করেছেন। কোন কমান্ড কেবল ওই দুটি টেস্ট আবার চালাবে?
- Apytest --ff
- Bpytest -x
- Cpytest --co
- Dpytest --lf
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
একটি লাইব্রেরি দেরিতে বই ফেরতের জন্য জরিমানা নেয়। এই প্রজেক্টটি তৈরি করুন:
library/
├── src/
│ └── library/
│ ├── __init__.py
│ └── fees.py
└── tests/
└── test_fees.pysrc/library/fees.py:
def late_fee(days_late, daily_rate=5):
if days_late < 0:
raise ValueError(f"days_late cannot be negative: {days_late}")
return min(days_late * daily_rate, 100)- আগে একটি টেস্ট পরিকল্পনা লিখুন: সময়মতো ফেরত, কয়েক দিন দেরি, ঠিক সর্বোচ্চ সীমায়, সীমা পেরিয়ে, নিজস্ব দৈনিক হার।
- সেই কেসগুলো একটি ক্লাস
TestLateFee-এর মেথড হিসেবেtests/test_fees.py-তে লিখুন।pyproject.tomlছাড়াpytestচালান, ত্রুটিটি পড়ুন, আরecho $?দেখুন। pyproject.tomlযোগ করুন, যাতে টেস্টগুলোlibraryimport করতে পারে। সবুজ না হওয়া পর্যন্ত চালান।--co -qদিয়ে node ID-গুলোর তালিকা দেখুন। কেবল "সীমা পেরিয়ে" টেস্টটি তার node ID দিয়ে চালান।-kদিয়ে কেবল সর্বোচ্চ সীমা (cap) সংক্রান্ত টেস্টগুলো চালান, তারপর সেগুলো বাদে বাকি সব।fees.py-তে সীমা100থেকে90করুন। স্যুটটি চালান, তারপর--lf। আগের মতো করে দিয়ে--lfচালান, তারপর সবকিছু।
সমাধান
পরিকল্পনা:
| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | সময়মতো | late_fee(0) | 0 | | কয়েক দিন দেরি | late_fee(3) | 15 | | সীমা: ঠিক cap-এ | late_fee(20) | 100 | | cap পেরিয়ে | late_fee(30) | 100 | | নিজস্ব হার | late_fee(3, daily_rate=2) | 6 | | ঋণাত্মক দিন | late_fee(-1) | ValueError — পরে, pytest.raises দিয়ে |
tests/test_fees.py:
from library.fees import late_fee
class TestLateFee:
def test_on_time_costs_nothing(self):
assert late_fee(0) == 0
def test_three_days_late(self):
assert late_fee(3) == 15
def test_fee_reaches_cap_exactly(self):
assert late_fee(20) == 100
def test_fee_never_exceeds_cap(self):
assert late_fee(30) == 100
def test_custom_daily_rate(self):
assert late_fee(3, daily_rate=2) == 6কনফিগারেশন ছাড়া রানটি সংগ্রহের ধাপেই থেমে যায় ModuleNotFoundError: No module named 'library' আর Interrupted: 1 error during collection দিয়ে, আর echo $? ছাপে 2। তারপর pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
..... [100%]
5 passed in 0.01s
$ pytest --co -q
tests/test_fees.py::TestLateFee::test_on_time_costs_nothing
tests/test_fees.py::TestLateFee::test_three_days_late
tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly
tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
tests/test_fees.py::TestLateFee::test_custom_daily_rate
5 tests collected in 0.01s
$ pytest -q tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
. [100%]
1 passed in 0.01s
$ pytest -q -k cap
.. [100%]
2 passed, 3 deselected in 0.01s
$ pytest -q -k "not cap"
... [100%]
3 passed, 2 deselected in 0.01scap 90 করার পর:
$ pytest -q --tb=no
..FF. [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 passed in 0.01s
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 deselected in 0.01scap আবার 100 করলে pytest -q --lf দেয় 2 passed, 3 deselected, আর পুরো pytest -q দেয় 5 passed।
সিদ্ধান্তগুলোর কারণ:
- cap-এর জন্য একটি নয়, দুটি টেস্ট।
late_fee(20)ঠিক সীমারেখায় — যেদিন জরিমানা প্রথম cap-এ পৌঁছায়;late_fee(30)তার অনেক পরে। এক-ঘর-এদিক-ওদিক ভুল থাকে সীমারেখাতেই, তাই সীমারেখা নিজের টেস্ট পায় — আর দ্বিতীয়টি প্রমাণ করে cap তার পরেও টিকে থাকে। - দুটি cap টেস্টের নামেই
capশব্দটি আছে, আর কেবল ওখানেই। এতেই-k capআর-k "not cap"পরিষ্কারভাবে বাছাই করতে পেরেছে — নামগুলো বাছাইয়ের কথা মাথায় রেখেই রাখা। - একটি ক্লাস, কোনো
__init__নেই, ভাগাভাগি করা state নেই। প্রতিটি মেথড নিজেইlate_feeডাকে; ক্লাসটি কেবল দলটিকে একটি ঠিকানা দেয়,tests/test_fees.py::TestLateFee। - টেস্ট ফাইলে
sys.pathলাইনের বদলেpythonpath, যাতে যে ফোল্ডার থেকেই চালান টেস্ট পাস করে। --lf-এর পরে পুরো রান।--lfপ্রমাণ করে দুটি লাল টেস্ট আবার সবুজ; বাকি তিনটি এখনো সবুজ কি না, তা প্রমাণ করে কেবল পুরো রান।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন