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.11sfailure को ध्यान से पढ़िए। इसमें 15.0 या 999.0 के बारे में एक शब्द भी नहीं है। assertion कभी चला ही नहीं। किसी async def फ़ंक्शन को बुलाने से उसका कोड नहीं चलता — बस एक coroutine बनता है, और उसे चलाने के लिए किसी को event loop चलाना पड़ता है। साधारण pytest यह नहीं करता। (pytest के पुराने संस्करण ऐसे टेस्ट को एक चेतावनी के साथ skip कर देते थे, यानी कुछ भी जाँचे बिना वह हरा दिखता था। pytest 9 उसे fail करता है, जो कहीं बेहतर है।)
यही इस अध्याय का पहला आधा हिस्सा है: pytest से async कोड चलवाना। दूसरा आधा उस हल के पीछे का गहरा विचार है — pytest ख़ुद hooks का एक समूह है, और plugin बस ऐसा कोड है जो उनमें से कुछ को भरता है। यह एक बार दिख जाए तो आप अपना plugin लिख सकते हैं: एक --runslow फ़्लैग, एक marker, रिपोर्ट के हेडर में एक लाइन, और ख़ुद plugin के लिए एक टेस्ट सूट।
इस अध्याय के अंत में आप कर पाएंगे
- समझा पाएंगे कि खाली
async def test_...क्यों नहीं चलता, और उस failure को पहचान पाएंगे 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 hooks कहाँ खोजता है:
conftest.py,-p, औरpytest11entry points pytesterfixture से किसी plugin की टेस्टिंग कर पाएंगे
ज़रूरी शर्तें: coverage और CI।
टेस्ट लिखने से पहले
यह अध्याय दो अलग तरह की चीज़ें टेस्ट करता है — एक async फ़ंक्शन और एक pytest plugin — और दोनों के लिए टेस्ट की पहली लाइन लिखने से पहले थोड़ा सोचना ज़रूरी है।
क्या वादा किया जा रहा है। पहले अनुबंध (contract) को सादे शब्दों में लिख लीजिए, क्योंकि टेस्ट उसी अनुबंध का अनुवाद हैं।
fetch_price(item)एक coroutine फ़ंक्शन है। किसी जानी-पहचानी चीज़ के साथ await करने पर उसकी क़ीमत देता है। अनजानी चीज़ के साथ await करने परKeyErrorउठाता है। कई कॉल एक साथ await किए जाएँ तो उनका इंतज़ार आपस में overlap होना चाहिए, क़तार में नहीं।- धीमे-टेस्ट वाले plugin का वादा:
--runslowके बिनाslowचिह्नित हर टेस्ट कारण के साथ skip होगा;--runslowदेने पर सब कुछ चलेगा; हेडर बताएगा कि कौन-सी स्थिति है; औरslowmarker रजिस्टर्ड होगा, इसलिए कोई चेतावनी नहीं आएगी।
क्या पहले से तैयार होना चाहिए। async टेस्ट को एक चलाने वाला चाहिए, जो pytest के पास नहीं है: pytest-asyncio इंस्टॉल कीजिए और टेस्ट लिखने से पहले तय कीजिए कि strict mode या auto, क्योंकि आप कौन-से 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 से 400 कॉल | सभी 400 वापस, लगभग एक कॉल जितने समय में | | सामान्य रन | एक 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 कैसे छापता है। सटीक समय भी नहीं: "400 कॉल एक सेकंड से कम में पूरे होते हैं" किसी भी मशीन पर स्थिर दावा है, "0.013 सेकंड में" नहीं।
अध्याय का बाक़ी हिस्सा इसी टेबल पर, पंक्ति-दर-पंक्ति चलता है।
pytest-asyncio से async टेस्ट चलाना
failure संदेश में कई plugins के नाम हैं। 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अब यह सही वजह से fail होता है। assert 15.0 == 999.0 — coroutine चला, await ने एक असली संख्या दी, और assertion ने उसकी तुलना की। ग़लत वैल्यू पर टेस्ट को fail होते देखना ही सबूत है कि वह सच में चल रहा है। संख्या ठीक कीजिए, और साथ ही error वाले रास्ते के लिए एक टेस्ट जोड़िए:
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 टेस्ट लिखना और async फ़ंक्शन को बिना await के बुलाना।
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 fixtures
हर टेस्ट के ऊपर @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 ठीक वैसे ही काम करता है जैसे fixtures वाले अध्याय में: उससे पहले का सब कुछ 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 के कुछ hooks लागू किए — तय नामों वाले फ़ंक्शन जिन्हें pytest तय मौक़ों पर बुलाता है, जैसे "एक टेस्ट अभी चलने वाला है"। plugin बस इतना ही है। इस अध्याय के बाक़ी हिस्से में आप अपना plugin लिखेंगे।conftest.py में hooks
hook pytest_<कुछ> नाम का एक फ़ंक्शन है जिसे pytest रन के किसी ख़ास मौक़े पर बुलाता है। इसे रजिस्टर नहीं करना पड़ता; आप उसे ऐसी जगह परिभाषित करते हैं जहाँ pytest देखता है, और बाक़ी काम नाम कर देता है। ऐसी पहली जगह वही conftest.py फ़ाइल है जिसे आप साझा fixtures के लिए पहले से इस्तेमाल करते हैं।
एक 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
एक आम ज़रूरत: कुछ टेस्ट कई सेकंड लेते हैं, और आप उन्हें हर save पर नहीं चलाना चाहते — सिर्फ़ push से पहले, या CI में। उन्हें slow चिह्नित कीजिए, और --runslow फ़्लैग न दिया जाए तो skip कीजिए।
पहली लुभावनी कोशिश यह होती है कि जाँच हर धीमे टेस्ट के अंदर रख दी जाए: शुरू में option पढ़ो, न हो तो pytest.skip() बुलाओ। यह काम करता है, और यह ग़लत तरीक़ा है। नियम अब एक जगह के बजाय हर धीमे टेस्ट में बिखरा है; जिस दिन कोई नया धीमा टेस्ट लिखकर वे दो लाइनें भूल जाएगा, वह हर save पर चलेगा और किसी को पता नहीं चलेगा कि सूट धीमा क्यों हो गया। ऊपर से, न चलने का फ़ैसला करने से पहले टेस्ट की body को चलना शुरू करना पड़ता है, यानी उसके fixtures तब तक बन चुके होते हैं — शायद एक डेटाबेस, शायद एक सर्वर।
सही जगह किसी भी टेस्ट के शुरू होने से पहले है। सारे टेस्ट इकट्ठा करने के बाद, और किसी के चलने से पहले, 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 में (दूसरी चीज़ों के साथ) टेस्ट के markers के नाम होते हैं, इसलिए "slow" in item.keywords पूछता है "क्या इस टेस्ट पर @pytest.mark.slow है?"। item.add_marker(skip_slow) उस पर एक skip जोड़ देता है, ठीक वैसे ही जैसे आपने ख़ुद टेस्ट के ऊपर @pytest.mark.skip(reason="needs --runslow") लिखा हो।
marker रजिस्टर करना और हेडर में एक लाइन
उस फ़ाइल के दो छोटे hooks पर एक-दो बातें।
pytest_configure(config) एक बार चलता है, command line पार्स होने के बाद। config.addinivalue_line("markers", ...) slow marker को रजिस्टर करता है — pyproject.toml की markers = [...] लाइन जैसा ही, लेकिन plugin ख़ुद उसे साथ लेकर चलता है। इसके बिना हर @pytest.mark.slow से PytestUnknownMarkWarning आता है, और --strict-markers के साथ वह error बन जाता है। अब pytest --markers उसे सबसे पहले दिखाता है:
@pytest.mark.slow: a test that takes seconds, not millisecondspytest_report_header(config) एक string (या strings की सूची) लौटाता है जिसे 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 hooks कहाँ खोजता है
इनमें से हर hook किसी मॉड्यूल में रहता है। pytest ऐसे मॉड्यूल — plugins — कई जगहों से इकट्ठा करता है:
- उसके अपने built-in plugins। pytest का ज़्यादातर हिस्सा plugins के रूप में लिखा है:
-k, markers,tmp_path, टर्मिनल रिपोर्ट। - इंस्टॉल किए गए वे पैकेज जो
pytest11entry point घोषित करते हैं।pytest-asyncio,pytest-covऔर बाक़ी इसी तरह इंस्टॉल होते ही ख़ुद को लोड करवाते हैं। हेडर कीplugins:लाइन इनकी सूची देती है। - command line पर या
addoptsमें-pसे बताए गए मॉड्यूल। conftest.pyफ़ाइलें, आपके टेस्ट की जड़ में और किसी भी सबडायरेक्टरी में (सबडायरेक्टरी काconftest.pyसिर्फ़ उसके नीचे के टेस्ट पर लागू होता है)।pytest_addoptionजैसे कुछ शुरुआती hooks सिर्फ़ जड़ वाले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 लागू होता है। hooks इकट्ठा किए गए items देखते हैं; उनके पीछे का फ़ंक्शन async है या नहीं, यह pytest-asyncio का मामला है, hooks का नहीं।
दूसरी, asyncio.gather ने fetch_price के 400 कॉल चलाए, हर एक 0.01 सेकंड सोता है, और पूरा रन एक सेकंड से काफ़ी कम में पूरा हुआ — चार सेकंड में नहीं। उन्होंने एक ही समय में इंतज़ार किया। async कोड के होने की वजह यही है, और टेस्ट साबित करता है कि कोड सच में ऐसे ही बर्ताव करता है।
तीसरी, run में makeini वाली लाइन। pytester के अंदर का रन अपने कॉन्फ़िगरेशन वाला एक अलग प्रोजेक्ट है, और वह हर इंस्टॉल plugin लोड करता है — pytest-asyncio समेत, जो asyncio_default_fixture_loop_scope सेट न होने पर चेतावनी देता है। अंदर के प्रोजेक्ट को वह एक सेटिंग देने से बाहर की रिपोर्ट साफ़ रहती है। आम सबक़: pytester रन आपके इंस्टॉल plugins देखता है, लेकिन आपका 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 टेस्ट ने async फ़ंक्शन को बिना await के बुलाया, और 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 रजिस्टर्ड option से मेल नहीं खाता। आपके plugins ने जितने option जोड़े हैं, pytest --help उन सबको "custom options" के तहत दिखाता है।
ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' -p एक मॉड्यूल का नाम लेता है, और वह मॉड्यूल import नहीं हो सका। कॉन्फ़िग में pythonpath = ["."] जोड़िए, या plugin को पैकेज की तरह इंस्टॉल कीजिए।
चरण 4 / 6 — अनुमान
अपनी समझ की जाँच करें
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 क्या कहता है। यह error एक बार देख लेने के बाद हमेशा के लिए पहचानना आसान हो जाता है।
आगे पढ़ने से पहले ख़ुद कोशिश कीजिए।
समाधान
प्रोजेक्ट का ढाँचा पूर्ण उदाहरण जैसा ही है:
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 2string"2"बनकर आता है, औरbudget / 5TypeErrorउठाता है।test_budget_option_is_a_floatइसी को पक्का करने के लिए है: अंदर का टेस्टbudget == 2.0assert करता है, और string"2"उसके बराबर नहीं होती।- fixture plugin के अंदर है। plugin मॉड्यूल भी
conftest.pyकी तरह fixture परिभाषित कर सकता है, इसलिए जो भी-p pytest_budgetलोड करता है, उसेbudgetभी मिलता है। टेस्टbudgetमाँगते हैं; उनमें से कोई नहीं जानता कि उसके पीछे एक option है। - skip का फ़ैसला collection के समय होता है, टेस्ट के अंदर नहीं।
test_price_service_answersमें कोईifनहीं है। एक hook नियम को सँभालता है, और कोई नयाnetworkटेस्ट लिखते ही उसके दायरे में आ जाता है। - समय वाला टेस्ट एक गुंजाइश का दावा करता है, किसी संख्या का नहीं। वह बजट का पाँचवाँ हिस्सा सोता है और assert करता है कि पूरे बजट के भीतर ख़त्म हुआ। यह धीमी CI मशीन पर भी टिकता है; "0.1 सेकंड लगे" नहीं टिकता।
- एक
runhelper। हरpytesterटेस्ट वही अंदर वाला प्रोजेक्ट बनाता है, इसलिए setup — अंदर के रन मेंpytest-asyncioको चुप रखने वालीmakeiniलाइन समेत — एक ही बार लिखा है। इस समाधान के पहले मसौदे मेंtest_marker_is_registeredमें helper इस्तेमाल करना छूट गया था, और बाहर के रन ने तुरंत उस एक अंदर वाले रन से1 warningबताया। - व्यवहार के लिए गिनती, आउटपुट के लिए लाइनें।
assert_outcomesजाँचता है कि plugin टेस्ट के साथ क्या करता है;fnmatch_linesजाँचता है कि वह क्या छापता है।"@pytest.mark.network: *"का*marker का विवरण दोबारा लिखे जाने पर भी टेस्ट को टूटने नहीं देता। - टूटा हुआ hook। pytest
pytest_से शुरू होने वाले हर फ़ंक्शन को अपने जाने-पहचाने hooks से मिलाता है, और कोई मेल न खाए तो शुरू ही नहीं होता। hook के नाम में वर्तनी की ग़लती चुपचाप कुछ-न-करने में नहीं बदल सकती — औरpytesterटेस्ट भी इसे वैसे ही पकड़ लेते, क्योंकिtest_header_shows_budgetएक ऐसी लाइन ढूँढता है जो कभी छपती ही नहीं।
Step 6 of 6
चुनौती — the chapter quiz
सरल से कठिन — दस प्रश्न, अंतिम वाले जानबूझकर चुनौतीपूर्ण बनाए गए हैं।
Sign in to take the quiz