الفصل 12

العلامات و skip و xfail — أي اختبار يعمل وأين

أخبر pytest بما يحتاجه كل اختبار وبما تتوقعه منه عبر skip و skipif و importorskip و xfail؛ ومعنى strict=True و raises= و XPASS؛ وسجّل علاماتك الخاصة واختر بـ -m؛ إضافة إلى fixtures ذات المعاملات و fixtures المصنع.

50 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

مشروع صغير اسمه shop، فيه وحدة واحدة وملفا اختبار. money.py:

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

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

text
==================================== 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 مبكر:

python
import os


def test_live_rates():
    if "RATES_API_KEY" not in os.environ:
        return
    assert False, "would call the real API here"
text
.                                                                        [100%]
1 passed in 0.07s

Passed. الاختبار لم يفحص شيئاً على الإطلاق، والتقرير يقول إنه نجح. على حاسوب لا يملك المفتاح، هذه كذبة تُقال في كل تشغيل، ولا سبيل إلى تمييز هذه النقطة الخضراء عن نقطة حقيقية. الحل ليس تجنب الشرط، بل الإبلاغ عنه بأمانة — كتخطٍّ.

أبسط علامة هي مُزخرِف (decorator) يقول "لا تشغّل هذا"، ومعه سبب:

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

text
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 بهذا:

python
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
text
..                                                                       [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 خطأ من هذا النوع. فهي لا تتعامل مع رمز العملة:

python
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.0

pytest -q -rx:

text
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 ليحذف $:

text
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 — اجعل الخبر السار مسموعاً

python
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.0
text
F                                                                        [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 بلا وسائط يقبل أي فشل. وهذا يشمل الحالات التي لم تقصدها:

python
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.0
text
xF                                                                       [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 يعرض كل شيء، بما في ذلك الناجحة:

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

python
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
text
....                                                                     [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، نص واحد لكل علامة، الاسم قبل النقطتين والوصف بعدهما:

toml
[tool.pytest.ini_options]
markers = [
    "slow: takes more than a second; deselect with -m 'not slow'",
    "db: needs the database",
]
text
....                                                                     [100%]
4 passed in 2.11s

اختفت التحذيرات، وصار pytest --markers يسردها للشخص التالي الذي ينضم إلى المشروع:

text
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'

@pytest.mark.db: needs the database

الاختيار بـ -m

الآن تؤتي الملصقات ثمارها. يأخذ -m تعبيراً على أسماء العلامات، مع and و or و not والأقواس:

text
$ 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.07s

Deselected (مستبعد) ليس skipped (متخطّى). الاختبار المتخطّى اختير ثم امتنع عن العمل؛ أما المستبعد فلم يُختر أصلاً. -m "not slow" هو التشغيل الذي تجريه عند كل حفظ؛ والتشغيل الكامل لـ CI وقبل الدفع (push).

--strict-markers — الخطأ الإملائي يصبح خطأً فعلياً

من السهل أن يفوتك التحذير. هذا اختبار أضافه أحدهم لاحقاً، مع خطأ إملائي في العلامة:

python
@pytest.mark.slwo
def test_split_large_group():
    time.sleep(1)
    assert split_bill(1000.0, 8) == 125.0
text
...                                                                      [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، توقِف العلامة غير المسجلة عملية الجمع بدلاً من ذلك:

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

toml
[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 ينطبق على كل اختبار في الملف:

python
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
text
$ pytest -q -m db
..                                                                       [100%]
2 passed, 4 deselected in 0.07s

يجب أن يكون الاسم pytestmark حرفياً. ويمكن أن يكون قائمة أيضاً، pytestmark = [pytest.mark.db, pytest.mark.slow].

علامات على حالة واحدة من parametrize

العلامة على اختبار ذي معاملات تنطبق على كل حالاته. لوسم حالة واحدة، غلّفها بـ pytest.param(..., marks=...):

python
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) == expected
text
test_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:

python
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) > 0
text
test_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 أن يأخذ وسائط من الاختبار؛ فالاختبار يذكر اسمه فقط. فماذا تفعل حين يحتاج الاختبار إلى كائنين مبنيين بطريقتين مختلفتين؟ أعِد دالة:

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

ما زال الـ fixture يعمل مرة لكل اختبار، وما زال يحصل على الـ fixtures الخاصة به (هنا tmp_path، الذي يتولى أيضاً حذف الملفات). ما يسلّمه هو _make، ويستدعيها الاختبار كلما شاء، بالوسائط التي يشاء. الاسم make_... هو العرف المتّبع، ويخبر القارئ بنظرة أن هذا الـ fixture دالة تُستدعى، لا قيمة جاهزة.


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

كل شيء معاً في مشروع واحد. pyproject.toml:

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:

python
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")

التشغيل اليومي، مع الأسباب:

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

البطيء وحده، ثم التشغيل دون اتصال الذي يستبعد الاختبارات البطيئة واختبارات الشبكة معاً:

text
$ 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.