অধ্যায় 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

ব্যর্থতার বার্তাটি মন দিয়ে পড়ুন। 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, আর pytest11 entry point
  • pytester fixture দিয়ে একটি plugin টেস্ট করতে

আগে যা জানা লাগবে: coverage আর CI।


টেস্ট লেখার আগে

এই অধ্যায়ে দুই রকম জিনিস টেস্ট হবে — একটি async ফাংশন আর একটি pytest plugin — আর দুটোর জন্যই টেস্টের প্রথম লাইন লেখার আগে কিছুটা ভাবা দরকার।

কী প্রতিশ্রুতি দেওয়া হচ্ছে। আগে চুক্তিটা সাদা ভাষায় লিখে ফেলুন, কারণ টেস্টগুলো সেই চুক্তিরই অনুবাদ।

  • fetch_price(item) একটি coroutine ফাংশন। চেনা জিনিস দিয়ে await করলে সেটির দাম দেয়। অচেনা জিনিস দিয়ে await করলে KeyError তোলে। অনেকগুলো কল একসাথে await করলে সেগুলোর অপেক্ষা একে অপরের ওপর পড়া উচিত, লাইনে দাঁড়িয়ে নয়।
  • ধীর-টেস্ট plugin-টির প্রতিশ্রুতি: --runslow ছাড়া slow চিহ্নিত প্রতিটি টেস্ট কারণসহ skip হবে; --runslow দিলে সবকিছু চলবে; হেডারে লেখা থাকবে কোনটা হচ্ছে; আর slow marker নিবন্ধিত থাকবে, তাই কোনো সতর্কবার্তা আসবে না।

কী আগে থেকে তৈরি থাকা চাই। 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:

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

এবার ব্যর্থতা ঠিক কারণে। assert 15.0 == 999.0 — coroutine চলেছে, await একটি আসল সংখ্যা দিয়েছে, আর assertion সেটির তুলনা করেছে। ভুল মানে টেস্টকে fail করতে দেখাই প্রমাণ যে টেস্টটি সত্যিই চলছে। সংখ্যাটি ঠিক করুন, আর সেই সাথে ত্রুটির পথের জন্যও একটি টেস্ট যোগ করুন:

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 টেস্ট লিখে await ছাড়াই async ফাংশনটি ডাকা।

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 fixture

প্রতিটি টেস্টের ওপর @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 ঠিক fixture অধ্যায়ের মতোই কাজ করে: তার আগের সবকিছু 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-এর কয়েকটি hook বাস্তবায়ন করেছে — নির্দিষ্ট নামের কিছু ফাংশন, যেগুলো pytest নির্দিষ্ট মুহূর্তে ডাকে, যেমন "একটি টেস্ট এখনই চালানো হবে"। plugin বলতে এটুকুই। অধ্যায়ের বাকি অংশে আপনি নিজের একটি plugin লিখবেন।

conftest.py-তে hook

hook হলো pytest_<কিছু একটা> নামের একটি ফাংশন, যেটি pytest রানের একটি নির্দিষ্ট মুহূর্তে ডাকে। একে নিবন্ধন করতে হয় না; pytest যেখানে খোঁজে সেখানে সংজ্ঞায়িত করলেই হয়, বাকিটা নামই করে দেয়। এমন প্রথম জায়গা হলো সেই conftest.py ফাইল, যেটি আপনি ভাগাভাগি করা fixture-এর জন্য আগে থেকেই ব্যবহার করছেন।

একটি 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

একটি প্রচলিত প্রয়োজন: কিছু টেস্টে কয়েক সেকেন্ড লাগে, আর প্রতিবার সেভ করার সময় সেগুলো চালাতে চান না — কেবল push-এর আগে, বা CI-তে। সেগুলোকে slow চিহ্ন দিন, আর --runslow ফ্ল্যাগ না দিলে skip করুন।

প্রথমে লোভনীয় চেষ্টাটা হলো প্রতিটি ধীর টেস্টের ভেতরেই পরীক্ষাটা বসানো: শুরুতে option পড়া, না থাকলে pytest.skip() ডাকা। এটা কাজ করে, এবং এটা ভুল পথ। নিয়মটা এখন এক জায়গায় নয়, প্রতিটি ধীর টেস্টে ছড়িয়ে আছে; যেদিন কেউ নতুন একটি ধীর টেস্ট লিখে ওই দুটি লাইন ভুলে যাবে, সেটি প্রতিবার সেভে চলবে, আর স্যুট কেন ধীর হলো কেউ টেরই পাবে না। তার ওপর, না চলার সিদ্ধান্ত নেওয়ার আগে টেস্টের body-কে চলা শুরু করতে হয়, ফলে তার fixture-গুলো ততক্ষণে তৈরি হয়ে গেছে — হয়তো একটি ডেটাবেস, হয়তো একটি সার্ভার।

ঠিক জায়গাটা কোনো টেস্ট শুরুর আগে। 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-এ (অন্যান্য জিনিসের সাথে) টেস্টের 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 তালিকার প্রথমেই এটিকে দেখায়:

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

pytest_report_header(config) একটি string (বা string-এর তালিকা) ফেরত দেয়, যা 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 hook কোথায় খোঁজে

এই প্রতিটি hook কোনো একটি মডিউলে থাকে। pytest এমন মডিউল — plugin — কয়েকটি জায়গা থেকে সংগ্রহ করে:

  1. নিজের built-in plugin। pytest-এর বেশিরভাগটাই plugin হিসেবে লেখা: -k, marker, tmp_path, টার্মিনাল রিপোর্ট।
  2. ইনস্টল করা যে প্যাকেজগুলো একটি pytest11 entry point ঘোষণা করে। pytest-asyncio, pytest-cov আর বাকিরা এভাবেই ইনস্টল হওয়ামাত্র নিজেদের লোড করায়। হেডারের plugins: লাইনে এদের তালিকা থাকে।
  3. command line-এ বা addopts-এ -p দিয়ে নাম দেওয়া মডিউল।
  4. conftest.py ফাইল, আপনার টেস্টের মূলে আর যেকোনো সাবডিরেক্টরিতে (সাবডিরেক্টরির conftest.py কেবল তার নিচের টেস্টগুলোতে খাটে)। pytest_addoption-এর মতো কয়েকটি আগেভাগের hook কেবল মূলের 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 প্রযোজ্য হয়। 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 হয়নি:

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 টেস্ট 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-টিকে প্যাকেজ হিসেবে ইনস্টল করুন।