अध्याय 15

async टेस्ट, hook और अपना plugin

pytest-asyncio से async कोड की टेस्टिंग, फिर conftest.py में hook लिखकर pytest को बढ़ाना — --runslow फ़्लैग, marker, रिपोर्ट हेडर — और आख़िर में अपना plugin बनाकर pytester से उसकी टेस्टिंग।

60 मिनटPython 3.12
  1. 1समस्या
  2. 2समझें
  3. 3उदाहरण
  4. 4अनुमान
  5. 5स्वयं करें
  6. 6चुनौती

वह समस्या जिसे हम हल कर रहे हैं

यह रहा asynchronous कोड का एक छोटा-सा टुकड़ा — ऐसा कोड जो नेटवर्क से बात करता है और जवाब के इंतज़ार में खाली नहीं बैठना चाहता।

prices.py:

python
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:

python
from prices import fetch_price


async def test_pen_price():
    assert await fetch_price("pen") == 999.0

pytest -q:

text
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

failure को ध्यान से पढ़िए। इसमें 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, और pytest11 entry points
  • pytester fixture से किसी plugin की टेस्टिंग कर पाएंगे

ज़रूरी शर्तें: coverage और CI।


टेस्ट लिखने से पहले

यह अध्याय दो अलग तरह की चीज़ें टेस्ट करता है — एक async फ़ंक्शन और एक pytest plugin — और दोनों के लिए टेस्ट की पहली लाइन लिखने से पहले थोड़ा सोचना ज़रूरी है।

क्या वादा किया जा रहा है। पहले अनुबंध (contract) को सादे शब्दों में लिख लीजिए, क्योंकि टेस्ट उसी अनुबंध का अनुवाद हैं।

  • fetch_price(item) एक coroutine फ़ंक्शन है। किसी जानी-पहचानी चीज़ के साथ await करने पर उसकी क़ीमत देता है। अनजानी चीज़ के साथ await करने पर KeyError उठाता है। कई कॉल एक साथ await किए जाएँ तो उनका इंतज़ार आपस में overlap होना चाहिए, क़तार में नहीं।
  • धीमे-टेस्ट वाले plugin का वादा: --runslow के बिना slow चिह्नित हर टेस्ट कारण के साथ skip होगा; --runslow देने पर सब कुछ चलेगा; हेडर बताएगा कि कौन-सी स्थिति है; और slow marker रजिस्टर्ड होगा, इसलिए कोई चेतावनी नहीं आएगी।

क्या पहले से तैयार होना चाहिए। 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 है:

text
pip install pytest-asyncio

सिर्फ़ इंस्टॉल कर देना काफ़ी नहीं है। वही टेस्ट फिर चलाइए, आउटपुट हूबहू वही रहेगा: async def functions are not natively supported। डिफ़ॉल्ट रूप से pytest-asyncio strict mode में काम करता है — वह सिर्फ़ उन्हीं टेस्ट को छूता है जो आपने साफ़ तौर पर उसे सौंपे हों। सौंपने का तरीक़ा एक marker है:

python
import pytest

from prices import fetch_price


@pytest.mark.asyncio
async def test_pen_price():
    assert await fetch_price("pen") == 999.0
text
F                                                                        [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 वाले रास्ते के लिए एक टेस्ट जोड़िए:

python
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")
text
..                                                                       [100%]
2 passed in 0.10s

async टेस्ट के अंदर भी pytest.raises ठीक वैसे ही काम करता है जैसे बाक़ी जगह; await बस with ब्लॉक के अंदर चला जाता है।

एक और ग़लत तरीक़ा देखने लायक़ है, क्योंकि वही सबसे आम है: पूरे सवाल से बचने के लिए साधारण def टेस्ट लिखना और async फ़ंक्शन को बिना await के बुलाना।

python
from prices import fetch_price


def test_pen_price():
    assert fetch_price("pen") == 15.0
text
F                                                                        [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 बदल दीजिए:

toml
[tool.pytest.ini_options]
asyncio_mode = "auto"

auto mode में pytest-asyncio बिना किसी marker के हर async def टेस्ट और हर async def fixture को अपने हाथ में ले लेता है। पूरे pytest रन का हेडर बताता है कि कौन-सा mode लागू है:

text
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function

fixture भी async हो सकते हैं। यहाँ एक cart बनता है, टेस्ट को सौंपा जाता है, और बाद में बंद होता है — yield के दोनों ओर एक-एक await के साथ:

python
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]
text
..                                                                       [100%]
2 passed in 0.11s

yield ठीक वैसे ही काम करता है जैसे fixtures वाले अध्याय में: उससे पहले का सब कुछ setup है, बाद का सब कुछ teardown, और टेस्ट fail होने पर भी teardown चलता है।

strict mode में उसी fixture को अपना decorator चाहिए, @pytest_asyncio.fixture, जो import pytest_asyncio से आता है। वहाँ साधारण @pytest.fixture हो तो pytest नहीं जानता कि उसे कैसे चलाए, और setup पर ही रुक जाता है:

text
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:

python
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:

python
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:

text
testing against http://localhost:8000
.
1 passed in 0.07s
testing against https://staging.example.com
.
1 passed in 0.07s

pytest_addoption एक बार चलता है, command line पार्स होने से पहले, और parser.addoption वही आर्ग्युमेंट लेता है जो Python का argparse। फिर request.config इस रन का कॉन्फ़िगरेशन ऑब्जेक्ट है, और getoption वैल्यू को वापस पढ़ता है। option pytest --help में भी दिखता है:

text
--shop-url=SHOP_URL   where the shop under test runs

option को एक 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:

python
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:

python
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 True

pytest -q, फिर pytest -q --runslow:

text
.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 उसे सबसे पहले दिखाता है:

text
@pytest.mark.slow: a test that takes seconds, not milliseconds

pytest_report_header(config) एक string (या strings की सूची) लौटाता है जिसे pytest रन की शुरुआत में छापता है। CI log पढ़ने वाले को जो जानना ज़रूरी है, उसे बताने की यही सही जगह है। pytest -rs:

text
============================= 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 — कई जगहों से इकट्ठा करता है:

  1. उसके अपने built-in plugins। pytest का ज़्यादातर हिस्सा plugins के रूप में लिखा है: -k, markers, tmp_path, टर्मिनल रिपोर्ट।
  2. इंस्टॉल किए गए वे पैकेज जो pytest11 entry point घोषित करते हैं। pytest-asyncio, pytest-cov और बाक़ी इसी तरह इंस्टॉल होते ही ख़ुद को लोड करवाते हैं। हेडर की plugins: लाइन इनकी सूची देती है।
  3. command line पर या addopts में -p से बताए गए मॉड्यूल।
  4. conftest.py फ़ाइलें, आपके टेस्ट की जड़ में और किसी भी सबडायरेक्टरी में (सबडायरेक्टरी का conftest.py सिर्फ़ उसके नीचे के टेस्ट पर लागू होता है)। pytest_addoption जैसे कुछ शुरुआती hooks सिर्फ़ जड़ वाले conftest.py में ही माने जाते हैं।

इसलिए ऊपर वाले conftest.py को जस का तस उठाया जा सकता है। उसका नाम बदलकर pytest_slowtests.py कीजिए, और वह एक plugin है। उसे -p और मॉड्यूल के नाम से लोड कीजिए:

text
pytest -p pytest_slowtests

मॉड्यूल import हो सकना चाहिए। अगर वह प्रोजेक्ट की जड़ में है, तो pythonpath से pytest को बताइए, और हर बार -p टाइप करने से बचने के लिए उसे addopts में रखिए:

toml
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"

उसे कई प्रोजेक्ट में बाँटने के लिए एक पैकेज बनाइए और entry point घोषित कीजिए। समूह का नाम pytest11 ही होना चाहिए; बाईं ओर का नाम plugin का नाम है, और दाईं ओर की वैल्यू मॉड्यूल:

toml
[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 है ही नहीं:

text
============================= 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 में) चालू कीजिए:

python
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 के टेस्ट।

text
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
    ├── test_plugin.py
    └── test_prices.py

prices.py वही है जो अध्याय की शुरुआत में था, और pytest_slowtests.py --runslow वाले हिस्से का conftest.py है, नाम बदला हुआ। pyproject.toml:

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:

python
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) == 400

tests/test_plugin.py:

python
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 (हेडर छोटा किया गया):

text
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:

text
......                                                                   [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 नहीं हुआ:

text
>       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 को पैकेज की तरह इंस्टॉल कीजिए।