الفصل 15

الاختبارات غير المتزامنة و hooks وكتابة إضافة

اختبار الكود غير المتزامن باستخدام pytest-asyncio، ثم توسيع pytest عبر hooks في conftest.py — خيار --runslow وعلامة وسطر في رأس التقرير — وأخيراً تحويل ذلك إلى إضافة خاصة بك واختبارها باستخدام 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. فالـ assert لم يُنفَّذ قط. استدعاء دالة async def لا ينفّذ جسمها — بل ينشئ coroutine فقط، ولا بد أن يشغّل شيءٌ حلقةَ أحداث (event loop) لتنفيذه. و pytest العادي لا يفعل ذلك. (كانت الإصدارات الأقدم من pytest تتخطّى اختباراً كهذا مع تحذير، فيبدو أخضر وهو لا يختبر شيئاً. أما pytest 9 فيُفشله، وهذا أفضل بكثير.)

هذا هو النصف الأول من الفصل: جعل pytest يشغّل الكود غير المتزامن. والنصف الثاني هو الفكرة الأعمق وراء الحل — فـ pytest نفسه مجموعة من hooks، والإضافة (plugin) ليست سوى كود يملأ بعضها. وما إن ترى ذلك حتى تستطيع كتابة إضافتك: خيار --runslow، وعلامة، وسطر في رأس التقرير، ومجموعة اختبارات للإضافة نفسها.

بنهاية هذا الفصل ستكون قادراً على

  • أن تشرح لماذا لا يعمل اختبار async def test_... المجرّد، وأن تتعرّف على رسالة فشله
  • أن تشغّل الاختبارات غير المتزامنة بـ pytest-asyncio، باستخدام @pytest.mark.asyncio أو asyncio_mode = "auto"
  • أن تكتب fixtures غير متزامنة، بما فيها تلك التي تُجري التنظيف بعد yield
  • أن تضيف خياراً لسطر الأوامر بـ pytest_addoption وتقرأه بـ request.config.getoption
  • أن تتخطّى الاختبارات البطيئة ما لم يُمرَّر --runslow، باستخدام pytest_collection_modifyitems
  • أن تسجّل علامة في pytest_configure وتضيف سطراً بـ pytest_report_header
  • أن تحدد أين يجد pytest الـ hooks: في conftest.py، وعبر -p، ونقاط الدخول pytest11
  • أن تختبر إضافة باستخدام الـ fixture المسمّى pytester

المتطلبات المسبقة: التغطية و CI.


قبل أن تكتب الاختبار

يختبر هذا الفصل نوعين مختلفين من الأشياء — دالة غير متزامنة وإضافة لـ pytest — وكلاهما يحتاج بعض التفكير قبل أول سطر من كود الاختبار.

ما الذي نَعِد به. اكتب العقد بكلمات بسيطة أولاً، لأن الاختبارات ليست إلا ترجمة لذلك العقد.

  • fetch_price(item) دالة coroutine. عند انتظارها (await) بعنصر معروف تعطي سعره. وعند انتظارها بعنصر مجهول ترفع KeyError. والاستدعاءات الكثيرة المنتظَرة معاً ينبغي أن تتداخل لا أن تصطف في طابور.
  • وإضافة الاختبارات البطيئة تَعِد بما يلي: من دون --runslow يُتخطّى كل اختبار معلَّم بـ slow مع ذكر السبب؛ ومع --runslow يعمل كل شيء؛ ويذكر رأس التقرير أي الحالتين؛ والعلامة slow مسجّلة فلا يظهر أي تحذير.

ما يجب أن يكون جاهزاً. تحتاج الاختبارات غير المتزامنة إلى مشغّل، وهو ما لا يملكه pytest: ثبّت pytest-asyncio وقرّر بين الوضعين strict و auto قبل كتابة الاختبارات، لأن الـ decorators التي ستكتبها تعتمد على ذلك. ويجب أن يكون الكود المختبَر قابلاً للاستيراد — prices.py في جذر المشروع، ولاحقاً وحدة الإضافة أيضاً (pythonpath = ["."]). واختبار الإضافة يتطلب تفعيل الـ fixture المسمّى pytester. ولا شيء ينبغي أن يحتاج شبكة حقيقية: فـ asyncio.sleep يحل محلها.

الخطة. صف لكل سلوك، مع مدخله وما تتوقع أن تراه:

| الحالة | المدخل | المتوقع | | --- | --- | --- | | عنصر معروف | await fetch_price("pen") | 15.0 | | عنصر مجهول | await fetch_price("stapler") | KeyError | | نسيان await | fetch_price("pen") في اختبار عادي | كائن coroutine لا سعر | | كثير في آن واحد | 400 استدعاء عبر asyncio.gather | تعود الـ 400 كلها في زمن استدعاء واحد تقريباً | | تشغيل افتراضي | pytest مع اختبار slow واحد | 1 passed, 1 skipped | | مع الخيار | pytest --runslow | 2 passed | | رأس التقرير | pytest | سطر slow tests: skipped (use --runslow) | | العلامة | pytest --markers | slow مدرجة، ولا PytestUnknownMarkWarning |

ما لا يجب اختباره. لا asyncio.sleep ولا حلقة الأحداث — فبايثون يختبرهما. ولا آلية التخطّي الخاصة بـ pytest: أنت تختبر أن الـ hook الخاص بك يُلحق التخطّي، بعدّ النتائج، لا كيف يطبع pytest الحرف s. ولا الأزمنة الدقيقة كذلك: "تنتهي 400 استدعاء في أقل من ثانية" ادعاء ثابت على أي جهاز، أما "في 0.013 ثانية" فلا.

بقية الفصل تمضي في هذا الجدول صفاً صفاً.


تشغيل الاختبارات غير المتزامنة بـ pytest-asyncio

تذكر رسالة الفشل عدة إضافات. وللكود المبني على asyncio الخيار المعتاد هو pytest-asyncio:

text
pip install pytest-asyncio

التثبيت وحده لا يكفي. شغّل الاختبار نفسه مجدداً وسيكون الخرج مطابقاً حرفاً بحرف: async def functions are not natively supported. فـ pytest-asyncio يعمل افتراضياً في الوضع strict — لا يمسّ إلا الاختبارات التي سلّمتها إليه صراحة. وتسلّمه اختباراً بعلامة:

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 رقماً حقيقياً، وقارنه الـ assert. ورؤية الاختبار يفشل بالقيمة الخاطئة هي الدليل على أنه يعمل فعلاً. أصلح الرقم، وأضف اختباراً لمسار الخطأ وأنت هنا:

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

يعمل pytest.raises داخل الاختبار غير المتزامن تماماً كما يعمل في غيره؛ كل ما في الأمر أن await يوضع داخل كتلة with.

ثمة طريقة خاطئة أخرى تستحق المشاهدة، لأنها الأكثر شيوعاً: التهرّب من المسألة كلها بكتابة اختبار def عادي واستدعاء الدالة غير المتزامنة من دون 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. ويضيف بايثون في السطر الأخير أن الـ coroutine رُمي من دون أن يعمل قط. هذا على الأقل يفشل بصوت عالٍ. أما الصورة الخطرة فهي assert fetch_price("pen") من دون مقارنة — فكائن الـ coroutine قيمة صادقة (truthy)، فيُبلغ ذلك الاختبار 1 passed, 1 warning وهو لا يتحقق من شيء. أي اختبار لكود غير متزامن يجب أن يكون هو نفسه async def وأن يستخدم await.

asyncio_mode = "auto" و fixtures غير المتزامنة

كتابة @pytest.mark.asyncio فوق كل اختبار تصبح مملّة بسرعة. وفي مشروع يكون فيه اللاتزامن هو الحالة المعتادة، غيّر الوضع في pyproject.toml:

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

في الوضع auto يتولى pytest-asyncio كل اختبار async def وكل fixture من نوع async def من دون أي علامة. ويخبرك رأس التشغيل الكامل لـ pytest بالوضع المعمول به:

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

يمكن أن تكون الـ fixtures غير متزامنة أيضاً. هنا تُنشأ سلة (cart) وتُسلَّم إلى الاختبار ثم تُغلق بعده — مع await على جانبي yield:

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: كل ما قبله إعداد، وكل ما بعده تنظيف، والتنظيف يعمل حتى لو فشل الاختبار.

في الوضع strict تحتاج الـ fixture نفسها إلى decorator خاص بها، هو @pytest_asyncio.fixture من import pytest_asyncio. ومع @pytest.fixture العادي هناك لا يعرف pytest كيف يشغّلها فيتوقف عند الإعداد:

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: علّم الاختبارات غير المتزامنة بـ @pytest.mark.asyncio والـ fixtures غير المتزامنة بـ @pytest_asyncio.fixture. الوضع auto: async def عادية في كل مكان. اختر واحداً لكل مشروع واكتبه في الإعدادات كي لا يضطر أحد إلى التخمين.

كيف فعل pytest-asyncio ذلك؟ لم يغيّر pytest. بل نفّذ بعض hooks الخاصة بـ pytest — دوال بأسماء ثابتة يستدعيها pytest في لحظات ثابتة، مثل "اختبار على وشك أن يعمل". وهذا كل ما في الإضافة. وفي بقية الفصل ستكتب إضافة خاصة بك.

الـ hooks في conftest.py

الـ hook دالة اسمها pytest_<شيء ما> يستدعيها pytest في لحظة معينة من التشغيل. لا تسجّلها؛ بل تعرّفها في مكان يبحث فيه pytest، والاسم يتكفّل بالباقي. وأول هذه الأماكن هو ملف conftest.py الذي تستخدمه أصلاً للـ fixtures المشتركة.

خيار لسطر الأوامر: pytest_addoption

افترض أن الاختبارات نفسها يجب أن تعمل على حاسوبك وعلى خادم staging. مكان العنوان سطر الأوامر لا الكود:

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 مرة واحدة قبل تحليل سطر الأوامر، ويأخذ parser.addoption الوسائط نفسها التي يأخذها argparse في بايثون. ثم إن request.config هو كائن إعدادات هذا التشغيل، و getoption تقرأ القيمة. ويظهر الخيار كذلك في pytest --help:

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

تغليف الخيار في fixture هو النمط المعتاد: تطلب الاختبارات shop_url ولا تلمس الإعدادات مباشرة أبداً.

تخطّي الاختبارات البطيئة: pytest_collection_modifyitems

حاجة شائعة: بعض الاختبارات تستغرق ثواني، ولا تريدها عند كل حفظ — بل قبل الدفع (push) فقط أو في CI. علّمها بـ slow، وتخطّها ما لم يُمرَّر الخيار --runslow.

المحاولة الأولى المغرية هي وضع الفحص داخل كل اختبار بطيء: قراءة الخيار في البداية، واستدعاء pytest.skip() إن غاب. هذا يعمل، وهو الطريقة الخاطئة. فالقاعدة الآن تعيش في كل اختبار بطيء بدلاً من مكان واحد؛ ويوم يكتب أحدهم اختباراً بطيئاً جديداً وينسى ذينك السطرين، سيعمل عند كل حفظ ولن يلاحظ أحد لماذا أصبحت المجموعة أبطأ. ثم إن جسم الاختبار يجب أن يبدأ العمل قبل أن يقرر ألا يعمل، فتكون الـ fixtures الخاصة به قد أُعدّت — ربما قاعدة بيانات، وربما خادم.

المكان الصحيح هو قبل أن يبدأ أي اختبار. بعد أن يجمع pytest كل الاختبارات وقبل أن يعمل أيٌّ منها، يستدعي pytest_collection_modifyitems(config, items). و items قائمة الاختبارات المجموعة، ويحق للـ hook تعديلها — قاعدة واحدة في مكان واحد، تُطبَّق على كل اختبار يحمل العلامة.

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. ولا يوجد request داخل الـ hook، لذا يقرأ الـ hook القيمة من config مباشرة.

يحوي item.keywords (ضمن أشياء أخرى) أسماء علامات الاختبار، فالتعبير "slow" in item.keywords يسأل "هل يحمل هذا الاختبار @pytest.mark.slow؟". ويُلحق item.add_marker(skip_slow) تخطّياً به، تماماً كما لو كتبت بنفسك @pytest.mark.skip(reason="needs --runslow") فوق الاختبار.

تسجيل العلامة وإضافة سطر إلى الرأس

يستحق hook-ان أصغر في ذلك الملف كلمة.

يعمل pytest_configure(config) مرة واحدة بعد تحليل سطر الأوامر. ويسجّل config.addinivalue_line("markers", ...) العلامة slow — كسطر markers = [...] في pyproject.toml، لكن الإضافة نفسها تحمله. ومن دونه يُنتج كل @pytest.mark.slow تحذير PytestUnknownMarkWarning، ومع --strict-markers يصبح خطأً. والآن يدرجها pytest --markers أولاً:

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

يعيد pytest_report_header(config) نصاً (أو قائمة نصوص) يطبعه pytest في أعلى التشغيل. وهو المكان المناسب لذكر أي شيء يحتاج قارئ سجل CI إلى معرفته. 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 =========================

الاختبار المتخطّى بسبب واضح اختبار صادق: يقول الملخص ما الذي لم يعمل، ولماذا.

كيف يجد pytest الـ hooks

كل واحد من هذه الـ hooks يعيش في وحدة (module). ويجمع pytest هذه الوحدات — الإضافات — من عدة أماكن:

  1. إضافاته المدمجة. معظم pytest مكتوب على شكل إضافات: -k، والعلامات، و tmp_path، وتقرير الطرفية.
  2. الحزم المثبّتة التي تعلن نقطة دخول pytest11. هكذا يحمّل pytest-asyncio و pytest-cov وسائرها أنفسها بمجرد تثبيتها. ويدرجها سطر plugins: في الرأس.
  3. الوحدات المذكورة بـ -p في سطر الأوامر أو في addopts.
  4. ملفات conftest.py، في جذر اختباراتك وفي أي مجلد فرعي (و conftest.py المجلد الفرعي لا ينطبق إلا على الاختبارات التي تحته). وبعض الـ hooks المبكرة، مثل pytest_addoption، لا تُعتمد إلا في conftest.py الموجود في الجذر.

لذا يمكن رفع conftest.py السابق كما هو. أعد تسميته إلى pytest_slowtests.py فيصبح إضافة. وحمّله بـ -p واسم الوحدة:

text
pytest -p pytest_slowtests

يجب أن تكون الوحدة قابلة للاستيراد. فإن كانت في جذر المشروع فأخبر pytest بذلك عبر pythonpath، ولتتجنب كتابة -p كل مرة ضعه في addopts:

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

ولمشاركتها بين المشاريع اجعلها حزمة وأعلن نقطة الدخول. يجب أن تُسمّى المجموعة pytest11؛ والاسم على اليسار هو اسم الإضافة، والقيمة على اليمين هي الوحدة:

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"

ثبّتها (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 عبر نقطة الدخول. والعكس يعمل أيضاً: -p no:slowtests يعطّل إضافة مثبّتة لتشغيل واحد، وهذا مفيد حين تشتبه بأن إضافة ما تسبب مشكلة.

اختبار إضافة باستخدام pytester

الإضافة تغيّر سلوك 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([...]) للتحقق من أسطر الخرج (ويمكن استخدام * كحرف بدل). والنسخة الكاملة في المثال المتكامل أدناه.


مثال تطبيقي متكامل

مشروع صغير يجمع كل ما في هذا الفصل: كوداً غير متزامن، واختبارات غير متزامنة في الوضع auto، وإضافة الاختبارات البطيئة، واختبارات للإضافة.

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

prices.py هو نفسه الذي في بداية الفصل، و pytest_slowtests.py هو conftest.py قسم --runslow بعد إعادة تسميته. 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

ثلاثة أمور تستحق الملاحظة.

أولاً، تعمل الإضافة على الاختبارات غير المتزامنة أيضاً. فـ test_every_price_at_once دالة async def تحمل العلامة slow، ويُطبَّق التخطّي عليها كأي اختبار آخر. ترى الـ hooks العناصر المجموعة؛ أما كون الدالة خلف أحدها غير متزامنة فشأن pytest-asyncio لا شأنها.

ثانياً، شغّل asyncio.gather أربعمئة استدعاء لـ fetch_price، ينام كل منها 0.01 ثانية، واستغرق التشغيل كله أقل بكثير من ثانية — لا أربع ثوانٍ. لقد انتظرت في الوقت نفسه. وهذا سبب وجود الكود غير المتزامن أصلاً، والاختبار يثبت أنه يتصرف كذلك.

ثالثاً، سطر makeini في run. فالتشغيل الداخلي ضمن pytester مشروع منفصل بإعداداته الخاصة، ويحمّل كل إضافة مثبّتة — بما فيها pytest-asyncio الذي يحذّر حين لا يُضبط asyncio_default_fixture_loop_scope. وإعطاء المشروع الداخلي هذا الإعداد الواحد يُبقي التقرير الخارجي نظيفاً. والدرس العام: تشغيل pytester يرى إضافاتك المثبّتة، لكنه لا يرى pyproject.toml الخاص بك.

وحين تنكسر الإضافة تقول هذه الاختبارات ذلك بدقة. هنا كُتب فحص التخطّي خطأً "slw"، فلم يُتخطَّ شيء:

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 والاختبار يفتقر إلى @pytest.mark.asyncio. أضف العلامة أو اضبط asyncio_mode = "auto".

requested an async fixture 'cart', with no plugin or hook that handled it fixture من نوع async def مزيّن بـ @pytest.fixture العادي في الوضع strict. استخدم @pytest_asyncio.fixture أو انتقل إلى الوضع auto.

AssertionError: assert <coroutine object fetch_price at 0x...> == 15.0، يليه RuntimeWarning: coroutine 'fetch_price' was never awaited اختبار def عادي استدعى دالة غير متزامنة من دون await، وقارن كائن الـ coroutine نفسه. اجعل الاختبار async def واكتب await fetch_price("pen").

PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? العلامة مستخدمة لكنها لم تُسجَّل قط. سجّلها في pytest_configure بـ config.addinivalue_line("markers", ...)، أو في الإعداد markers.

PluginValidationError: unknown hook 'pytest_addoptions' in plugin <module 'conftest' ...> دالة تبدأ بـ pytest_ لكنها ليست hook يعرفه pytest — هنا حرف s زائد. يفحص pytest كل اسم يبدأ بـ pytest_ في الإضافة، وهذا بالضبط ما يلتقط الخطأ الإملائي. طابق التهجئة مع مرجع الـ hooks.

ValueError: no option named '--runslow' طلبت config.getoption خياراً لم يُضفه أي pytest_addoption. فإما أن الاسم مختلف، وإما أن الـ hook pytest_addoption موجود في conftest.py لم يحمّله pytest مبكراً بما يكفي — أبقه في جذر اختباراتك.

pytest: error: unrecognized arguments: --run-slow الخيار في سطر الأوامر لا يطابق الخيار المسجّل. يدرج pytest --help كل خيار أضافته إضافاتك تحت "custom options".

ImportError: Error importing plugin "pytest_slowtests": No module named 'pytest_slowtests' يأخذ -p اسم وحدة، وتعذّر استيراد تلك الوحدة. أضف pythonpath = ["."] إلى الإعدادات، أو ثبّت الإضافة كحزمة.