async টেস্ট, hook আর নিজের plugin
pytest-asyncio দিয়ে async কোড টেস্ট করা, তারপর conftest.py-তে hook লিখে pytest-কে বাড়ানো — --runslow ফ্ল্যাগ, marker, রিপোর্ট হেডার — আর শেষে নিজের plugin বানিয়ে pytester দিয়ে সেটির টেস্ট লেখা।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এখানে একটুখানি asynchronous কোড — এমন কোড যা নেটওয়ার্কের সাথে কথা বলে, আর উত্তরের অপেক্ষায় বসে থাকতে চায় না।
prices.py:
import asyncio
PRICES = {"pen": 15.0, "notebook": 60.0}
async def fetch_price(item):
await asyncio.sleep(0.01) # stands in for a network call
if item not in PRICES:
raise KeyError(item)
return PRICES[item]এতদিন যেভাবে সবকিছু টেস্ট করেছেন, এটিকেও সেভাবেই করবেন: একটি ফাংশন, যার নাম test_ দিয়ে শুরু। ফাংশনটি async, কারণ await কেবল এমন ফাংশনের ভেতরেই লেখা যায়। আর টেস্টটি সত্যিই কিছু পরীক্ষা করছে কি না নিশ্চিত হতে, প্রত্যাশিত দামটি ইচ্ছে করে ভুল দেওয়া হলো:
test_prices.py:
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0pytest -q:
F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
async def functions are not natively supported.
You need to install a suitable plugin for your async framework, for example:
- anyio
- pytest-asyncio
- pytest-tornasync
- pytest-trio
- pytest-twisted
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - Failed: async def functions are not n...
1 failed in 0.11sব্যর্থতার বার্তাটি মন দিয়ে পড়ুন। 15.0 বা 999.0 নিয়ে এতে একটি কথাও নেই। assertion-টি কখনো চলেইনি। একটি async def ফাংশন ডাকলে তার ভেতরের কোড চলে না — কেবল একটি coroutine তৈরি হয়, আর সেটি চালাতে কাউকে একটি event loop চালাতে হয়। সাধারণ pytest তা করে না। (pytest-এর পুরোনো সংস্করণ এমন টেস্টকে একটি সতর্কবার্তা দিয়ে skip করত, ফলে কিছুই পরীক্ষা না করেও সেটি সবুজ দেখাত। pytest 9 এটিকে fail করে, যা অনেক ভালো।)
এটাই এই অধ্যায়ের প্রথম অর্ধেক: pytest দিয়ে async কোড চালানো। দ্বিতীয় অর্ধেক সেই সমাধানের পেছনের গভীর ধারণা — pytest নিজেই কতগুলো hook-এর সমষ্টি, আর plugin হলো এমন কোড যা তার কয়েকটি পূরণ করে। এটা একবার দেখে ফেললে নিজেরটাও লিখতে পারবেন: একটি --runslow ফ্ল্যাগ, একটি marker, রিপোর্টের হেডারে একটি লাইন, আর plugin-টির নিজের জন্য একটি টেস্ট স্যুট।
এই অধ্যায় শেষে আপনি পারবেন
- ব্যাখ্যা করতে কেন খালি
async def test_...চলে না, আর সেই ব্যর্থতা চিনতে pytest-asyncioদিয়ে async টেস্ট চালাতে,@pytest.mark.asyncioঅথবাasyncio_mode = "auto"ব্যবহার করে- async fixture লিখতে,
yield-এর পরে teardown-সহ pytest_addoptionদিয়ে একটি command-line option যোগ করতে আরrequest.config.getoptionদিয়ে সেটি পড়তেpytest_collection_modifyitemsদিয়ে,--runslowনা দিলে ধীর টেস্টগুলো skip করতেpytest_configure-এ একটি marker নিবন্ধন করতে আরpytest_report_headerদিয়ে একটি লাইন যোগ করতে- বলতে pytest hook কোথায় খোঁজে:
conftest.py,-p, আরpytest11entry point pytesterfixture দিয়ে একটি plugin টেস্ট করতে
আগে যা জানা লাগবে: coverage আর CI।
টেস্ট লেখার আগে
এই অধ্যায়ে দুই রকম জিনিস টেস্ট হবে — একটি async ফাংশন আর একটি pytest plugin — আর দুটোর জন্যই টেস্টের প্রথম লাইন লেখার আগে কিছুটা ভাবা দরকার।
কী প্রতিশ্রুতি দেওয়া হচ্ছে। আগে চুক্তিটা সাদা ভাষায় লিখে ফেলুন, কারণ টেস্টগুলো সেই চুক্তিরই অনুবাদ।
fetch_price(item)একটি coroutine ফাংশন। চেনা জিনিস দিয়ে await করলে সেটির দাম দেয়। অচেনা জিনিস দিয়ে await করলেKeyErrorতোলে। অনেকগুলো কল একসাথে await করলে সেগুলোর অপেক্ষা একে অপরের ওপর পড়া উচিত, লাইনে দাঁড়িয়ে নয়।- ধীর-টেস্ট plugin-টির প্রতিশ্রুতি:
--runslowছাড়াslowচিহ্নিত প্রতিটি টেস্ট কারণসহ skip হবে;--runslowদিলে সবকিছু চলবে; হেডারে লেখা থাকবে কোনটা হচ্ছে; আরslowmarker নিবন্ধিত থাকবে, তাই কোনো সতর্কবার্তা আসবে না।
কী আগে থেকে তৈরি থাকা চাই। async টেস্ট চালাতে একজন চালক লাগে, যা pytest-এর নেই: pytest-asyncio ইনস্টল করুন আর টেস্ট লেখার আগেই strict না auto mode তা ঠিক করুন, কারণ কোন decorator লিখবেন তা এর ওপর নির্ভর করে। যে কোড টেস্ট হবে তা import করা যাওয়া চাই — প্রজেক্টের মূলে prices.py, আর পরে plugin মডিউলটিও (pythonpath = ["."])। plugin টেস্ট করতে pytester fixture চালু করতে হবে। আর কোনো কিছুর জন্য আসল নেটওয়ার্ক লাগা উচিত নয়: asyncio.sleep তার জায়গা নেয়।
পরিকল্পনা। প্রতিটি আচরণের জন্য একটি সারি, তার ইনপুট আর প্রত্যাশিত ফলসহ:
| ক্ষেত্র | ইনপুট | প্রত্যাশিত | | --- | --- | --- | | চেনা জিনিস | await fetch_price("pen") | 15.0 | | অচেনা জিনিস | await fetch_price("stapler") | KeyError | | await ভুলে যাওয়া | সাধারণ টেস্টে fetch_price("pen") | একটি coroutine অবজেক্ট, দাম নয় | | অনেকগুলো একসাথে | asyncio.gather দিয়ে ৪০০টি কল | ৪০০টিই ফেরে, মোটামুটি একটি কলের সময়ে | | সাধারণ রান | একটি slow টেস্টসহ pytest | 1 passed, 1 skipped | | ফ্ল্যাগ দিলে | pytest --runslow | 2 passed | | হেডার | pytest | একটি লাইন slow tests: skipped (use --runslow) | | marker | pytest --markers | তালিকায় slow, কোনো PytestUnknownMarkWarning নেই |
কী টেস্ট করবেন না। asyncio.sleep বা event loop নয় — সেগুলো Python নিজেই টেস্ট করে। pytest-এর নিজের skip ব্যবস্থাও নয়: আপনি টেস্ট করবেন যে আপনার hook skip জুড়ে দিচ্ছে, ফলাফল গুনে — pytest কীভাবে একটি s ছাপে তা নয়। নিখুঁত সময়ও নয়: "৪০০টি কল এক সেকেন্ডের কমে শেষ হয়" যেকোনো মেশিনে স্থির দাবি, "০.০১৩ সেকেন্ডে" নয়।
অধ্যায়ের বাকিটা এই টেবিল ধরে, সারির পর সারি এগোবে।
pytest-asyncio দিয়ে async টেস্ট চালানো
ব্যর্থতার বার্তায় কয়েকটি plugin-এর নাম আছে। asyncio-ভিত্তিক কোডের জন্য সাধারণ পছন্দ pytest-asyncio:
pip install pytest-asyncioকেবল ইনস্টল করাই যথেষ্ট নয়। একই টেস্ট আবার চালান, আউটপুট হুবহু একই: async def functions are not natively supported। ডিফল্টভাবে pytest-asyncio strict mode-এ কাজ করে — কেবল সেই টেস্টগুলোতেই হাত দেয় যেগুলো আপনি স্পষ্টভাবে তার হাতে তুলে দিয়েছেন। তুলে দেওয়া হয় একটি marker দিয়ে:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 999.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
@pytest.mark.asyncio
async def test_pen_price():
> assert await fetch_price("pen") == 999.0
E assert 15.0 == 999.0
test_prices.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - assert 15.0 == 999.0
1 failed in 0.10sএবার ব্যর্থতা ঠিক কারণে। assert 15.0 == 999.0 — coroutine চলেছে, await একটি আসল সংখ্যা দিয়েছে, আর assertion সেটির তুলনা করেছে। ভুল মানে টেস্টকে fail করতে দেখাই প্রমাণ যে টেস্টটি সত্যিই চলছে। সংখ্যাটি ঠিক করুন, আর সেই সাথে ত্রুটির পথের জন্যও একটি টেস্ট যোগ করুন:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 15.0
@pytest.mark.asyncio
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler").. [100%]
2 passed in 0.10sasync টেস্টের ভেতরেও pytest.raises ঠিক অন্য জায়গার মতোই কাজ করে; await-টি কেবল with ব্লকের ভেতরে যায়।
আরও একটি ভুল পথ দেখে রাখা দরকার, কারণ এটিই সবচেয়ে প্রচলিত: পুরো প্রশ্নটা এড়াতে একটি সাধারণ def টেস্ট লিখে await ছাড়াই async ফাংশনটি ডাকা।
from prices import fetch_price
def test_pen_price():
assert fetch_price("pen") == 15.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
def test_pen_price():
> assert fetch_price("pen") == 15.0
E AssertionError: assert <coroutine object fetch_price at 0x70659d650e80> == 15.0
E + where <coroutine object fetch_price at 0x70659d650e80> = fetch_price('pen')
test_noawait.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_noawait.py::test_pen_price - AssertionError: assert <coroutine ob...
1 failed in 0.08s
sys:1: RuntimeWarning: coroutine 'fetch_price' was never awaitedরিপোর্টটি ঠিক যা ঘটেছে তা-ই বলছে: fetch_price('pen') একটি coroutine অবজেক্ট ফেরত দিয়েছে, আর coroutine 15.0-এর সমান নয়। শেষ লাইনে Python জানাচ্ছে যে coroutine-টি একবারও না চলে ফেলে দেওয়া হয়েছে। এই টেস্ট অন্তত জোরে শব্দ করে fail করে। বিপজ্জনক রূপটি হলো তুলনা ছাড়া assert fetch_price("pen") — coroutine অবজেক্ট truthy, তাই সেই টেস্ট কিছুই পরীক্ষা না করে 1 passed, 1 warning জানায়। async কোডের যেকোনো টেস্টকে নিজেই async def হতে হবে আর await করতে হবে।
asyncio_mode = "auto" আর async fixture
প্রতিটি টেস্টের ওপর @pytest.mark.asyncio লিখতে লিখতে দ্রুতই একঘেয়ে লাগে। যে প্রজেক্টে async-ই স্বাভাবিক, সেখানে pyproject.toml-এ mode বদলে দিন:
[tool.pytest.ini_options]
asyncio_mode = "auto"auto mode-এ pytest-asyncio কোনো marker ছাড়াই প্রতিটি async def টেস্ট আর প্রতিটি async def fixture নিজের হাতে নেয়। পূর্ণ pytest রানের হেডার বলে দেয় কোন mode চালু আছে:
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionfixture-ও async হতে পারে। এখানে একটি cart তৈরি হয়, টেস্টের হাতে যায়, আর পরে বন্ধ হয় — yield-এর দুই পাশেই একটি করে await:
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
self.closed = False
async def add(self, item):
self.items.append((item, await fetch_price(item)))
async def close(self):
self.closed = True
@pytest.fixture
async def cart():
c = Cart()
yield c
await c.close()
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]
async def test_two_items(cart):
await cart.add("pen")
await cart.add("notebook")
assert [price for _, price in cart.items] == [15.0, 60.0].. [100%]
2 passed in 0.11syield ঠিক fixture অধ্যায়ের মতোই কাজ করে: তার আগের সবকিছু setup, পরের সবকিছু teardown, আর টেস্ট fail করলেও teardown চলে।
strict mode-এ একই fixture-এর জন্য নিজস্ব decorator লাগে, @pytest_asyncio.fixture, import pytest_asyncio থেকে। সেখানে সাধারণ @pytest.fixture থাকলে pytest জানে না সেটি কীভাবে চালাবে, আর setup-এই থেমে যায়:
E [100%]
==================================== ERRORS ====================================
__________________________ ERROR at setup of test_add __________________________
'test_add' requested an async fixture 'cart', with no plugin or hook that handled it. This is an error, as pytest does not natively support it.
See: https://docs.pytest.org/en/stable/deprecations.html#sync-test-depending-on-async-fixture
=========================== short test summary info ============================
ERROR test_cart.py::test_add - Failed: 'test_add' requested an async fixture ...
1 error in 0.09sতাই বেছে নেওয়াটা সহজ। strict mode: async টেস্টে @pytest.mark.asyncio আর async fixture-এ @pytest_asyncio.fixture। auto mode: সব জায়গায় সাধারণ async def। প্রতি প্রজেক্টে একটি বেছে নিন আর কনফিগে লিখে রাখুন, যাতে কাউকে অনুমান করতে না হয়।
pytest-asyncio এটা করল কীভাবে? সে pytest বদলায়নি। সে pytest-এর কয়েকটি hook বাস্তবায়ন করেছে — নির্দিষ্ট নামের কিছু ফাংশন, যেগুলো pytest নির্দিষ্ট মুহূর্তে ডাকে, যেমন "একটি টেস্ট এখনই চালানো হবে"। plugin বলতে এটুকুই। অধ্যায়ের বাকি অংশে আপনি নিজের একটি plugin লিখবেন।conftest.py-তে hook
hook হলো pytest_<কিছু একটা> নামের একটি ফাংশন, যেটি pytest রানের একটি নির্দিষ্ট মুহূর্তে ডাকে। একে নিবন্ধন করতে হয় না; pytest যেখানে খোঁজে সেখানে সংজ্ঞায়িত করলেই হয়, বাকিটা নামই করে দেয়। এমন প্রথম জায়গা হলো সেই conftest.py ফাইল, যেটি আপনি ভাগাভাগি করা fixture-এর জন্য আগে থেকেই ব্যবহার করছেন।
একটি command-line option: pytest_addoption
ধরুন একই টেস্টগুলো আপনার ল্যাপটপের বিরুদ্ধেও চলবে, একটি staging সার্ভারের বিরুদ্ধেও। ঠিকানাটির জায়গা command line-এ, কোডে নয়:
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--shop-url", default="http://localhost:8000", help="where the shop under test runs"
)
@pytest.fixture
def shop_url(request):
return request.config.getoption("--shop-url")test_shop.py:
def test_shop_url(shop_url):
print("testing against", shop_url)
assert shop_url.startswith("http")pytest -q -s, তারপর pytest -q -s --shop-url https://staging.example.com:
testing against http://localhost:8000
.
1 passed in 0.07s
testing against https://staging.example.com
.
1 passed in 0.07spytest_addoption একবার চলে, command line পার্স হওয়ার আগে, আর parser.addoption Python-এর argparse-এর মতোই আর্গুমেন্ট নেয়। এরপর request.config হলো এই রানের কনফিগারেশন অবজেক্ট, আর getoption মানটি ফেরত পড়ে দেয়। option-টি pytest --help-এও দেখা যায়:
--shop-url=SHOP_URL where the shop under test runsoption-টিকে একটি fixture-এ মুড়ে দেওয়াই প্রচলিত ধরন: টেস্টগুলো shop_url চায়, কনফিগারেশনে সরাসরি কখনো হাত দেয় না।
ধীর টেস্ট skip করা: pytest_collection_modifyitems
একটি প্রচলিত প্রয়োজন: কিছু টেস্টে কয়েক সেকেন্ড লাগে, আর প্রতিবার সেভ করার সময় সেগুলো চালাতে চান না — কেবল push-এর আগে, বা CI-তে। সেগুলোকে slow চিহ্ন দিন, আর --runslow ফ্ল্যাগ না দিলে skip করুন।
প্রথমে লোভনীয় চেষ্টাটা হলো প্রতিটি ধীর টেস্টের ভেতরেই পরীক্ষাটা বসানো: শুরুতে option পড়া, না থাকলে pytest.skip() ডাকা। এটা কাজ করে, এবং এটা ভুল পথ। নিয়মটা এখন এক জায়গায় নয়, প্রতিটি ধীর টেস্টে ছড়িয়ে আছে; যেদিন কেউ নতুন একটি ধীর টেস্ট লিখে ওই দুটি লাইন ভুলে যাবে, সেটি প্রতিবার সেভে চলবে, আর স্যুট কেন ধীর হলো কেউ টেরই পাবে না। তার ওপর, না চলার সিদ্ধান্ত নেওয়ার আগে টেস্টের body-কে চলা শুরু করতে হয়, ফলে তার fixture-গুলো ততক্ষণে তৈরি হয়ে গেছে — হয়তো একটি ডেটাবেস, হয়তো একটি সার্ভার।
ঠিক জায়গাটা কোনো টেস্ট শুরুর আগে। pytest সব টেস্ট সংগ্রহ করার পরে, আর কোনোটি চলার আগে, pytest_collection_modifyitems(config, items) ডাকে। items হলো সংগৃহীত টেস্টের তালিকা, আর hook সেটি বদলাতে পারে — এক জায়গায় একটি নিয়ম, marker বহনকারী প্রতিটি টেস্টে প্রযোজ্য।
conftest.py:
import pytest
def pytest_addoption(parser):
parser.addoption(
"--runslow", action="store_true", default=False, help="also run tests marked slow"
)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a test that takes seconds, not milliseconds")
def pytest_report_header(config):
if config.getoption("--runslow"):
return "slow tests: included"
return "slow tests: skipped (use --runslow)"
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)test_reports.py:
import time
import pytest
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
time.sleep(1) # pretend this crunches a year of sales
assert Truepytest -q, তারপর pytest -q --runslow:
.s [100%]
1 passed, 1 skipped in 0.10s
.. [100%]
2 passed in 1.09sফ্ল্যাগ ছাড়া ধীর টেস্টটি একটি s, আর রানে লাগে এক সেকেন্ডের দশ ভাগের এক ভাগ। ফ্ল্যাগ দিলে দুটোই চলে, আর দ্বিতীয়টি তার পুরো এক সেকেন্ড নেয়। action="store_true" --runslow-কে একটি সুইচ বানায়: না থাকলে False, থাকলে True। hook-এর ভেতরে request নেই, তাই hook মানটি সরাসরি config থেকে পড়ে।
item.keywords-এ (অন্যান্য জিনিসের সাথে) টেস্টের marker-গুলোর নাম থাকে, তাই "slow" in item.keywords জিজ্ঞেস করে "এই টেস্টে কি @pytest.mark.slow আছে?"। item.add_marker(skip_slow) তাতে একটি skip জুড়ে দেয়, ঠিক যেন আপনি নিজেই টেস্টের ওপর @pytest.mark.skip(reason="needs --runslow") লিখেছেন।
marker নিবন্ধন আর হেডারে একটি লাইন
ওই ফাইলের ছোট দুটি hook নিয়ে দু-এক কথা।
pytest_configure(config) একবার চলে, command line পার্স হওয়ার পরে। config.addinivalue_line("markers", ...) slow marker-টি নিবন্ধন করে — pyproject.toml-এ একটি markers = [...] লাইনের মতোই, তবে plugin নিজেই সেটি বয়ে আনে। এটা না থাকলে প্রতিটি @pytest.mark.slow থেকে PytestUnknownMarkWarning আসে, আর --strict-markers দিলে সেটা ত্রুটি হয়ে যায়। এখন pytest --markers তালিকার প্রথমেই এটিকে দেখায়:
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config) একটি string (বা string-এর তালিকা) ফেরত দেয়, যা pytest রানের শুরুতে ছাপে। CI log-এর পাঠকের যা জানা দরকার, তা জানানোর ঠিক জায়গা এটাই। pytest -rs:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/shop
collected 2 items
test_reports.py .s [100%]
=========================== short test summary info ============================
SKIPPED [1] test_reports.py:10: needs --runslow
========================= 1 passed, 1 skipped in 0.07s =========================স্পষ্ট কারণসহ skip হওয়া টেস্ট সৎ: সারাংশ বলে দেয় কী চলেনি, আর কেন।
pytest hook কোথায় খোঁজে
এই প্রতিটি hook কোনো একটি মডিউলে থাকে। pytest এমন মডিউল — plugin — কয়েকটি জায়গা থেকে সংগ্রহ করে:
- নিজের built-in plugin। pytest-এর বেশিরভাগটাই plugin হিসেবে লেখা:
-k, marker,tmp_path, টার্মিনাল রিপোর্ট। - ইনস্টল করা যে প্যাকেজগুলো একটি
pytest11entry point ঘোষণা করে।pytest-asyncio,pytest-covআর বাকিরা এভাবেই ইনস্টল হওয়ামাত্র নিজেদের লোড করায়। হেডারেরplugins:লাইনে এদের তালিকা থাকে। - command line-এ বা
addopts-এ-pদিয়ে নাম দেওয়া মডিউল। conftest.pyফাইল, আপনার টেস্টের মূলে আর যেকোনো সাবডিরেক্টরিতে (সাবডিরেক্টরিরconftest.pyকেবল তার নিচের টেস্টগুলোতে খাটে)।pytest_addoption-এর মতো কয়েকটি আগেভাগের hook কেবল মূলেরconftest.py-তেই মানা হয়।
তাই ওপরের conftest.py-টি হুবহু তুলে আনা যায়। নাম বদলে pytest_slowtests.py করুন, আর সেটি একটি plugin। -p আর মডিউলের নাম দিয়ে লোড করুন:
pytest -p pytest_slowtestsমডিউলটিকে import করা যাওয়া চাই। সেটি প্রজেক্টের মূলে থাকলে pythonpath দিয়ে pytest-কে তা জানান, আর প্রতিবার -p টাইপ করা এড়াতে সেটি addopts-এ রাখুন:
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"বিভিন্ন প্রজেক্টে ভাগ করে নিতে চাইলে এটিকে একটি প্যাকেজ বানান আর entry point ঘোষণা করুন। গ্রুপের নাম হতেই হবে pytest11; বাঁয়ের নামটি plugin-এর নাম, আর ডানের মানটি মডিউল:
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "pytest-slowtests"
version = "0.1.0"
dependencies = ["pytest>=8"]
[project.entry-points.pytest11]
slowtests = "pytest_slowtests"একটি নতুন environment-এ এটি ইনস্টল করুন (pip install ./pytest-slowtests), আর এমন একটি প্রজেক্টে চালান যেখানে কোনো conftest.py-ই নেই:
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
slow tests: skipped (use --runslow)
rootdir: /home/you/other-project
plugins: slowtests-0.1.0
collected 2 items
test_reports.py .s [100%]
========================= 1 passed, 1 skipped in 0.01s =========================plugins: slowtests-0.1.0 — pytest এটিকে entry point দিয়ে খুঁজে পেয়েছে। উল্টোটাও কাজ করে: -p no:slowtests একটি রানের জন্য ইনস্টল করা plugin বন্ধ করে দেয়, যা কাজে লাগে যখন সন্দেহ হয় কোনো plugin গোলমাল করছে।
pytester দিয়ে plugin টেস্ট করা
plugin pytest-এর আচরণ বদলায়, তাই তার টেস্টকে pytest চালাতে হয় আর দেখতে হয় কী ঘটল। ঠিক এই কাজের জন্য pytest একটি fixture দেয়, pytester। এটি ডিফল্টভাবে বন্ধ; টেস্ট মডিউলে (বা মূলের conftest.py-তে) চালু করুন:
pytest_plugins = ["pytester"]pytester প্রতিটি টেস্টকে একটি খালি অস্থায়ী ডিরেক্টরি দেয়, একটি নতুন ছোট্ট প্রজেক্ট। pytester.makepyfile(...) তাতে একটি টেস্ট ফাইল লেখে, pytester.runpytest(...) আপনার দেওয়া আর্গুমেন্ট নিয়ে সেখানে pytest চালায়, আর ফলাফলে আছে গণনা মেলানোর জন্য assert_outcomes(passed=..., skipped=..., failed=...), আর আউটপুটের লাইন মেলানোর জন্য result.stdout.fnmatch_lines([...]) (* wildcard হিসেবে ব্যবহার করা যায়)। পূর্ণ রূপটি নিচের সম্পূর্ণ উদাহরণে।
একটা সম্পূর্ণ উদাহরণ
একটি ছোট প্রজেক্ট, যাতে এই অধ্যায়ের সবকিছু আছে: async কোড, auto mode-এ async টেস্ট, ধীর-টেস্ট plugin, আর plugin-টির টেস্ট।
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.py অধ্যায়ের শুরুর সেটিই, আর pytest_slowtests.py হলো --runslow অংশের conftest.py, নাম বদলানো। pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_slowtests"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"tests/test_prices.py:
import asyncio
import pytest
from prices import PRICES, fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 15.0
async def test_unknown_item():
with pytest.raises(KeyError):
await fetch_price("stapler")
@pytest.mark.slow
async def test_every_price_at_once():
names = list(PRICES) * 200
prices = await asyncio.gather(*(fetch_price(n) for n in names))
assert len(prices) == 400tests/test_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_quick():
assert True
@pytest.mark.slow
def test_slow():
assert True
"""
def run(pytester, *args):
# The inner run is a brand-new project in a temporary directory.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE)
return pytester.runpytest("-p", "pytest_slowtests", *args)
def test_slow_is_skipped_by_default(pytester):
result = run(pytester)
result.assert_outcomes(passed=1, skipped=1)
def test_runslow_runs_everything(pytester):
result = run(pytester, "--runslow")
result.assert_outcomes(passed=2)
def test_header_mentions_the_flag(pytester):
result = run(pytester)
result.stdout.fnmatch_lines(["slow tests: skipped (use --runslow)"])pytest -v (হেডার ছোট করা):
slow tests: skipped (use --runslow)
configfile: pyproject.toml
testpaths: tests
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
collecting ... collected 6 items
tests/test_plugin.py::test_slow_is_skipped_by_default PASSED [ 16%]
tests/test_plugin.py::test_runslow_runs_everything PASSED [ 33%]
tests/test_plugin.py::test_header_mentions_the_flag PASSED [ 50%]
tests/test_prices.py::test_pen_price PASSED [ 66%]
tests/test_prices.py::test_unknown_item PASSED [ 83%]
tests/test_prices.py::test_every_price_at_once SKIPPED (needs --runslow) [100%]
========================= 5 passed, 1 skipped in 0.17s =========================আর pytest -q --runslow:
...... [100%]
6 passed in 0.40sতিনটি জিনিস লক্ষ করুন।
প্রথমত, plugin-টি async টেস্টেও কাজ করে। test_every_price_at_once একটি async def, slow marker-সহ, আর অন্য যেকোনো টেস্টের মতোই তাতে skip প্রযোজ্য হয়। hook দেখে সংগৃহীত item; তার পেছনের ফাংশনটি async কি না, সেটা pytest-asyncio-র মাথাব্যথা, hook-এর নয়।
দ্বিতীয়ত, asyncio.gather fetch_price-এর ৪০০টি কল চালিয়েছে, প্রতিটি ০.০১ সেকেন্ড ঘুমিয়ে, আর পুরো রানে লেগেছে এক সেকেন্ডেরও অনেক কম — চার সেকেন্ড নয়। তারা একই সময়ে অপেক্ষা করেছে। async কোডের অস্তিত্বের কারণই এটা, আর টেস্টটি প্রমাণ করে যে কোড সত্যিই এভাবে আচরণ করে।
তৃতীয়ত, run-এর ভেতরের makeini লাইনটি। pytester-এর ভেতরের রানটি নিজস্ব কনফিগারেশনসহ একটি আলাদা প্রজেক্ট, আর এটি ইনস্টল করা প্রতিটি plugin লোড করে — pytest-asyncio-সহ, যা asyncio_default_fixture_loop_scope সেট না থাকলে সতর্ক করে। ভেতরের প্রজেক্টকে ওই একটি সেটিং দিলে বাইরের রিপোর্ট পরিষ্কার থাকে। সাধারণ শিক্ষাটা: একটি pytester রান আপনার ইনস্টল করা plugin দেখে, কিন্তু আপনার pyproject.toml দেখে না।
plugin ভাঙলে এই টেস্টগুলো তা নিখুঁতভাবে জানায়। এখানে skip-এর পরীক্ষায় ভুল করে "slw" লেখা হয়েছিল, তাই কিছুই skip হয়নি:
> result.assert_outcomes(passed=1, skipped=1)
E AssertionError: assert {'passed': 2,...rors': 0, ...} == {'passed': 1,...rors': 0, ...}
E
E Omitting 4 identical items, use -vv to show
E Differing items:
E {'passed': 2} != {'passed': 1}
E {'skipped': 0} != {'skipped': 1}
E Use -v to get more diffতার নিচে, Captured stdout call-এর অধীনে, pytest ভেতরের রানের পুরো আউটপুট ছাপে, যাতে ঠিক কী ঘটেছে দেখতে পান।
কিছু ভাঙা অবস্থা ও তার সমাধান
async def functions are not natively supported. টেস্টটি async def, আর কেউ সেটি চালাচ্ছে না। হয় pytest-asyncio ইনস্টল নেই, নয়তো সেটি strict mode-এ আছে আর টেস্টে @pytest.mark.asyncio নেই। marker যোগ করুন, অথবা asyncio_mode = "auto" দিন।
requested an async fixture 'cart', with no plugin or hook that handled it strict mode-এ একটি async def fixture-এ সাধারণ @pytest.fixture লাগানো। @pytest_asyncio.fixture ব্যবহার করুন, অথবা auto mode-এ যান।
AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0, তারপর RuntimeWarning: coroutine 'fetch_price' was never awaited একটি সাধারণ def টেস্ট await ছাড়া async ফাংশন ডেকেছে, আর coroutine অবজেক্টটিরই তুলনা করেছে। টেস্টটিকে async def করুন আর await fetch_price("pen") লিখুন।
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? marker ব্যবহার হয়েছে, কিন্তু কখনো নিবন্ধিত হয়নি। pytest_configure-এ config.addinivalue_line("markers", ...) দিয়ে, অথবা markers সেটিং-এ নিবন্ধন করুন।
PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> একটি ফাংশন pytest_ দিয়ে শুরু, কিন্তু সেটি pytest-এর চেনা কোনো hook নয় — এখানে একটি বাড়তি s। pytest একটি plugin-এর প্রতিটি pytest_ নাম যাচাই করে, আর ঠিক সেটাই বানান ভুলটি ধরে ফেলে। hook reference-এর সাথে বানান মিলিয়ে নিন।
ValueError: no option named '--runslow' config.getoption এমন option চেয়েছে যা কোনো pytest_addoption যোগ করেনি। হয় নামটি আলাদা, নয়তো pytest_addoption hook এমন একটি conftest.py-তে আছে যা pytest যথেষ্ট আগে লোড করেনি — সেটি টেস্টের মূলে রাখুন।
pytest: error: unrecognized arguments: --run-slow command line-এর option নিবন্ধিতটির সাথে মেলে না। আপনার plugin-গুলো যত option যোগ করেছে, pytest --help সবগুলো "custom options"-এর অধীনে দেখায়।
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -p একটি মডিউলের নাম নেয়, আর সেই মডিউল import করা যায়নি। কনফিগে pythonpath = ["."] যোগ করুন, অথবা plugin-টিকে প্যাকেজ হিসেবে ইনস্টল করুন।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
fetch_price("pen") আসলে 15.0 দেয়। pytest -q কী জানাবে?
# pytest-asyncio is installed, in its default (strict) mode
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0- A1 failed — assert 15.0 == 999.0
- B1 failed — async def functions are not natively supported.
- C1 passed
- D1 skipped, 1 warning
strict mode-এ এই টেস্টটি setup-এই ERROR দেয়। ঠিক করার উপায় কী?
# pyproject.toml has no asyncio_mode, so pytest-asyncio is in strict mode
import pytest
from prices import fetch_price
class Cart:
def __init__(self):
self.items = []
async def add(self, item):
self.items.append((item, await fetch_price(item)))
@pytest.fixture
async def cart():
yield Cart()
@pytest.mark.asyncio
async def test_add(cart):
await cart.add("pen")
assert cart.items == [("pen", 15.0)]- A`test_add`-কে সাধারণ `def` টেস্ট বানানো
- Bটেস্ট থেকে `@pytest.mark.asyncio` সরিয়ে দেওয়া
- Cfixture-টিতে `@pytest_asyncio.fixture` লাগানো (অথবা `asyncio_mode = "auto"` করা)
- D`yield`-এর বদলে `return` লেখা
দুটি ফাইল একই ফোল্ডারে আছে। কোনো ফ্ল্যাগ ছাড়া pytest -q চালালে কী দেখা যাবে?
# conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption("--runslow", action="store_true", default=False)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: a slow test")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip_slow = pytest.mark.skip(reason="needs --runslow")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
# test_reports.py
def test_quick_total():
assert sum([15.0, 60.0]) == 75.0
@pytest.mark.slow
def test_full_year_report():
assert True- A.. 2 passed
- B.F 1 passed, 1 failed
- Cs 1 skipped
- D.s 1 passed, 1 skipped
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
একটি plugin লিখুন, pytest_budget.py, যা প্রতিটি টেস্টকে একটি সময়-বাজেট দেয়।
--budgetনামে একটি option যোগ করুন, ডিফল্ট0.5(সেকেন্ড);parser.addoption-এtype=floatদিন।- একটি fixture
budgetদিন যাrequest.config.getoption("--budget")দিয়ে মানটি ফেরত দেয়। pytest_configure-এnetworkনামে একটি marker নিবন্ধন করুন, আরpytest_report_headerদিয়েbudget: 0.5s(বা যা দেওয়া হয়েছে) ছাপুন।pytest_collection_modifyitems-এ, দ্বিতীয় একটি option--offlineদেওয়া হলেnetworkচিহ্নিত প্রতিটি টেস্ট skip করুন।-p pytest_budgetদিয়ে এটি লোড করুন, আর auto mode-এ একটি async টেস্ট লিখুন যাbudget-এর চেয়ে কম সময়await asyncio.sleep(...)করে আর assert করে যে সময়মতো শেষ হয়েছে।
তারপর pytester দিয়ে plugin-টি টেস্ট করুন: প্রতিটি option-এর জন্য একটি টেস্ট, গণনার জন্য assert_outcomes আর হেডারের জন্য fnmatch_lines। শেষে ইচ্ছে করে plugin-টি ভাঙুন — pytest_report_header-এর নাম বদলে pytest_report_headers করুন — আর pytest কী বলে পড়ুন। এই ত্রুটি একবার দেখলে চিরদিনের জন্য চিনে রাখা সহজ।
পড়ার আগে নিজে চেষ্টা করুন।
সমাধান
প্রজেক্টের গড়ন সম্পূর্ণ উদাহরণের মতোই:
budget/
├── pyproject.toml
├── pytest_budget.py
└── tests/
├── test_budget_plugin.py
└── test_timing.pypyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_budget"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"pytest_budget.py:
"""Give every test a time budget, and let network tests be switched off."""
import pytest
def pytest_addoption(parser):
parser.addoption("--budget", type=float, default=0.5, help="seconds a test may take")
parser.addoption(
"--offline", action="store_true", default=False, help="skip tests marked network"
)
def pytest_configure(config):
config.addinivalue_line("markers", "network: the test needs a network connection")
def pytest_report_header(config):
return f"budget: {config.getoption('--budget')}s"
def pytest_collection_modifyitems(config, items):
if not config.getoption("--offline"):
return
skip_network = pytest.mark.skip(reason="--offline was given")
for item in items:
if "network" in item.keywords:
item.add_marker(skip_network)
@pytest.fixture
def budget(request):
return request.config.getoption("--budget")tests/test_timing.py:
import asyncio
import time
import pytest
async def test_finishes_within_budget(budget):
start = time.perf_counter()
await asyncio.sleep(budget / 5)
assert time.perf_counter() - start < budget
@pytest.mark.network
async def test_price_service_answers():
await asyncio.sleep(0.01) # stands in for a real request
assert Truetests/test_budget_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_reads_budget(budget):
assert budget == EXPECTED
@pytest.mark.network
def test_network():
assert True
"""
def run(pytester, *args, expected=0.5):
# A fresh inner project: its own config, our sample tests, our plugin.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE.replace("EXPECTED", repr(expected)))
return pytester.runpytest("-p", "pytest_budget", *args)
def test_default_budget(pytester):
result = run(pytester)
result.assert_outcomes(passed=2)
def test_budget_option_is_a_float(pytester):
result = run(pytester, "--budget", "2", expected=2.0)
result.assert_outcomes(passed=2)
def test_offline_skips_network_tests(pytester):
result = run(pytester, "--offline")
result.assert_outcomes(passed=1, skipped=1)
def test_header_shows_budget(pytester):
result = run(pytester, "--budget", "1.5", expected=1.5)
result.stdout.fnmatch_lines(["budget: 1.5s"])
def test_marker_is_registered(pytester):
result = run(pytester, "--markers")
result.stdout.fnmatch_lines(["@pytest.mark.network: *"])pytest -v (হেডার ছোট করা):
budget: 0.5s
collecting ... collected 7 items
tests/test_budget_plugin.py::test_default_budget PASSED [ 14%]
tests/test_budget_plugin.py::test_budget_option_is_a_float PASSED [ 28%]
tests/test_budget_plugin.py::test_offline_skips_network_tests PASSED [ 42%]
tests/test_budget_plugin.py::test_header_shows_budget PASSED [ 57%]
tests/test_budget_plugin.py::test_marker_is_registered PASSED [ 71%]
tests/test_timing.py::test_finishes_within_budget PASSED [ 85%]
tests/test_timing.py::test_price_service_answers PASSED [100%]
============================== 7 passed in 0.31s ===============================pytest -q --offline -rs:
......s [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_timing.py:13: --offline was given
6 passed, 1 skipped in 0.57sআর hook-এর নাম বদলে pytest_report_headers করলে কিছুই চলে না:
INTERNALERROR> pluggy._manager.PluginValidationError: unknown hook 'pytest_report_headers' in plugin <module 'pytest_budget' from '/home/you/budget/pytest_budget.py'>
no tests ran in 0.00sপ্রতিটি সিদ্ধান্তের কারণ:
type=float। এটা না দিলে--budget 2আসে string"2"হয়ে, আরbudget / 5TypeErrorতোলে।test_budget_option_is_a_floatআছে ঠিক এটাকে বেঁধে রাখতে: ভেতরের টেস্টটি assert করেbudget == 2.0, আর string"2"তার সমান হতো না।- fixture-টি plugin-এর ভেতরে। একটি plugin মডিউল
conftest.py-এর মতোই fixture সংজ্ঞায়িত করতে পারে, তাই যে-ই-p pytest_budgetলোড করে, সেbudget-ও পায়। টেস্টগুলোbudgetচায়; কেউ জানে না এর পেছনে একটি option আছে। - skip-এর সিদ্ধান্ত হয় সংগ্রহের সময়, টেস্টের ভেতরে নয়।
test_price_service_answers-এ কোনোifনেই। একটি hook নিয়মটি ধরে রাখে, আর নতুন কোনোnetworkটেস্ট লেখামাত্র তার আওতায় আসে। - সময়ের টেস্ট একটি ব্যবধান দাবি করে, একটি সংখ্যা নয়। এটি বাজেটের পাঁচ ভাগের এক ভাগ ঘুমায় আর assert করে যে পুরো বাজেটের মধ্যে শেষ হয়েছে। ধীর CI মেশিনেও এটা টেকে; "০.১ সেকেন্ড লেগেছে" টিকত না।
- একটি
runসহায়ক। প্রতিটিpytesterটেস্ট একই ভেতরের প্রজেক্ট বানায়, তাই setup — ভেতরের রানেpytest-asyncio-কে চুপ রাখারmakeiniলাইনসহ — একবারই লেখা। এই সমাধানের প্রথম খসড়ায়test_marker_is_registered-এ সহায়কটি ব্যবহার করতে ভুল হয়েছিল, আর বাইরের রান সঙ্গে সঙ্গে ওই একটি ভেতরের রান থেকে1 warningজানিয়েছিল। - আচরণের জন্য গণনা, আউটপুটের জন্য লাইন।
assert_outcomesপরীক্ষা করে plugin টেস্টগুলোর সাথে কী করে;fnmatch_linesপরীক্ষা করে সে কী ছাপে।"@pytest.mark.network: *"-এর*marker-এর বিবরণ নতুন করে লিখলেও টেস্টটিকে ভাঙতে দেয় না। - ভাঙা hook। pytest
pytest_দিয়ে শুরু হওয়া প্রতিটি ফাংশনকে তার চেনা hook-গুলোর সাথে মেলায়, আর কোনোটি না মিললে শুরুই করে না। hook-এর নামে বানান ভুল চুপচাপ কিছু-না-করা হয়ে থাকতে পারে না — আরpytesterটেস্টগুলোও এমনিতেই ধরে ফেলত, কারণtest_header_shows_budgetএমন একটি লাইন খোঁজে যা কখনো ছাপা হতো না।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন