الفصل 06

parametrize — اختبار واحد وحالات كثيرة

استبدال الاختبارات المنسوخة والحلقات داخل الاختبار بـ @pytest.mark.parametrize. معرّفات الاختبارات و ids= و pytest.param، وتكديس المزخرفات، والقيم والأخطاء في جدول واحد، والأخطاء الشائعة.

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

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

إليك دالة صغيرة تحوّل درجة الامتحان إلى تقدير، في الملف grading.py:

python
def grade(score):
    if not 0 <= score <= 100:
        raise ValueError(f"score must be between 0 and 100, got {score}")
    if score >= 80:
        return "A"
    if score >= 60:
        return "B"
    if score >= 40:
        return "C"
    return "F"
text
$ python3 -c "from grading import grade; print(grade(95), grade(79), grade(40), grade(0))"
A B C F

اختبارها كما ينبغي يعني فحص درجات كثيرة، وخاصة الحدود: 80 و79، و60 و59، و40 و39. والطريقة الأولى التي يكتب بها الجميع ذلك هي نسخ اختبار وتغيير قيمتين:

python
from grading import grade


def test_grade_95():
    assert grade(95) == "A"


def test_grade_79():
    assert grade(79) == "B"


def test_grade_40():
    assert grade(40) == "C"
text
$ pytest -q test_copy.py
...                                                                      [100%]
3 passed in 0.01s

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

فتأتي الفكرة التالية: قائمة بالحالات وحلقة تكرار داخل اختبار واحد. اثنان من التقديرات المتوقعة أدناه خاطئان عن قصد — الدرجة 79 يجب أن تعطي B، والدرجة 40 يجب أن تعطي C:

python
from grading import grade


def test_grades():
    cases = [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")]
    for score, expected in cases:
        assert grade(score) == expected
text
$ pytest -q --tb=short test_loop.py
F                                                                        [100%]
=================================== FAILURES ===================================
_________________________________ test_grades __________________________________
test_loop.py:7: in test_grades
    assert grade(score) == expected
E   AssertionError: assert 'B' == 'C'
E     
E     - C
E     + B
=========================== short test summary info ============================
FAILED test_loop.py::test_grades - AssertionError: assert 'B' == 'C'
1 failed in 0.01s

هذا التقرير أسوأ مما يبدو. كانت هناك حالتان خاطئتان، وعلمت بواحدة فقط: أول assert يفشل ينهي الاختبار، فلم تُنفَّذ حالة الدرجة 40 أبداً. ولا يذكر التقرير أي درجة فشلت — عليك أن تستنتج أن 'B' == 'C' جاءت من 79. وعبارة "1 failed" تعدّ خمس حالات كأنها حقيقة واحدة. تصلح الأولى، وتشغّل من جديد، وعندها فقط تقابل الثانية.

ما تريده هو إيجاز الحلقة مع تقارير النسخ المتعددة: دالة واحدة، واختبارات كثيرة منفصلة.

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

  • تشغيل دالة اختبار واحدة على حالات كثيرة باستخدام @pytest.mark.parametrize
  • تمرير عدة قيم في كل حالة وقراءة معرّفات الاختبارات (test IDs) التي يولّدها pytest
  • تسمية الحالات باستخدام ids= وباستخدام pytest.param(..., id=...)
  • وسم حالة واحدة بـ xfail أو skip دون المساس بغيرها
  • الجمع بين مزخرفَي parametrize للحصول على كل تركيبة ممكنة من قيمهما
  • خلط حالات "تُرجع قيمة" وحالات "تطلق خطأ" في جدول واحد
  • التعرّف على الأخطاء الشائعة في parametrize من رسائل الخطأ الخاصة بها

المتطلبات المسبقة: مقارنة الأعداد العشرية باستخدام `pytest.approx`.


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

إن parametrize طريقة لتدوين جدول. لذلك فالعمل الذي يسبق أي كود اختبار هو بناء هذا الجدول، والتقرير هل الجدول هو الشكل المناسب أصلاً.

1. صُغ العقد. تعد الدالة grade(score) بما يلي: تدخل درجة صحيحة من 0 إلى 100؛ ويخرج واحد من "A" أو "B" أو "C" أو "F"؛ وأي قيمة خارج النطاق 0–100 تطلق ValueError تذكر رسالته الدرجة. وليس لها آثار جانبية — لا ملفات ولا طباعة — فلا شيء يحتاج إلى تهيئة أو تنظيف. وهذا ما يجعلها مناسبة: كل حالة هي "هذه المدخلات، وتلك النتيجة".

2. تحقّق من بيئتك. تحتاج إلى بيئة افتراضية مفعّلة، وpytest مثبتاً، وأن يكون grading.py قابلاً للاستيراد من ملف الاختبار — في هذه الأمثلة يوجد بجانب الاختبارات في المجلد نفسه. ولا شيء غير ذلك: parametrize مدمج في pytest، وcontextlib جزء من المكتبة القياسية.

3. خطّط للحالات. الحدود هي الأهم، لأن هناك تعيش أخطاء >= مقابل >. كل قاعدة تأخذ قيمة على كل جانب من خطها:

| الحالة | المدخل | المتوقع | |---|---|---| | أعلى النطاق | 100 | "A" | | أدنى A | 80 | "A" | | أعلى B | 79 | "B" | | أدنى B | 60 | "B" | | أعلى C | 59 | "C" | | أدنى C | 40 | "C" | | أعلى F | 39 | "F" | | أسفل النطاق | 0 | "F" | | أسفل النطاق مباشرة | -1 | ValueError | | أعلى النطاق مباشرة | 101 | ValueError |

يصبح كل صف حالة واحدة. وعمود "الحالة" ليس زينة — سيصبح لاحقاً معرّف الاختبار، فيخبرك الفشل أي قاعدة انكسرت.

4. قرّر ما لن تختبره. ليس كل درجة من 0 إلى 100: فالدرجتان 95 و85 تختبران القاعدة نفسها التي تختبرها 80، وتضيفان وقت تشغيل دون أن تضيفا معلومة. تكفي قيمة واحدة على كل جانب من كل حد. لا تختبر عوامل المقارنة في بايثون نفسها، ولا تضع سلوكين مختلفين تحت اختبار واحد لمجرد أنهما يتسعان في جدول — إذا احتاجت الصفوف إلى تأكيدات مختلفة، فمكانها اختبارات مختلفة.

بقية الفصل تحوّل هذا الجدول إلى كود، أداةً بعد أداة.


وسيط واحد وقيم كثيرة

يأخذ المزخرف اسم وسيط وقائمة قيم له. ينشئ pytest اختباراً لكل قيمة ويمرّر القيمة تحت ذلك الاسم:

python
import pytest

from grading import grade


@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
    assert grade(score) == "A"
text
$ pytest -v test_one.py
collecting ... collected 3 items

test_one.py::test_top_grade[80] PASSED                                   [ 33%]
test_one.py::test_top_grade[95] PASSED                                   [ 66%]
test_one.py::test_top_grade[100] PASSED                                  [100%]

============================== 3 passed in 0.01s ===============================

دالة واحدة، وثلاثة اختبارات، لكل منها سطره واسمه. الجزء الواقع بين القوسين المربعين هو معرّف الاختبار، وهو مبني من القيمة. وليس score تجهيزة (fixture) وليست له قيمة افتراضية — يكفي أن يطابق الاسم في نص المزخرف معاملاً من معاملات الدالة.

عدة وسائط في كل حالة

تحتاج معظم الحالات إلى مدخل ونتيجة متوقعة. سمِّ الاثنين مفصولين بفاصلة، وقدّم كل حالة على شكل صف (tuple) بالترتيب نفسه. إليك الحلقة مرة أخرى، مع التوقعين الخاطئين أنفسهما:

python
import pytest

from grading import grade


@pytest.mark.parametrize(
    "score,expected",
    [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")],
)
def test_grade(score, expected):
    assert grade(score) == expected
text
$ pytest -q --tb=short test_param.py
..FF.                                                                    [100%]
=================================== FAILURES ===================================
_______________________________ test_grade[79-C] _______________________________
test_param.py:11: in test_grade
    assert grade(score) == expected
E   AssertionError: assert 'B' == 'C'
E     
E     - C
E     + B
_______________________________ test_grade[40-D] _______________________________
test_param.py:11: in test_grade
    assert grade(score) == expected
E   AssertionError: assert 'C' == 'D'
E     
E     - D
E     + C
=========================== short test summary info ============================
FAILED test_param.py::test_grade[79-C] - AssertionError: assert 'B' == 'C'
FAILED test_param.py::test_grade[40-D] - AssertionError: assert 'C' == 'D'
2 failed, 3 passed in 0.01s

قارن هذا بالحلقة. نُفّذت كل الحالات. وأُبلغ عن الفشلين كليهما، كلٌّ تحت معرّف — test_grade[79-C] — يسمّي الحالة. والعدّ صادق: 2 فشلا، و3 نجحت. (ومع التتبع الطويل الافتراضي، يطبع pytest أيضاً وسائط كل حالة فاشلة، score = 79, expected = 'C'.)

يمكن كتابة الأسماء نصاً واحداً، "score,expected" أو "score, expected" — المسافات بعد الفواصل مسموحة — أو قائمة نصوص، ["score", "expected"]. والصيغتان تنتجان الاختبارات نفسها تماماً. النص هو الصيغة الشائعة؛ والقائمة مفيدة حين تُبنى الأسماء بالكود.

كيف تُصنع المعرّفات

بالنسبة للأرقام والنصوص والقيم المنطقية وNone، يضع pytest القيمة نفسها في المعرّف ويصل قيم الحالة الواحدة بـ -. أما ما عدا ذلك — قائمة أو قاموس أو كائن — فلا يحاول. بل يستخدم اسم الوسيط مع موضع الحالة:

python
import pytest


@pytest.mark.parametrize(
    ["value", "expected"],
    [
        (1.5, True),
        ("hello world", None),
        ([1, 2], {"a": 1}),
    ],
)
def test_show_ids(value, expected):
    pass
text
$ pytest -v test_ids_auto.py
collecting ... collected 3 items

test_ids_auto.py::test_show_ids[1.5-True] PASSED                         [ 33%]
test_ids_auto.py::test_show_ids[hello world-None] PASSED                 [ 66%]
test_ids_auto.py::test_show_ids[value2-expected2] PASSED                 [100%]

============================== 3 passed in 0.01s ===============================

المعرّف value2-expected2 يعني "value وexpected للحالة رقم 2، بالعدّ من الصفر". إنه فريد، ولا يخبرك بشيء. وحين تفشل حالة كهذه، تعدّ في القائمة لتجدها. وهذا سبب تسمية الحالات بنفسك.

تسمية الحالات: ids=

مرّر إلى ids= قائمة نصوص، واحداً لكل حالة، بالترتيب نفسه:

python
import pytest

from grading import grade


@pytest.mark.parametrize(
    "score,expected",
    [(80, "A"), (79, "B"), (40, "C"), (39, "F")],
    ids=["lowest-A", "highest-B", "lowest-C", "highest-F"],
)
def test_grade_boundaries(score, expected):
    assert grade(score) == expected
text
$ pytest -v test_ids_list.py
collecting ... collected 4 items

test_ids_list.py::test_grade_boundaries[lowest-A] PASSED                 [ 25%]
test_ids_list.py::test_grade_boundaries[highest-B] PASSED                [ 50%]
test_ids_list.py::test_grade_boundaries[lowest-C] PASSED                 [ 75%]
test_ids_list.py::test_grade_boundaries[highest-F] PASSED                [100%]

============================== 4 passed in 0.01s ===============================

الآن يقول الناتج لماذا توجد كل حالة. ونقطة الضعف أن الأسماء تعيش في قائمة ثانية بعيدة عن حالاتها: أدخل حالة في المنتصف، وانسَ اسمها، فيزيح كل اسم بعدها بمقدار واحد.

ويقبل ids= أيضاً دالة. يستدعيها pytest مرة لـكل قيمة، لا لكل حالة، ويصل النتائج بـ -:

python
import pytest

from grading import grade


def describe(value):
    if isinstance(value, int):
        return f"score={value}"
    return f"grade={value}"


@pytest.mark.parametrize("score,expected", [(80, "A"), (79, "B")], ids=describe)
def test_grade_named(score, expected):
    assert grade(score) == expected
text
$ pytest -v test_ids_func.py
collecting ... collected 2 items

test_ids_func.py::test_grade_named[score=80-grade=A] PASSED              [ 50%]
test_ids_func.py::test_grade_named[score=79-grade=B] PASSED              [100%]

============================== 2 passed in 0.01s ===============================

تناسب الدالة القيم التي يكون معرّفها التلقائي بلا فائدة، كالكائنات. وإذا أرجعت None لقيمة ما، يعود pytest إلى المعرّف التلقائي لتلك القيمة.

حالة في كل مرة: pytest.param

تغلّف pytest.param حالة واحدة وتتيح لك أن تُلحق بها أشياء: id= يجلس بجانب قيمها مباشرة، وmarks= تنطبق على تلك الحالة وحدها.

python
import pytest

from grading import grade


@pytest.mark.parametrize(
    "score,expected",
    [
        pytest.param(100, "A", id="perfect"),
        pytest.param(60, "B", id="lowest-B"),
        pytest.param(59.5, "B", id="round-up", marks=pytest.mark.xfail(reason="no rounding yet")),
        pytest.param(0, "F", id="zero", marks=pytest.mark.skip(reason="example of skip")),
    ],
)
def test_grade_cases(score, expected):
    assert grade(score) == expected
text
$ pytest -v test_param_obj.py
collecting ... collected 4 items

test_param_obj.py::test_grade_cases[perfect] PASSED                      [ 25%]
test_param_obj.py::test_grade_cases[lowest-B] PASSED                     [ 50%]
test_param_obj.py::test_grade_cases[round-up] XFAIL (no rounding yet)    [ 75%]
test_param_obj.py::test_grade_cases[zero] SKIPPED (example of skip)      [100%]

=================== 2 passed, 1 skipped, 1 xfailed in 0.01s ====================

تسجّل حالة round-up قراراً لم يُتخذ بعد — هل يجب تقريب 59.5 صعوداً إلى B؟ — دون أن تُفشل التشغيل، وتبقى الحالات الأخرى كما هي. (لـ xfail وskip فصل خاص بهما لاحقاً؛ يكفي الآن أن تعرف أن الوسم يمكن أن يوضع على حالة واحدة.)

ولأن المعرّف اسم حقيقي، يمكن تشغيل حالة واحدة بمفردها: pytest "test_param_obj.py::test_grade_cases[perfect]". ضعه بين علامتي تنصيص — فالصدفة (shell) تعامل الأقواس المربعة معاملة خاصة.

مزخرفان: كل التركيبات

ضع مزخرفَي parametrize أحدهما فوق الآخر، فيشغّل pytest الاختبار لـكل اقتران بين قيمهما — أي الضرب الديكارتي. عملتان في ثلاثة مبالغ تساوي ستة اختبارات:

python
import pytest


@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [0, 10, 999])
def test_format(amount, currency):
    text = f"{amount} {currency}"
    assert text.endswith(currency)
text
$ pytest -v test_stack.py
collecting ... collected 6 items

test_stack.py::test_format[0-USD] PASSED                                 [ 16%]
test_stack.py::test_format[0-EUR] PASSED                                 [ 33%]
test_stack.py::test_format[10-USD] PASSED                                [ 50%]
test_stack.py::test_format[10-EUR] PASSED                                [ 66%]
test_stack.py::test_format[999-USD] PASSED                               [ 83%]
test_stack.py::test_format[999-EUR] PASSED                               [100%]

============================== 6 passed in 0.01s ===============================

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

القيم والأخطاء في جدول واحد

أحياناً تنقسم الحالات إلى "تُرجع هذا" و"تطلق ذاك". يمكنك أن تجعل التوقع نفسه وسيطاً، وتدخله بـ with:

python
from contextlib import nullcontext

import pytest

from grading import grade


@pytest.mark.parametrize(
    "score,expectation",
    [
        (50, nullcontext("C")),
        (100, nullcontext("A")),
        (-1, pytest.raises(ValueError)),
        (101, pytest.raises(ValueError, match="got 101")),
    ],
)
def test_grade_or_error(score, expectation):
    with expectation as expected:
        assert grade(score) == expected
text
$ pytest -v test_raises.py
collecting ... collected 4 items

test_raises.py::test_grade_or_error[50-expectation0] PASSED              [ 25%]
test_raises.py::test_grade_or_error[100-expectation1] PASSED             [ 50%]
test_raises.py::test_grade_or_error[-1-expectation2] PASSED              [ 75%]
test_raises.py::test_grade_or_error[101-expectation3] PASSED             [100%]

============================== 4 passed in 0.01s ===============================

إن contextlib.nullcontext مدير سياق لا يفعل شيئاً: with nullcontext("C") as expected يضبط فقط expected = "C"، فتنفّذ الحالات العادية assert كالمعتاد. وفي حالات الخطأ يكون pytest.raises هو مدير السياق: تطلق grade الخطأ داخله، فلا يكتمل سطر assert أبداً، وينجح الاختبار لأن الخطأ الصحيح وصل. وإن لم يصل أي خطأ، تحصل على DID NOT RAISE المألوفة.

هذا مرتّب لجدول قصير. فإن بدأ جسم الاختبار يحتاج إلى عبارات if ليفرّق بين النوعين، فقسّمه إلى اختبارين. ولاحظ المعرّفات أيضاً: expectation0 هي قاعدة الكائنات مرة أخرى. ويصلح المثال المتكامل ذلك باستخدام pytest.param.

تطبيق parametrize على صنف

ضع المزخرف على صنف اختبار، فيتلقى كل تابع اختبار فيه الوسيط:

python
import pytest

from grading import grade


@pytest.mark.parametrize("score", [80, 100])
class TestTopBand:
    def test_is_a(self, score):
        assert grade(score) == "A"

    def test_is_a_string(self, score):
        assert isinstance(grade(score), str)
text
$ pytest -v test_class.py
collecting ... collected 4 items

test_class.py::TestTopBand::test_is_a[80] PASSED                         [ 25%]
test_class.py::TestTopBand::test_is_a[100] PASSED                        [ 50%]
test_class.py::TestTopBand::test_is_a_string[80] PASSED                  [ 75%]
test_class.py::TestTopBand::test_is_a_string[100] PASSED                 [100%]

============================== 4 passed in 0.01s ===============================

تابعان في قيمتين: أربعة اختبارات. ويجب أن يقبل كل تابع في الصنف الوسيط score، وإلا فشل الجمع (collection).


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

shipping.py:

python
import math

RATES = {"local": 50, "national": 120}


def shipping_cost(weight_kg, zone):
    if zone not in RATES:
        raise KeyError(f"unknown zone: {zone}")
    if weight_kg <= 0:
        raise ValueError("weight must be positive")
    # The first kilogram is in the base rate; each started kilogram after it costs 20.
    return RATES[zone] + (math.ceil(weight_kg) - 1) * 20
text
$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160

test_shipping.py:

python
from contextlib import nullcontext

import pytest

from shipping import shipping_cost


@pytest.mark.parametrize(
    "weight_kg,zone,expected",
    [
        pytest.param(0.5, "local", 50, id="light-local"),
        pytest.param(1, "local", 50, id="exactly-1kg"),
        pytest.param(1.2, "local", 70, id="part-kg-rounds-up"),
        pytest.param(3, "national", 160, id="heavy-national"),
    ],
)
def test_cost(weight_kg, zone, expected):
    assert shipping_cost(weight_kg, zone) == expected


@pytest.mark.parametrize("zone", ["local", "national"])
@pytest.mark.parametrize("weight_kg", [0.1, 0.5, 1])
def test_first_kg_is_flat(weight_kg, zone):
    assert shipping_cost(weight_kg, zone) == shipping_cost(1, zone)


@pytest.mark.parametrize(
    "weight_kg,zone,expectation",
    [
        pytest.param(2, "local", nullcontext(70), id="ok"),
        pytest.param(0, "local", pytest.raises(ValueError), id="zero-weight"),
        pytest.param(-1, "local", pytest.raises(ValueError), id="negative"),
        pytest.param(1, "moon", pytest.raises(KeyError, match="moon"), id="bad-zone"),
    ],
)
def test_cost_or_error(weight_kg, zone, expectation):
    with expectation as expected:
        assert shipping_cost(weight_kg, zone) == expected
text
$ pytest -v test_shipping.py
collecting ... collected 14 items

test_shipping.py::test_cost[light-local] PASSED                          [  7%]
test_shipping.py::test_cost[exactly-1kg] PASSED                          [ 14%]
test_shipping.py::test_cost[part-kg-rounds-up] PASSED                    [ 21%]
test_shipping.py::test_cost[heavy-national] PASSED                       [ 28%]
test_shipping.py::test_first_kg_is_flat[0.1-local] PASSED                [ 35%]
test_shipping.py::test_first_kg_is_flat[0.1-national] PASSED             [ 42%]
test_shipping.py::test_first_kg_is_flat[0.5-local] PASSED                [ 50%]
test_shipping.py::test_first_kg_is_flat[0.5-national] PASSED             [ 57%]
test_shipping.py::test_first_kg_is_flat[1-local] PASSED                  [ 64%]
test_shipping.py::test_first_kg_is_flat[1-national] PASSED               [ 71%]
test_shipping.py::test_cost_or_error[ok] PASSED                          [ 78%]
test_shipping.py::test_cost_or_error[zero-weight] PASSED                 [ 85%]
test_shipping.py::test_cost_or_error[negative] PASSED                    [ 92%]
test_shipping.py::test_cost_or_error[bad-zone] PASSED                    [100%]

============================== 14 passed in 0.01s ==============================

ثلاث دوال، وأربعة عشر اختباراً، وكل سطر يقول ما الذي فحصه. وهناك ثلاثة أمور تستحق الانتباه.

أولاً، تعطي المعرّفات في test_cost سبب كل حالة — فالمعرّف part-kg-rounds-up يسمّي القاعدة المحمية، وهو ما لم يكن 1.2-local-70 ليفعله.

ثانياً، يكدّس test_first_kg_is_flat المزخرفات لأن الوزن والمنطقة مستقلان فعلاً. كما أنه يقارن الدالة بنفسها لا برقم، فيصمد أمام تغيير الأسعار.

ثالثاً، تحوّل pytest.param(..., id=...) المعرّف expectation2 إلى negative. وصار جدول القيم والأخطاء يُقرأ كأنه مواصفة.


الأخطاء الشائعة وحلولها

function uses no argument 'scores' اسمٌ في نص المزخرف لا يطابق أي معامل في الدالة — هنا "scores,expected" مقابل def test_grade(score, expected). ويحدث الفشل أثناء الجمع، قبل تشغيل أي اختبار:

python
import pytest

from grading import grade


@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
    assert grade(score) == expected
text
$ pytest -q test_mismatch.py

==================================== ERRORS ====================================
______________________ ERROR collecting test_mismatch.py _______________________
In test_mismatch.py::test_grade: function uses no argument 'scores'
=========================== short test summary info ============================
ERROR test_mismatch.py - Failed: In test_mismatch.py::test_grade: function us...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

fixture 'expected' not found العكس: للدالة معامل لا يذكره المزخرف أبداً، فيبحث pytest عن تجهيزة بهذا الاسم ولا يجد شيئاً. أضف الاسم إلى نص المزخرف.

the number of names (2) ... must be equal to the number of values (1) حالة واحدة فيها عدد خاطئ من القيم — مثلاً (40,) في قائمة لـ "score,expected":

text
$ pytest -q test_count.py

==================================== ERRORS ====================================
________________________ ERROR collecting test_count.py ________________________
test_count.py::test_grade: in "parametrize" the number of names (2):
  ['score', 'expected']
must be equal to the number of values (1):
  (40,)

معرّفات مثل [score0] وTypeError يذكر صفاً (tuple) مع اسم وسيط واحد، تُمرَّر كل قيمة في القائمة كاملة. فإن كتبت صفوفاً هناك بحكم العادة، تتلقى الدالة صفاً:

python
import pytest

from grading import grade


@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
    assert grade(score) == "A"
text
$ pytest -v --tb=no test_tuples.py
collecting ... collected 2 items

test_tuples.py::test_top_grade[score0] FAILED                            [ 50%]
test_tuples.py::test_top_grade[score1] FAILED                            [100%]

=========================== short test summary info ============================
FAILED test_tuples.py::test_top_grade[score0] - TypeError: '<=' not supported...
FAILED test_tuples.py::test_top_grade[score1] - TypeError: '<=' not supported...
============================== 2 failed in 0.01s ===============================

المعرّفات هي الدليل: الرقم العادي كان سيعطي [80]، فـ [score0] تقول إن القيمة كانت شيئاً آخر. لوسيط واحد، اكتب [80, 95].

حالة تنجح وحدها وتفشل مع غيرها تُنشأ قيم الوسائط مرة واحدة، حين تُجمع الوحدة (module)، ويُسلَّم الكائن نفسه لكل اختبار يتلقاه. فإن غيّر اختبارٌ قيمةً قابلة للتعديل — قائمة أو قاموساً — يرى الاختبار التالي هذا التغيير:

python
import pytest


@pytest.mark.parametrize("cart", [[]])
class TestCart:
    def test_add_item(self, cart):
        cart.append("pen")
        assert cart == ["pen"]

    def test_starts_empty(self, cart):
        assert cart == []
text
$ pytest -q --tb=short test_shared.py
.F                                                                       [100%]
=================================== FAILURES ===================================
______________________ TestCart.test_starts_empty[cart0] _______________________
test_shared.py:11: in test_starts_empty
    assert cart == []
E   AssertionError: assert ['pen'] == []
E     
E     Left contains one more item: 'pen'
E     Use -v to get more diff
=========================== short test summary info ============================
FAILED test_shared.py::TestCart::test_starts_empty[cart0] - AssertionError: a...
1 failed, 1 passed in 0.01s

مرّر شيئاً غير قابل للتعديل، كالصف ()، وابنِ كائناً جديداً داخل كل اختبار بـ cart = list(items). عندها يبدأ كل اختبار من قائمته الفارغة الخاصة، وينجح الاختباران.