العلامات و skip و xfail — أي اختبار يعمل وأين
أخبر pytest بما يحتاجه كل اختبار وبما تتوقعه منه عبر skip و skipif و importorskip و xfail؛ ومعنى strict=True و raises= و XPASS؛ وسجّل علاماتك الخاصة واختر بـ -m؛ إضافة إلى fixtures ذات المعاملات و fixtures المصنع.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
مشروع صغير اسمه shop، فيه وحدة واحدة وملفا اختبار. money.py:
def parse_price(text: str) -> float:
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)يفحص test_money.py هذه الوحدة بقيم بسيطة. أما test_export.py فيقرأ الأسعار من YAML، وهذا يحتاج إلى الحزمة الخارجية yaml:
import yaml
from money import parse_price
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0على جهاز لم تُثبَّت فيه yaml، يقول pytest -q:
==================================== ERRORS ====================================
_______________________ ERROR collecting test_export.py ________________________
ImportError while importing test module '/home/you/shop/test_export.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_export.py:1: in <module>
import yaml
E ModuleNotFoundError: No module named 'yaml'
=========================== short test summary info ============================
ERROR test_export.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sاقرأ السطرين الأخيرين. Interrupted. الاختباران السليمان في test_money.py لم يُشغَّلا أصلاً. ملف واحد لم يستطع العمل على هذا الجهاز أسقط التشغيل كله معه.
هذا هو الواقع في أي مشروع يعيش مدة كافية. بعض الاختبارات تحتاج مكتبة اختيارية. وبعضها لا معنى له إلا على Linux. وبعضها يحتاج مفتاح API لا يملكه إلا خادم CI. وبعضها يصف خطأً برمجياً يعرفه الجميع ولم يصلحه أحد بعد. وبعضها يستغرق دقيقة كاملة، ولا تريد أن تنتظر دقيقة في كل مرة تحفظ فيها ملفاً.
حذف هذه الاختبارات خطأ، وكذلك تعطيلها بالتعليقات. ما تريده هو أن تخبر pytest بما يحتاجه كل اختبار وبما تتوقعه منه، ثم تترك لـ pytest أن يقرر في كل تشغيل ماذا يفعل به. والأداة لذلك هي العلامة (marker).
بنهاية هذا الفصل ستكون قادراً على
- تخطي اختبار دائماً، أو بشرط، أو من داخل الاختبار نفسه، مع إبقاء السبب ظاهراً
- تخطي ملف كامل عند غياب حزمة اختيارية باستخدام
pytest.importorskip - وسم خطأ معروف بـ
xfail، وجعله دقيقاً بـstrict=Trueوraises= - قراءة أحرف التقرير
sوxوX، وطلب التفاصيل بـ-rsو-rxو-rA - ابتكار علاماتك الخاصة وتسجيلها، واصطياد الأخطاء الإملائية بـ
--strict-markers - اختيار الاختبارات بـ
-m "not slow"و-m "slow and db" - كتابة fixture يشغّل كل اختبار عدة مرات، و fixture يعيد دالة
المتطلبات المسبقة: المحاكاة (mocking).
قبل أن تكتب الاختبار
العلامات لا تتعلق بـ ما يفحصه الاختبار — فقد تناولت الفصول السابقة ذلك. إنها تتعلق بسؤالين تجيب عنهما قبل كتابة الاختبار، ومعظم الناس لا يجيبون عنهما إلا بعد أن يتحول تشغيلٌ ما إلى الأحمر لسبب خاطئ.
1. ماذا يحتاج هذا الاختبار كي يعمل أصلاً؟ حزمة ليست اعتمادية إلزامية، أو نظام تشغيل معيّن، أو إصدار من بايثون، أو متغير بيئة يحمل سراً، أو قاعدة بيانات، أو شبكة، أو وقتاً طويلاً. كل واحد من هذه شرط مسبق. فإذا غاب، لم يعد لدى الاختبار ما يقوله: فهو لم ينجح، ولم يفشل.
2. ماذا أتوقع منه اليوم؟ في العادة، أن ينجح. لكن إن كان في الكود خطأ معروف وأنت تكتب الاختبار الذي يصف السلوك الصحيح، فأنت تتوقع أن يفشل — وأن يفشل بطريقة محددة.
الإجابات هي التي تحدد العلامة، فاكتبها أولاً. خطة مشروع shop تبدو هكذا:
| الحالة | المدخل / الوضع | ما يحتاجه | النتيجة المتوقعة | العلامة | |---|---|---|---|---| | سعر عادي | "1,250.50" | لا شيء | 1250.5 | لا شيء | | تصدير YAML | "pen: '15.00'" | حزمة yaml | 15.0 حيث تكون مثبتة | pytest.importorskip("yaml") | | مسار POSIX | "data", "prices.csv" | ليس Windows | "data/prices.csv" | skipif(sys.platform == "win32") | | أسعار مباشرة | الـ API الحقيقي | ضبط RATES_API_KEY | سعر صرف، فقط حيث يوجد المفتاح | pytest.skip() داخل الاختبار | | رمز العملة | "$1,250" | لا شيء | 1250.0 — اليوم ValueError (الخطأ #42) | xfail(raises=ValueError, strict=True) | | أسعار كثيرة | 1000 نص | ثانية أو أكثر | المجموع الصحيح | slow (خاصة) |
اقرأ الجدول عموداً عموداً. عمود ما يحتاجه يُنتج حالات التخطي (skip). عمود النتيجة المتوقعة يُنتج حالات xfail. والوقت المستغرق يُنتج علامة خاصة تتيح لك اختيار ما تشغّله.
ما يجب أن يكون جاهزاً قبل أن يعمل أي من هذا:
- بيئة افتراضية مثبت فيها
pytest، وmoney.pyقابل للاستيراد من الاختبارات (شغّل من جذر المشروع كما في الفصول السابقة)؛ - ملف
pyproject.tomlفي جذر المشروع، لأن العلامات الخاصة تُسجَّل فيه؛ - الخطأ مدوَّناً في مكان ما برقم، كي يشير إليه
reason=.
وما لا يجب وسمه:
- لا تتخطَّ اختباراً لأنه يفشل ولا تعرف السبب. التخطي يخفي الفشل عن كل تشغيل لاحق. إما أن تصلحه، أو تفهمه بما يكفي لتكتب
xfail(raises=...)مع سبب. - لا تضع
xfailعلى اختبار متقلّب (flaky). الاختبار الذي ينجح أحياناً سيظهرXفي يوم وxفي يوم آخر، ومعstrict=Trueسيفشل عشوائياً. التقلّب خطأ في الاختبار نفسه؛ أصلحه بدلاً من ذلك. - لا تضع العلامة
slowعلى كل شيء. إذا استُبعد معظم الحزمة في كل تشغيل، فإن التشغيل اليومي لا يفحص شيئاً. ضع العلامة على الاختبارات القليلة التي تكلّف ثوانيَ فعلاً. - لا تختبر أن pytest يتخطى بشكل صحيح. أنت تختبر
money.py، لا pytest. العلامة إعداد؛ وتقرير التشغيل دليل كافٍ على أنها تعمل.
بقية الفصل تنفّذ هذا الجدول، صفاً صفاً.
skip — ليس هذا الاختبار، وهذا هو السبب
أولاً الطريقة المعتادة قبل أن يعرف أحد العلامات — return مبكر:
import os
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
return
assert False, "would call the real API here". [100%]
1 passed in 0.07sPassed. الاختبار لم يفحص شيئاً على الإطلاق، والتقرير يقول إنه نجح. على حاسوب لا يملك المفتاح، هذه كذبة تُقال في كل تشغيل، ولا سبيل إلى تمييز هذه النقطة الخضراء عن نقطة حقيقية. الحل ليس تجنب الشرط، بل الإبلاغ عنه بأمانة — كتخطٍّ.
أبسط علامة هي مُزخرِف (decorator) يقول "لا تشغّل هذا"، ومعه سبب:
import os
import sys
import pytest
from money import parse_price, split_bill
@pytest.mark.skip(reason="rounding rules not agreed yet")
def test_split_rounding():
assert split_bill(100.0, 3) == 33.34
@pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX path")
def test_posix_path():
assert os.path.join("data", "prices.csv") == "data/prices.csv"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="needs Python 3.13")
def test_new_feature():
assert parse_price("10") == 10.0
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
assert False, "would call the real API here"s.ss [100%]
1 passed, 3 skipped in 0.09sأربعة اختبارات، وأربع طرق مختلفة لاتخاذ القرار:
@pytest.mark.skip(reason=...)— يُتخطى دائماً. لا يُنفَّذ جسمه أبداً.@pytest.mark.skipif(condition, reason=...)— يُتخطى عندما يكون الشرط صحيحاً. يُقيَّم الشرط عند جمع الملف (collection). جرى هذا التشغيل على Linux، لذا شُغّلtest_posix_pathونجح؛ وكان إصدار بايثون 3.12، لذا تُخطّيtest_new_feature.pytest.skip("...")المستدعاة داخل الاختبار — يُتخذ القرار أثناء تشغيل الاختبار. استخدمها حين لا تعرف إلا في منتصف الطريق، مثلاً بعد النظر في البيئة أو فيما أعاده fixture.
كل s في سطر التقدم اختبار متخطّى. والتخطي ليس فشلاً: يبقى التشغيل أخضر.
لكن الأسباب لا تظهر في أي مكان. افتراضياً يلخّص pytest حالات الفشل والأخطاء فقط. اطلب حالات التخطي بـ -rs:
s.ss [100%]
=========================== short test summary info ============================
SKIPPED [1] test_skips.py:9: rounding rules not agreed yet
SKIPPED [1] test_skips.py:19: needs Python 3.13
SKIPPED [1] test_skips.py:26: RATES_API_KEY is not set
1 passed, 3 skipped in 0.10sلهذا يهمّ reason. بعد ثلاثة أشهر، كلمة "skipped" وحدها لن تخبر أحداً هل يمكن إعادة تفعيل الاختبار.
importorskip — حين يحتاج ملف كامل إلى حزمة
لنعد إلى test_export.py. استبدل import yaml بهذا:
import pytest
from money import parse_price
yaml = pytest.importorskip("yaml")
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0.. [100%]
=========================== short test summary info ============================
SKIPPED [1] test_export.py:5: could not import 'yaml': No module named 'yaml'
2 passed, 1 skipped in 0.08sتحاول pytest.importorskip("yaml") الاستيراد. إن نجح أعادت الوحدة، ولهذا تُسند إلى yaml. وإن فشل تُخطّي الملف كله، وخطأ الاستيراد هو السبب. والاختباران الآخران يعملان الآن. ثبّت yaml وسيعمل الملف نفسه كاملاً، دون أي تغيير.
xfail — خطأ تعرفه
التخطي يقول "هذا الاختبار لا يمكن تشغيله هنا". وهناك عبارة ثانية مختلفة: "هذا الاختبار يعمل، ويُتوقع أن يفشل، لأن في الكود خطأً معروفاً".
في parse_price خطأ من هذا النوع. فهي لا تتعامل مع رمز العملة:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped")
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0
@pytest.mark.xfail(reason="spaces as thousands separators")
def test_parse_with_spaces():
assert parse_price("1 250") == 1250.0pytest -q -rx:
xx [100%]
=========================== short test summary info ============================
XFAIL test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
XFAIL test_xfail.py::test_parse_with_spaces - spaces as thousands separators
2 xfailed in 0.08sعلى خلاف الاختبار المتخطّى، اختبار xfail يعمل فعلاً. لقد فشل كما تنبأنا، فيظهر x صغيراً ويبقى التشغيل أخضر. صار الاختبار الآن سجلاً مكتوباً للخطأ، قابعاً داخل الحزمة، لا ملاحظة في رأس أحدهم.
لماذا لا نتخطاه ببساطة؟ لأن الاختبار المتخطّى لا يعمل أبداً، فلا يستطيع أن يخبرك بشيء — لا حين يُصلَح الخطأ، ولا حين يتغير الكود فيفشل بطريقة جديدة. أما اختبار xfail فيعمل في كل مرة، وتُقارَن نتيجته بما توقعته. التخطي لحالة "لا يمكن تشغيله هنا"؛ و xfail لحالة "يعمل، وأعرف ما سيقوله".
الآن يصلح أحدهم الخطأ #42 ويعدّل money.py ليحذف $:
Xx [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
1 xfailed, 1 xpassed in 0.12sحرف X كبير، XPASS: "كان يُتوقع أن يفشل، لكنه نجح". والتشغيل ما زال أخضر — رمز الخروج 0. لا أحد ينظر إلى تشغيل أخضر، فتبقى العلامة، ويواصل الاختبار ادعاء خطأ لم يعد موجوداً.
strict=True — اجعل الخبر السار مسموعاً
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped", strict=True)
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0F [100%]
=================================== FAILURES ===================================
____________________________ test_parse_with_symbol ____________________________
[XPASS(strict)] bug #42: currency symbol is not stripped
=========================== short test summary info ============================
FAILED test_xfail.py::test_parse_with_symbol - [XPASS(strict)] bug #42: curre...
1 failed in 0.10sمع strict=True، يصبح النجاح غير المتوقع فشلاً. يبدو هذا معكوساً، وهو بالضبط ما تريده: يُطلب ممن أصلح الخطأ أن يزيل العلامة، فيتحول الاختبار إلى اختبار عادي يحرس الإصلاح من ذلك اليوم فصاعداً. وإن أردت هذا لكل xfail في المشروع، فاضبط xfail_strict = true في إعدادات pytest.
raises= — افشل للسبب الصحيح
اختبار xfail بلا وسائط يقبل أي فشل. وهذا يشمل الحالات التي لم تقصدها:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42")
def test_loose():
assert parse_prise("$1,250") == 1250.0
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
assert parse_prise("$1,250") == 1250.0xF [100%]
=================================== FAILURES ===================================
_________________________________ test_precise _________________________________
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
> assert parse_prise("$1,250") == 1250.0
^^^^^^^^^^^
E NameError: name 'parse_prise' is not defined
test_raises.py:13: NameError
=========================== short test summary info ============================
XFAIL test_raises.py::test_loose - bug #42
1 failed, 1 xfailed in 0.09sفي الاختبارين الخطأ الإملائي نفسه، parse_prise. ابتلع test_loose الخطأ NameError وأبلغ بـ x هادئ — وكان سيبقى "متوقَّع الفشل" إلى الأبد، دون أن يختبر parse_price قط. أما test_precise فقد صرّح بالفشل الذي يتوقعه، أي ValueError الذي يرفعه float("$1250")، فيُبلَّغ عن أي استثناء آخر على أنه فشل حقيقي. صحّح الخطأ الإملائي وسيصبح كلاهما x.
أحرف التقرير
يستخدم سطر التقدم حرفاً واحداً لكل اختبار، ويأخذ -r الأحرف نفسها لاختيار ما يدخل في الملخص:
| الحرف | المعنى | يظهر في الملخص بـ | |---|---|---| | . | نجح | -rp | | F | فشل | -rf (مفعّل افتراضياً) | | E | خطأ في الإعداد أو التنظيف | -rE (مفعّل افتراضياً) | | s | تُخطّي | -rs | | x | xfailed — فشل كما هو متوقع | -rx | | X | xpassed — نجح على غير المتوقع | -rX |
يمكن جمع الأحرف: -rsx يعرض حالات التخطي و xfail معاً. و -rA يعرض كل شيء، بما في ذلك الناجحة:
..xs [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_params.py::test_parse_price[15-15.0]
PASSED test_params.py::test_parse_price[1,250.50-1250.5]
SKIPPED [1] test_params.py:6: space separators not decided
XFAIL test_params.py::test_parse_price[$1,250-1250.0] - bug #42
2 passed, 1 skipped, 1 xfailed in 0.08sعلاماتك الخاصة
skip و xfail تغيّران ما يفعله pytest بالاختبار. لكن العلامة يمكن أن تكون مجرد ملصق، باسم تبتكره، لا يغيّر شيئاً حتى تستخدمه لاختيار الاختبارات. لنفترض أن بعض الاختبارات بطيئة، وبعضها يحتاج قاعدة بيانات:
import time
import pytest
from money import parse_price, split_bill
def test_parse_plain():
assert parse_price("1,250.50") == 1250.5
@pytest.mark.slow
def test_parse_many():
time.sleep(1)
assert sum(parse_price("1.5") for _ in range(1000)) == 1500.0
@pytest.mark.slow
@pytest.mark.db
def test_bill_from_database():
time.sleep(1)
assert split_bill(300.0, 3) == 100.0
@pytest.mark.db
def test_bill_lookup():
assert split_bill(90.0, 3) == 30.0.... [100%]
=============================== warnings summary ===============================
test_marks.py:12
/home/you/shop/test_marks.py:12: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:18
/home/you/shop/test_marks.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:19
/home/you/shop/test_marks.py:19: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
test_marks.py:25
/home/you/shop/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 4 warnings in 2.09sنجحت الأربعة، ويشتكي pytest من كل استخدام. يقبل pytest pytest.mark.<أي شيء> — إذ تُنشأ الخاصية في لحظتها — فلا يستطيع التمييز بين slow وبين خطأ إملائي في اسم آخر. ولذلك يطلب منك أن تصرّح بالأسماء التي تقصدها.
تسجيل العلامات
توضع القائمة في pyproject.toml، نص واحد لكل علامة، الاسم قبل النقطتين والوصف بعدهما:
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
].... [100%]
4 passed in 2.11sاختفت التحذيرات، وصار pytest --markers يسردها للشخص التالي الذي ينضم إلى المشروع:
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'
@pytest.mark.db: needs the databaseالاختيار بـ -m
الآن تؤتي الملصقات ثمارها. يأخذ -m تعبيراً على أسماء العلامات، مع and و or و not والأقواس:
$ pytest -q -m "not slow"
.. [100%]
2 passed, 2 deselected in 0.07s
$ pytest -q -m "slow and db"
. [100%]
1 passed, 3 deselected in 1.08s
$ pytest -q -m "slow or db"
... [100%]
3 passed, 1 deselected in 2.07sDeselected (مستبعد) ليس skipped (متخطّى). الاختبار المتخطّى اختير ثم امتنع عن العمل؛ أما المستبعد فلم يُختر أصلاً. -m "not slow" هو التشغيل الذي تجريه عند كل حفظ؛ والتشغيل الكامل لـ CI وقبل الدفع (push).
--strict-markers — الخطأ الإملائي يصبح خطأً فعلياً
من السهل أن يفوتك التحذير. هذا اختبار أضافه أحدهم لاحقاً، مع خطأ إملائي في العلامة:
@pytest.mark.slwo
def test_split_large_group():
time.sleep(1)
assert split_bill(1000.0, 8) == 125.0... [100%]
=============================== warnings summary ===============================
test_marks.py:30
/home/you/shop/test_marks.py:30: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
3 passed, 2 deselected, 1 warning in 1.07sطُلب -m "not slow"، ومع ذلك استغرق التشغيل ثانية: الاختبار ذو الاسم الخاطئ ليس slow، فشُغّل. مع --strict-markers، توقِف العلامة غير المسجلة عملية الجمع بدلاً من ذلك:
==================================== ERRORS ====================================
________________________ ERROR collecting test_marks.py ________________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_marks.py - Failed: 'slwo' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sلا تريد كتابة هذا الخيار كل مرة، فضعه في addopts:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
]pytestmark — وسم ملف كامل
حين يحتاج كل اختبار في ملف إلى قاعدة البيانات، يكون وضع المُزخرِف على كل واحد تكراراً. متغير على مستوى الوحدة باسم pytestmark ينطبق على كل اختبار في الملف:
import pytest
from money import split_bill
pytestmark = pytest.mark.db
def test_bill_for_two():
assert split_bill(50.0, 2) == 25.0
def test_bill_for_four():
assert split_bill(80.0, 4) == 20.0$ pytest -q -m db
.. [100%]
2 passed, 4 deselected in 0.07sيجب أن يكون الاسم pytestmark حرفياً. ويمكن أن يكون قائمة أيضاً، pytestmark = [pytest.mark.db, pytest.mark.slow].
علامات على حالة واحدة من parametrize
العلامة على اختبار ذي معاملات تنطبق على كل حالاته. لوسم حالة واحدة، غلّفها بـ pytest.param(..., marks=...):
import pytest
from money import parse_price
@pytest.mark.parametrize(
"text, expected",
[
("15", 15.0),
("1,250.50", 1250.5),
pytest.param(
"$1,250", 1250.0,
marks=pytest.mark.xfail(raises=ValueError, reason="bug #42"),
),
pytest.param(
"1 250", 1250.0,
marks=pytest.mark.skip(reason="space separators not decided"),
),
],
)
def test_parse_price(text, expected):
assert parse_price(text) == expectedtest_params.py::test_parse_price[15-15.0] PASSED [ 25%]
test_params.py::test_parse_price[1,250.50-1250.5] PASSED [ 50%]
test_params.py::test_parse_price[$1,250-1250.0] XFAIL (bug #42) [ 75%]
test_params.py::test_parse_price[1 250-1250.0] SKIPPED (space separa...) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.11s ====================يعيش الخطأ المعروف بجوار الحالات التي تعمل، في الجدول نفسه، ويوم يُصلَح تحذف غلاف pytest.param واحداً.
خطوة أبعد: fixtures تتنوّع، و fixtures تبني
نمطان من أنماط fixtures لم يجدا مكاناً في فصول الـ fixtures، والعلامات لحظة مناسبة لهما، لأن كليهما يضاعف أو يشكّل الاختبارات التي تعلمت للتو كيف تختارها.
fixture ذو معاملات
parametrize ينوّع مدخلات اختبار واحد. وأحياناً تريد أن يعمل كل اختبار يستخدم fixture مرة لكل قيمة من قيمه. أعطِ @pytest.fixture قائمة params، واقرأ القيمة الحالية من request.param:
import pytest
from money import parse_price
@pytest.fixture(params=["1250", "1,250", "1,250.00"], ids=["plain", "comma", "decimals"])
def price_text(request):
return request.param
def test_parses_to_1250(price_text):
assert parse_price(price_text) == 1250.0
def test_is_positive(price_text):
assert parse_price(price_text) > 0test_fixtures.py::test_parses_to_1250[plain] PASSED [ 16%]
test_fixtures.py::test_parses_to_1250[comma] PASSED [ 33%]
test_fixtures.py::test_parses_to_1250[decimals] PASSED [ 50%]
test_fixtures.py::test_is_positive[plain] PASSED [ 66%]
test_fixtures.py::test_is_positive[comma] PASSED [ 83%]
test_fixtures.py::test_is_positive[decimals] PASSED [100%]
============================== 6 passed in 0.08s ===============================اختباران، ثلاث قيم، ست عمليات تشغيل. request هو fixture مدمج يصف الاختبار الذي يُجهَّز؛ ولا توجد request.param إلا حين يملك الـ fixture قائمة params. تسمّي ids كل قيمة في التقرير — ومن دونها يبني pytest معرّفاً من القيمة نفسها، وهو صعب القراءة للقيم الطويلة أو غير القابلة للطباعة. ويمكن أن تحتوي القائمة على pytest.param(...) مع marks= أيضاً، تماماً كما في parametrize.
أين يستحق هذا مكانه: مجموعة الاختبارات نفسها على عدة واجهات خلفية، أو عدة صيغ ملفات، أو عدة عملات. أضف قيمة واحدة إلى params، وسيغطيها كل اختبار يستخدم الـ fixture.
fixture المصنع (factory)
لا يستطيع الـ fixture أن يأخذ وسائط من الاختبار؛ فالاختبار يذكر اسمه فقط. فماذا تفعل حين يحتاج الاختبار إلى كائنين مبنيين بطريقتين مختلفتين؟ أعِد دالة:
import pytest
from money import parse_price
@pytest.fixture
def make_receipt(tmp_path):
def _make(name, lines):
path = tmp_path / f"{name}.txt"
path.write_text("\n".join(lines))
return path
return _make
def receipt_total(path):
return sum(parse_price(line) for line in path.read_text().splitlines())
def test_two_receipts(make_receipt):
lunch = make_receipt("lunch", ["15", "1,250.50"])
taxi = make_receipt("taxi", ["300"])
assert receipt_total(lunch) + receipt_total(taxi) == 1565.5
def test_empty_receipt(make_receipt):
empty = make_receipt("empty", [])
assert receipt_total(empty) == 0.. [100%]
2 passed in 0.08sما زال الـ fixture يعمل مرة لكل اختبار، وما زال يحصل على الـ fixtures الخاصة به (هنا tmp_path، الذي يتولى أيضاً حذف الملفات). ما يسلّمه هو _make، ويستدعيها الاختبار كلما شاء، بالوسائط التي يشاء. الاسم make_... هو العرف المتّبع، ويخبر القارئ بنظرة أن هذا الـ fixture دالة تُستدعى، لا قيمة جاهزة.
مثال تطبيقي متكامل
كل شيء معاً في مشروع واحد. pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: talks to an outside service",
]test_checkout.py، مع money.py نفسه — وما زال فيه الخطأ #42:
import os
import time
import pytest
from money import parse_price, split_bill
# Every test that asks for amount_text runs once per format.
@pytest.fixture(params=["300", "300.00", "1,250"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
# A factory: the fixture hands back a function the test can call many times.
@pytest.fixture
def make_share():
def _make(text, people):
return split_bill(parse_price(text), people)
return _make
def test_share_is_positive(amount_text, make_share):
assert make_share(amount_text, 3) > 0
@pytest.mark.parametrize(
"text, people, share",
[
("300", 3, 100.0),
("90", 4, 22.5),
pytest.param(
"$300", 3, 100.0,
marks=pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #42"),
),
],
)
def test_share(make_share, text, people, share):
assert make_share(text, people) == share
@pytest.mark.slow
def test_many_group_sizes(make_share):
time.sleep(1) # stands in for real, slow work
assert all(make_share("300", n) > 0 for n in range(1, 1000))
@pytest.mark.network
def test_live_exchange_rate():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")التشغيل اليومي، مع الأسباب:
$ pytest -q -rsx -m "not slow"
.....xs [100%]
=========================== short test summary info ============================
SKIPPED [1] test_checkout.py:52: RATES_API_KEY is not set
XFAIL test_checkout.py::test_share[$300-3-100.0] - bug #42
5 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09sالبطيء وحده، ثم التشغيل دون اتصال الذي يستبعد الاختبارات البطيئة واختبارات الشبكة معاً:
$ pytest -q -m slow
. [100%]
1 passed, 7 deselected in 1.07s
$ pytest -q -m "not slow and not network"
.....x [100%]
5 passed, 2 deselected, 1 xfailed in 0.08sعُدّها: fixture بثلاث قيم يعطي ثلاث عمليات تشغيل لـ test_share_is_positive، وثلاث حالات لـ test_share، واختبار بطيء واحد، واختبار شبكة واحد — ثمانية في المجموع، وهذا ما يساويه 1 passed, 7 deselected.
وكل نتيجة تقول الحقيقة. اختبار الشبكة لا يتظاهر بالنجاح دون مفتاح؛ بل يقول إنه تُخطّي ولماذا. والخطأ #42 داخل الحزمة، ولأنه strict مع raises=ValueError، فيوم يُصلَح سيتحول التشغيل إلى الأحمر ويطلب إزالة العلامة.
الأخطاء الشائعة وحلولها
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? العلامة غير مسجلة. أضفها إلى markers في pyproject.toml. وإن كان الاسم خطأً إملائياً فعلاً فصحّحه — وأضف --strict-markers إلى addopts كي يكون الخطأ التالي خطأً لا تحذيراً.
`'slwo' not found in markers configuration option` هذا --strict-markers يؤدي عمله: الملف يستخدم علامة غير موجودة في القائمة. صحّح الإملاء، أو سجّل الاسم الجديد.
8 deselected و no tests ran، ورمز الخروج 5 لم يطابق -m شيئاً. الاسم الخاطئ إملائياً في تعبير -m ليس خطأً، حتى مع --strict-markers — فـ -m sloww ببساطة لا يختار أي اختبار. قارن الاسم بمخرجات pytest --markers.
ERROR: Wrong expression passed to '-m': not slow and: at column 13: expected not OR left parenthesis OR identifier; got end of input تعبير -m غير مكتمل. ضع التعبير كله بين علامتي اقتباس، وتأكد أن لكل and/or شيئاً على جانبيه.
Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. skipif(sys.platform == "linux") دون reason=. الشرط المنطقي لا يقول شيئاً عن السبب، لذا يصرّ pytest على ذكره.
`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` استُدعيت pytest.skip("...") في المستوى الأعلى من الملف. لحزمة مفقودة استخدم pytest.importorskip؛ وإلا فاكتب pytest.skip("...", allow_module_level=True)، أو استخدم skipif داخل pytestmark.
[XPASS(strict)] bug #42 خبر سار يُبلَّغ عنه كفشل: لقد أُصلح الخطأ. أزل علامة xfail واحتفظ بالاختبار.
اختبار xfail يبقى x حتى بعد إصلاح الخطأ إنه يفشل لسبب آخر — غالباً خطأ إملائي في الاختبار نفسه. أضف raises= مع الاستثناء الذي تتوقعه فعلاً، وسيظهر أي شيء آخر على أنه فشل حقيقي.
AttributeError: 'SubRequest' object has no attribute 'param' الـ fixture يقرأ request.param، لكن ليس لديه قائمة params=، ولم يُعطَ معاملات بأي طريقة أخرى. أضف params=[...] إلى @pytest.fixture.
fixture 'name' not found، مشيراً إلى fixture كُتب fixture على شكل def make_user(name): على أمل أن يمرر الاختبار name. وسائط الـ fixture هي fixtures أخرى، فذهب pytest يبحث عن fixture اسمه name. حوّله إلى مصنع: دالة داخلية تأخذ name، ويعيدها الـ fixture.
Step 4 of 6 — Predict
Check your understanding
ما سطر التقدم والسطر الأخير في مخرجات pytest -q؟ (تذكّر أن round(2.675, 2) يعطي 2.67.)
import pytest
@pytest.mark.skip(reason="not ready")
def test_export():
assert False
def test_total():
assert 2 + 2 == 4
@pytest.mark.xfail(reason="bug #7")
def test_rounding():
assert round(2.675, 2) == 2.68- As.F 1 passed, 1 skipped, 1 failed
- B..x 2 passed, 1 xfailed
- Cs.x 1 passed, 1 skipped, 1 xfailed
- Ds.X 1 passed, 1 skipped, 1 xpassed
العلامة slow مسجلة في pyproject.toml، و --strict-markers غير مفعّل. تشغّل pytest -m "not slow" ومع ذلك يستغرق التشغيل خمس ثوانٍ. لماذا؟
import time
import pytest
@pytest.mark.slwo
def test_big_report():
time.sleep(5)
assert True- A`-m` لا يعمل إلا مع العلامات المدمجة مثل `skip`
- Bالعلامة مكتوبة خطأً `slwo`، فالاختبار ليس `slow` ويُختار؛ و pytest يكتفي بتحذير
- C`time.sleep` يجعل pytest يتجاهل العلامات
- D`-m "not slow"` يشغّل الاختبارات البطيئة فقط
الخطأ #12 أُصلح بالفعل، فالـ assert صحيح. بماذا يُبلغ pytest -q؟
import pytest
@pytest.mark.xfail(strict=True, reason="bug #12")
def test_upper():
assert "taka".upper() == "TAKA"- A`X` — 1 xpassed، رمز الخروج 0
- B`x` — 1 xfailed، رمز الخروج 0
- C`s` — يُتخطى الاختبار لأنه موسوم
- D`F` — FAILED مع `[XPASS(strict)]`، رمز الخروج 1
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.
دورك الآن
خذ حزمة اختبارات لديك أصلاً — من فصل سابق، أو من عملك — وأعطها مجموعة صادقة من العلامات:
- سجّل علامتين خاصتين بك في
pyproject.toml(مثلاًslowوnetwork)، وفعّل--strict-markersفيaddopts. - ضع كل علامة على اختبار واحد على الأقل. استخدم
time.sleep(1)لتصطنع اختباراً بطيئاً إن لم يكن لديك واحد. - أضف اختباراً يحتاج متغير بيئة، وتخطَّه من الداخل بـ
pytest.skipحين يكون المتغير غائباً. - اعثر على خطأ في كودك، أو اختلق واحداً. اكتب الاختبار الذي يجب أن ينجح، وضع عليه
xfailمعstrict=Trueوraises=دقيق. - أضف fixture بـ
paramsوids، يستخدمه اختباران على الأقل.
ثم شغّل، واحفظ مخرجات كل أمر:
pytest -q -rsx -m "not slow"pytest -q -m slowpytest -q -rA
وأخيراً، تجربتان. اكتب إحدى علاماتك بخطأ إملائي وشغّل مرة دون --strict-markers ومرة معه. ثم أصلح الخطأ من الخطوة 4 وشغّل مجدداً. اقرأ ما يقوله pytest في كل حالة — هاتان الرسالتان هما ما يُبقي حزمة الاختبارات الموسومة صادقة، ومن الأسهل التعرف عليهما في المرة الثانية إن كنت قد تسببت فيهما بنفسك مرة.
الحل
حل واحد، باستخدام money.py من مشروع shop. الخطأ في الخطوة 4 جديد، الخطأ #51: الخلية الفارغة في جدول البيانات تصل على شكل ""، ويجب أن تُحسب 0.0 — لكن float("") يرفع ValueError.
pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: needs a real outside service and its API key",
]test_solution.py:
import os
import time
import pytest
from money import parse_price, split_bill
# Step 5: one fixture, three formats, used by two tests.
@pytest.fixture(params=["90", "90.00", "1,090"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
def test_parse_is_positive(amount_text):
assert parse_price(amount_text) > 0
def test_split_for_one_is_whole_amount(amount_text):
assert split_bill(parse_price(amount_text), 1) == parse_price(amount_text)
# Step 2: a slow test, labelled so it can be left out.
@pytest.mark.slow
def test_many_prices():
time.sleep(1) # stands in for real, slow work
assert sum(parse_price("2.5") for _ in range(1000)) == 2500.0
# Step 3: decided inside the test, because only the environment knows.
@pytest.mark.network
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")
# Step 4: bug #51 - an empty cell should count as 0, today it raises ValueError.
@pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #51: empty cell")
def test_empty_cell_is_zero():
assert parse_price("") == 0.0عمليات التشغيل الثلاث:
$ pytest -q -rsx -m "not slow"
......sx [100%]
=========================== short test summary info ============================
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
6 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s
$ pytest -q -m slow
. [100%]
1 passed, 8 deselected in 1.07s
$ pytest -q -rA
.......sx [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_solution.py::test_parse_is_positive[whole]
PASSED test_solution.py::test_parse_is_positive[decimals]
PASSED test_solution.py::test_parse_is_positive[comma]
PASSED test_solution.py::test_split_for_one_is_whole_amount[whole]
PASSED test_solution.py::test_split_for_one_is_whole_amount[decimals]
PASSED test_solution.py::test_split_for_one_is_whole_amount[comma]
PASSED test_solution.py::test_many_prices
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
7 passed, 1 skipped, 1 xfailed in 1.08sالتجربة الأولى: كُتبت @pytest.mark.slow خطأً @pytest.mark.slwo. دون --strict-markers (المعطّل هنا بـ -o addopts="")، ما زال التشغيل يستغرق ثانية، لأن الاختبار "البطيء" لم يُستبعد:
$ pytest -q -m "not slow" -o addopts=""
.......sx [100%]
=============================== warnings summary ===============================
test_solution.py:24
/home/you/shop/test_solution.py:24: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
7 passed, 1 skipped, 1 xfailed, 1 warning in 1.09sومعه، يرفض التشغيل أن يبدأ:
$ pytest -q -m "not slow"
==================================== ERRORS ====================================
______________________ ERROR collecting test_solution.py _______________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_solution.py - Failed: 'slwo' not found in `markers` configuration ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sالتجربة الثانية: أُصلح الخطأ #51 في money.py:
def parse_price(text: str) -> float:
if not text.strip():
return 0.0
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)$ pytest -q -m "not slow"
......sF [100%]
=================================== FAILURES ===================================
___________________________ test_empty_cell_is_zero ____________________________
[XPASS(strict)] bug #51: empty cell
=========================== short test summary info ============================
FAILED test_solution.py::test_empty_cell_is_zero - [XPASS(strict)] bug #51: e...
1 failed, 6 passed, 1 skipped, 1 deselected in 0.08sأزل سطر xfail وسينجح الاختبار كاختبار عادي من ذلك الحين.
سبب كل قرار:
--strict-markersفيaddopts، لا في سطر الأوامر. شبكة الأمان التي عليك أن تتذكر تفعيلها ليست شبكة أمان. في ملف الإعداد تنطبق على الجميع، بما في ذلك CI.slowملصق، لا تخطٍّ. الاختبار البطيء اختبار جيد؛ يجب أن يعمل، فقط ليس عند كل حفظ. الملصق يتيح لك الاختيار؛ أما التخطي فكان سيزيله من كل تشغيل.pytest.skip()داخل الاختبار لمفتاح الـ API، لاskipif. كلاهما يصلح هنا، لكن مكان الفحص بجوار الكود الذي يحتاج المفتاح. وحين لا يعرف الاختبار إلا في منتصف الطريق أنه لا يستطيع المتابعة، تكون الصيغة الداخلية هي الوحيدة التي تعمل.raises=ValueErrorوstrict=Trueمعاً. يضمنraises=أن الاختبار يفشل بسبب الخطأ #51 لا بسبب خطأ إملائي؛ ويضمنstrict=Trueأن يُلاحَظ يوم إصلاح الخطأ. كل منهما يسدّ طريقاً مختلفاً قد تبدأ منه العلامة بالكذب.- fixture ذو معاملات للصيغ، لا
parametrize. يتشارك اختباران المدخلات الثلاثة نفسها. مع الـ fixture تعيش القائمة في مكان واحد؛ وإضافة صيغة رابعة تضيفها إلى الاختبارين دفعة واحدة. - المعرّفات
wholeوdecimalsوcomma. في التقرير،[comma]يقول عمّ تدور الحالة. كان[1,090]سيصلح هنا أيضاً، لكن المعرّفات تبقى صالحة حين تكون القيم طويلة أو غير قابلة للطباعة.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz