الاختبارات غير المتزامنة و hooks وكتابة إضافة
اختبار الكود غير المتزامن باستخدام pytest-asyncio، ثم توسيع pytest عبر hooks في conftest.py — خيار --runslow وعلامة وسطر في رأس التقرير — وأخيراً تحويل ذلك إلى إضافة خاصة بك واختبارها باستخدام pytester.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك قطعة صغيرة من الكود غير المتزامن (asynchronous) — من النوع الذي يتحدث إلى الشبكة ولا يريد أن يبقى خاملاً وهو ينتظر.
prices.py:
import asyncio
PRICES = {"pen": 15.0, "notebook": 60.0}
async def fetch_price(item):
await asyncio.sleep(0.01) # stands in for a network call
if item not in PRICES:
raise KeyError(item)
return PRICES[item]تختبرها كما اختبرت كل شيء حتى الآن: دالة يبدأ اسمها بـ test_. وهي دالة async، لأن await لا يُسمح به إلا داخل دالة كهذه. وللتأكد من أن الاختبار يتحقق من شيء فعلاً، وُضع السعر المتوقع خاطئاً عن قصد:
test_prices.py:
from prices import fetch_price
async def test_pen_price():
assert await fetch_price("pen") == 999.0pytest -q:
F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
async def functions are not natively supported.
You need to install a suitable plugin for your async framework, for example:
- anyio
- pytest-asyncio
- pytest-tornasync
- pytest-trio
- pytest-twisted
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - Failed: async def functions are not n...
1 failed in 0.11sاقرأ رسالة الفشل بعناية. لا تذكر شيئاً عن 15.0 أو 999.0. فالـ 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:
pip install pytest-asyncioالتثبيت وحده لا يكفي. شغّل الاختبار نفسه مجدداً وسيكون الخرج مطابقاً حرفاً بحرف: async def functions are not natively supported. فـ pytest-asyncio يعمل افتراضياً في الوضع strict — لا يمسّ إلا الاختبارات التي سلّمتها إليه صراحة. وتسلّمه اختباراً بعلامة:
import pytest
from prices import fetch_price
@pytest.mark.asyncio
async def test_pen_price():
assert await fetch_price("pen") == 999.0F [100%]
=================================== FAILURES ===================================
________________________________ test_pen_price ________________________________
@pytest.mark.asyncio
async def test_pen_price():
> assert await fetch_price("pen") == 999.0
E assert 15.0 == 999.0
test_prices.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_prices.py::test_pen_price - assert 15.0 == 999.0
1 failed in 0.10sالآن يفشل للسبب الصحيح. assert 15.0 == 999.0 — عمل الـ coroutine، وأنتج await رقماً حقيقياً، وقارنه الـ assert. ورؤية الاختبار يفشل بالقيمة الخاطئة هي الدليل على أنه يعمل فعلاً. أصلح الرقم، وأضف اختباراً لمسار الخطأ وأنت هنا:
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.10sيعمل pytest.raises داخل الاختبار غير المتزامن تماماً كما يعمل في غيره؛ كل ما في الأمر أن await يوضع داخل كتلة with.
ثمة طريقة خاطئة أخرى تستحق المشاهدة، لأنها الأكثر شيوعاً: التهرّب من المسألة كلها بكتابة اختبار def عادي واستدعاء الدالة غير المتزامنة من دون 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. ويضيف بايثون في السطر الأخير أن الـ coroutine رُمي من دون أن يعمل قط. هذا على الأقل يفشل بصوت عالٍ. أما الصورة الخطرة فهي assert fetch_price("pen") من دون مقارنة — فكائن الـ coroutine قيمة صادقة (truthy)، فيُبلغ ذلك الاختبار 1 passed, 1 warning وهو لا يتحقق من شيء. أي اختبار لكود غير متزامن يجب أن يكون هو نفسه async def وأن يستخدم await.
asyncio_mode = "auto" و fixtures غير المتزامنة
كتابة @pytest.mark.asyncio فوق كل اختبار تصبح مملّة بسرعة. وفي مشروع يكون فيه اللاتزامن هو الحالة المعتادة، غيّر الوضع في pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"في الوضع auto يتولى pytest-asyncio كل اختبار async def وكل fixture من نوع async def من دون أي علامة. ويخبرك رأس التشغيل الكامل لـ pytest بالوضع المعمول به:
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=functionيمكن أن تكون الـ fixtures غير متزامنة أيضاً. هنا تُنشأ سلة (cart) وتُسلَّم إلى الاختبار ثم تُغلق بعده — مع await على جانبي yield:
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.11sيعمل yield كما كان في فصل الـ fixtures: كل ما قبله إعداد، وكل ما بعده تنظيف، والتنظيف يعمل حتى لو فشل الاختبار.
في الوضع strict تحتاج الـ fixture نفسها إلى decorator خاص بها، هو @pytest_asyncio.fixture من import pytest_asyncio. ومع @pytest.fixture العادي هناك لا يعرف pytest كيف يشغّلها فيتوقف عند الإعداد:
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:
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.07sيعمل pytest_addoption مرة واحدة قبل تحليل سطر الأوامر، ويأخذ parser.addoption الوسائط نفسها التي يأخذها argparse في بايثون. ثم إن request.config هو كائن إعدادات هذا التشغيل، و getoption تقرأ القيمة. ويظهر الخيار كذلك في pytest --help:
--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:
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. ولا يوجد 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 أولاً:
@pytest.mark.slow: a test that takes seconds, not millisecondsيعيد pytest_report_header(config) نصاً (أو قائمة نصوص) يطبعه pytest في أعلى التشغيل. وهو المكان المناسب لذكر أي شيء يحتاج قارئ سجل CI إلى معرفته. 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 =========================الاختبار المتخطّى بسبب واضح اختبار صادق: يقول الملخص ما الذي لم يعمل، ولماذا.
كيف يجد pytest الـ hooks
كل واحد من هذه الـ hooks يعيش في وحدة (module). ويجمع pytest هذه الوحدات — الإضافات — من عدة أماكن:
- إضافاته المدمجة. معظم pytest مكتوب على شكل إضافات:
-k، والعلامات، وtmp_path، وتقرير الطرفية. - الحزم المثبّتة التي تعلن نقطة دخول
pytest11. هكذا يحمّلpytest-asyncioوpytest-covوسائرها أنفسها بمجرد تثبيتها. ويدرجها سطرplugins:في الرأس. - الوحدات المذكورة بـ
-pفي سطر الأوامر أو فيaddopts. - ملفات
conftest.py، في جذر اختباراتك وفي أي مجلد فرعي (وconftest.pyالمجلد الفرعي لا ينطبق إلا على الاختبارات التي تحته). وبعض الـ hooks المبكرة، مثلpytest_addoption، لا تُعتمد إلا فيconftest.pyالموجود في الجذر.
لذا يمكن رفع conftest.py السابق كما هو. أعد تسميته إلى pytest_slowtests.py فيصبح إضافة. وحمّله بـ -p واسم الوحدة:
pytest -p pytest_slowtestsيجب أن تكون الوحدة قابلة للاستيراد. فإن كانت في جذر المشروع فأخبر pytest بذلك عبر pythonpath، ولتتجنب كتابة -p كل مرة ضعه في addopts:
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-p pytest_slowtests"ولمشاركتها بين المشاريع اجعلها حزمة وأعلن نقطة الدخول. يجب أن تُسمّى المجموعة pytest11؛ والاسم على اليسار هو اسم الإضافة، والقيمة على اليمين هي الوحدة:
[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:
============================= 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 الجذر):
pytest_plugins = ["pytester"]يعطي pytester كل اختبار مجلداً مؤقتاً فارغاً، مشروعاً صغيراً جديداً. يكتب pytester.makepyfile(...) ملف اختبار فيه، ويشغّل pytester.runpytest(...) الأداة pytest هناك بالوسائط التي تمررها، وفي النتيجة assert_outcomes(passed=..., skipped=..., failed=...) للتحقق من الأعداد، و result.stdout.fnmatch_lines([...]) للتحقق من أسطر الخرج (ويمكن استخدام * كحرف بدل). والنسخة الكاملة في المثال المتكامل أدناه.
مثال تطبيقي متكامل
مشروع صغير يجمع كل ما في هذا الفصل: كوداً غير متزامن، واختبارات غير متزامنة في الوضع auto، وإضافة الاختبارات البطيئة، واختبارات للإضافة.
shop/
├── pyproject.toml
├── prices.py
├── pytest_slowtests.py
└── tests/
├── test_plugin.py
└── test_prices.pyprices.py هو نفسه الذي في بداية الفصل، و pytest_slowtests.py هو conftest.py قسم --runslow بعد إعادة تسميته. 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ثلاثة أمور تستحق الملاحظة.
أولاً، تعمل الإضافة على الاختبارات غير المتزامنة أيضاً. فـ 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"، فلم يُتخطَّ شيء:
> 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 = ["."] إلى الإعدادات، أو ثبّت الإضافة كحزمة.
Step 4 of 6 — Predict
Check your understanding
الدالة 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 يعطي هذا الاختبار 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` من الاختبار
- Cتزيين الـ fixture بـ `@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
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
اكتب إضافة، pytest_budget.py، تعطي كل اختبار ميزانية زمنية.
- أضف خياراً
--budgetبقيمة افتراضية0.5(ثانية)؛ استخدمtype=floatفيparser.addoption. - وفّر fixture اسمه
budgetيعيد القيمة عبرrequest.config.getoption("--budget"). - سجّل علامة
networkفيpytest_configure، واطبعbudget: 0.5s(أو القيمة الممرَّرة) بـpytest_report_header. - في
pytest_collection_modifyitems، تخطَّ كل اختبار معلَّم بـnetworkحين يُمرَّر خيار ثانٍ هو--offline. - حمّلها بـ
-p pytest_budget، واكتب اختباراً غير متزامن في الوضع auto ينفّذawait asyncio.sleep(...)لمدة أقل منbudgetويتحقق بـ assert من أنه انتهى في الوقت.
ثم اختبر الإضافة بـ pytester: اختبار لكل خيار، مستخدماً assert_outcomes للأعداد و fnmatch_lines لرأس التقرير. وأخيراً اكسر الإضافة عن قصد — أعد تسمية pytest_report_header إلى pytest_report_headers — واقرأ ما يقوله pytest. رؤية هذا الخطأ مرة واحدة تجعل التعرّف عليه سهلاً إلى الأبد.
جرّب بنفسك قبل أن تكمل القراءة.
الحل
للمشروع الشكل نفسه الذي للمثال المتكامل:
budget/
├── pyproject.toml
├── pytest_budget.py
└── tests/
├── test_budget_plugin.py
└── test_timing.pypyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-p pytest_budget"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"pytest_budget.py:
"""Give every test a time budget, and let network tests be switched off."""
import pytest
def pytest_addoption(parser):
parser.addoption("--budget", type=float, default=0.5, help="seconds a test may take")
parser.addoption(
"--offline", action="store_true", default=False, help="skip tests marked network"
)
def pytest_configure(config):
config.addinivalue_line("markers", "network: the test needs a network connection")
def pytest_report_header(config):
return f"budget: {config.getoption('--budget')}s"
def pytest_collection_modifyitems(config, items):
if not config.getoption("--offline"):
return
skip_network = pytest.mark.skip(reason="--offline was given")
for item in items:
if "network" in item.keywords:
item.add_marker(skip_network)
@pytest.fixture
def budget(request):
return request.config.getoption("--budget")tests/test_timing.py:
import asyncio
import time
import pytest
async def test_finishes_within_budget(budget):
start = time.perf_counter()
await asyncio.sleep(budget / 5)
assert time.perf_counter() - start < budget
@pytest.mark.network
async def test_price_service_answers():
await asyncio.sleep(0.01) # stands in for a real request
assert Truetests/test_budget_plugin.py:
pytest_plugins = ["pytester"]
SAMPLE = """
import pytest
def test_reads_budget(budget):
assert budget == EXPECTED
@pytest.mark.network
def test_network():
assert True
"""
def run(pytester, *args, expected=0.5):
# A fresh inner project: its own config, our sample tests, our plugin.
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function\n")
pytester.makepyfile(SAMPLE.replace("EXPECTED", repr(expected)))
return pytester.runpytest("-p", "pytest_budget", *args)
def test_default_budget(pytester):
result = run(pytester)
result.assert_outcomes(passed=2)
def test_budget_option_is_a_float(pytester):
result = run(pytester, "--budget", "2", expected=2.0)
result.assert_outcomes(passed=2)
def test_offline_skips_network_tests(pytester):
result = run(pytester, "--offline")
result.assert_outcomes(passed=1, skipped=1)
def test_header_shows_budget(pytester):
result = run(pytester, "--budget", "1.5", expected=1.5)
result.stdout.fnmatch_lines(["budget: 1.5s"])
def test_marker_is_registered(pytester):
result = run(pytester, "--markers")
result.stdout.fnmatch_lines(["@pytest.mark.network: *"])pytest -v (مع اختصار الرأس):
budget: 0.5s
collecting ... collected 7 items
tests/test_budget_plugin.py::test_default_budget PASSED [ 14%]
tests/test_budget_plugin.py::test_budget_option_is_a_float PASSED [ 28%]
tests/test_budget_plugin.py::test_offline_skips_network_tests PASSED [ 42%]
tests/test_budget_plugin.py::test_header_shows_budget PASSED [ 57%]
tests/test_budget_plugin.py::test_marker_is_registered PASSED [ 71%]
tests/test_timing.py::test_finishes_within_budget PASSED [ 85%]
tests/test_timing.py::test_price_service_answers PASSED [100%]
============================== 7 passed in 0.31s ===============================pytest -q --offline -rs:
......s [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_timing.py:13: --offline was given
6 passed, 1 skipped in 0.57sومع إعادة تسمية الـ hook إلى pytest_report_headers لا يعمل شيء على الإطلاق:
INTERNALERROR> pluggy._manager.PluginValidationError: unknown hook 'pytest_report_headers' in plugin <module 'pytest_budget' from '/home/you/budget/pytest_budget.py'>
no tests ran in 0.00sسبب كل قرار:
type=float. من دونه يصل--budget 2نصاً"2"، فيرفعbudget / 5الخطأTypeError. ويوجدtest_budget_option_is_a_floatلتثبيت هذا بالذات: يتحقق الاختبار الداخلي منbudget == 2.0، والنص"2"لا يساويه.- الـ fixture يعيش في الإضافة. تستطيع وحدة الإضافة تعريف fixtures كما يفعل
conftest.py، فكل من يحمّل-p pytest_budgetيحصل علىbudgetأيضاً. تطلب الاختباراتbudget؛ ولا يعرف أيٌّ منها أن خلفه خياراً. - قرار التخطّي يُتخذ عند الجمع، لا داخل الاختبار. لا يحوي
test_price_service_answersأيif. hook واحد يحمل القاعدة، وأي اختبارnetworkجديد يُشمل لحظة كتابته. - اختبار التوقيت يدّعي هامشاً لا رقماً. ينام خُمس الميزانية ويتحقق من أنه انتهى ضمن الميزانية كلها. وهذا يصمد على جهاز CI بطيء؛ أما "استغرق 0.1 ثانية" فلا.
- دالة مساعدة واحدة
run. كل اختبارpytesterيبني المشروع الداخلي نفسه، لذا يُكتب الإعداد مرة واحدة — بما فيه سطرmakeiniالذي يُسكتpytest-asyncioفي التشغيل الداخلي. وقد نسيت المسوّدة الأولى لهذا الحل استخدام الدالة المساعدة فيtest_marker_is_registered، فأبلغ التشغيل الخارجي فوراً عن1 warningمن ذلك التشغيل الداخلي الوحيد. - الأعداد للسلوك، والأسطر للخرج. يتحقق
assert_outcomesمما تفعله الإضافة بالاختبارات؛ ويتحققfnmatch_linesمما تطبعه. والـ*في"@pytest.mark.network: *"يمنع الاختبار من الانكسار إن أُعيدت صياغة وصف العلامة. - الـ hook المكسور. يطابق pytest كل دالة تبدأ بـ
pytest_مع الـ hooks التي يعرفها، ويرفض البدء إن لم تطابق إحداها. فالخطأ الإملائي في اسم hook لا يمكن أن يتحول بصمت إلى لا شيء — وكانت اختباراتpytesterستلتقطه على أي حال، لأنtest_header_shows_budgetيبحث عن سطر لن يُطبع أبداً.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz