parametrize — اختبار واحد وحالات كثيرة
استبدال الاختبارات المنسوخة والحلقات داخل الاختبار بـ @pytest.mark.parametrize. معرّفات الاختبارات و ids= و pytest.param، وتكديس المزخرفات، والقيم والأخطاء في جدول واحد، والأخطاء الشائعة.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك دالة صغيرة تحوّل درجة الامتحان إلى تقدير، في الملف grading.py:
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"$ 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. والطريقة الأولى التي يكتب بها الجميع ذلك هي نسخ اختبار وتغيير قيمتين:
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"$ pytest -q test_copy.py
... [100%]
3 passed in 0.01sهذا يعمل، لكنه لا يتوسع. ثلاث دوال تقول شيئاً واحداً — "هذه الدرجة تعطي هذا التقدير" — ولا يختلف بينها إلا قيمتان. وعشر حالات إضافية تعني عشر نسخ إضافية يجب أن تبقى متطابقة.
فتأتي الفكرة التالية: قائمة بالحالات وحلقة تكرار داخل اختبار واحد. اثنان من التقديرات المتوقعة أدناه خاطئان عن قصد — الدرجة 79 يجب أن تعطي B، والدرجة 40 يجب أن تعطي C:
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$ 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 اختباراً لكل قيمة ويمرّر القيمة تحت ذلك الاسم:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
assert grade(score) == "A"$ 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) بالترتيب نفسه. إليك الحلقة مرة أخرى، مع التوقعين الخاطئين أنفسهما:
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$ 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 القيمة نفسها في المعرّف ويصل قيم الحالة الواحدة بـ -. أما ما عدا ذلك — قائمة أو قاموس أو كائن — فلا يحاول. بل يستخدم اسم الوسيط مع موضع الحالة:
import pytest
@pytest.mark.parametrize(
["value", "expected"],
[
(1.5, True),
("hello world", None),
([1, 2], {"a": 1}),
],
)
def test_show_ids(value, expected):
pass$ 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= قائمة نصوص، واحداً لكل حالة، بالترتيب نفسه:
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$ 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 مرة لـكل قيمة، لا لكل حالة، ويصل النتائج بـ -:
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$ 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= تنطبق على تلك الحالة وحدها.
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$ 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 الاختبار لـكل اقتران بين قيمهما — أي الضرب الديكارتي. عملتان في ثلاثة مبالغ تساوي ستة اختبارات:
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)$ 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:
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$ 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 على صنف
ضع المزخرف على صنف اختبار، فيتلقى كل تابع اختبار فيه الوسيط:
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)$ 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:
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$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160test_shipping.py:
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$ 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). ويحدث الفشل أثناء الجمع، قبل تشغيل أي اختبار:
import pytest
from grading import grade
@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
assert grade(score) == expected$ 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.01sfixture 'expected' not found العكس: للدالة معامل لا يذكره المزخرف أبداً، فيبحث pytest عن تجهيزة بهذا الاسم ولا يجد شيئاً. أضف الاسم إلى نص المزخرف.
the number of names (2) ... must be equal to the number of values (1) حالة واحدة فيها عدد خاطئ من القيم — مثلاً (40,) في قائمة لـ "score,expected":
$ 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) مع اسم وسيط واحد، تُمرَّر كل قيمة في القائمة كاملة. فإن كتبت صفوفاً هناك بحكم العادة، تتلقى الدالة صفاً:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"$ 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)، ويُسلَّم الكائن نفسه لكل اختبار يتلقاه. فإن غيّر اختبارٌ قيمةً قابلة للتعديل — قائمة أو قاموساً — يرى الاختبار التالي هذا التغيير:
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 == []$ 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). عندها يبدأ كل اختبار من قائمته الفارغة الخاصة، وينجح الاختباران.
Step 4 of 6 — Predict
Check your understanding
عند تشغيل pytest -v، ما معرّفات الاختبارات الأربعة التي تظهر، وبأي ترتيب؟
import pytest
@pytest.mark.parametrize("b", ["x", "y"])
@pytest.mark.parametrize("a", [1, 2])
def test_pair(a, b):
pass- Atest_pair[x-1], test_pair[x-2], test_pair[y-1], test_pair[y-2]
- Btest_pair[x-1], test_pair[y-1], test_pair[x-2], test_pair[y-2]
- Ctest_pair[1-x], test_pair[1-y], test_pair[2-x], test_pair[2-y]
- Dtest_pair[1-x], test_pair[2-y]
كل من grade(80) و grade(95) يعيد "A"، ومع ذلك يفشل الاختباران. لماذا؟
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- Aلا يمكن استخدام parametrize مع اسم وسيط واحد فقط
- Bمع اسم واحد تُمرَّر كل قيمة كاملة، فيكون `score` هو الصف `(80,)` لا `80`
- Cالمعرّفان `score0` و `score1` يتعارضان مع اسم الدالة
- Dيجب أن تكون القيم صفاً من الصفوف لا قائمة
توقعان هنا خاطئان (يجب أن تكون 79 هي B و 40 هي C). ماذا يبلّغ pytest؟
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — سطر لكل حالة
- B3 failed، لأن فشلاً واحداً يُفشل كل الحالات
- C1 failed — للحالة 40، وهي آخر حالة خاطئة
- D1 failed — لأول حالة خاطئة فقط؛ الحالة 40 لا تُنفَّذ أبداً
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.
دورك الآن
اكتب password.py فيه دالة check_password(text) تُرجع قائمة بالمشكلات: "too short" إذا كان أقل من 8 أحرف، و"no digit" إذا لم يحتوِ رقماً، و"no letter" إذا لم يحتوِ حرفاً. وكلمة المرور الجيدة تُرجع []. وأي شيء ليس str يطلق TypeError.
ثم اكتب test_password.py يحتوي على:
- اختبار واحد بـ parametrize من ست حالات على الأقل، مكتوب بـ
pytest.paramومعid=يقول ما الذي تدور حوله كل حالة ("seven-chars"،"digits-only"وهكذا) - مزخرفين مكدّسين — ثلاث كلمات مرور جيدة في لاحقتين،
""و"!"— يتحققان من أن الست كلها تُرجع[] - جدولاً واحداً يخلط الحالات العادية (
nullcontext) مع حالاتTypeError(pytest.raises) — وNoneوالعدد الصحيح12345678مدخلان جيدان للنوع الثاني - حالة واحدة موسومة بـ
xfailلقاعدة لم تكتبها بعد، مثل "لا يوجد حرف كبير"
شغّل pytest -v واقرأ قائمة المعرّفات كأن شخصاً آخر كتبها. هل تستطيع معرفة قواعد كلمة المرور من المعرّفات وحدها؟ إن لم تستطع، أعد تسمية الحالات حتى تستطيع.
ثم اكسر توقعاً واحداً عن قصد وشغّل من جديد. تحقّق من أن سطراً واحداً بالضبط يفشل، وأن معرّفه يخبرك أي حالة هي، وأن كل الحالات الأخرى نُفّذت.
الحل
ابدأ بالعقد، ثم الجدول. تأخذ check_password قيمة str وتُرجع قائمة بكل مشكلة وُجدت، بترتيب ثابت؛ وأي قيمة ليست str تطلق TypeError بدلاً من إرجاع أي شيء. password.py:
def check_password(text):
if not isinstance(text, str):
raise TypeError(f"password must be a str, got {type(text).__name__}")
problems = []
if len(text) < 8:
problems.append("too short")
if not any(ch.isdigit() for ch in text):
problems.append("no digit")
if not any(ch.isalpha() for ch in text):
problems.append("no letter")
return problems$ python3 -c "from password import check_password as c; print(c('abcdef12'), c('ab1'), c(''))"
[] ['too short'] ['too short', 'no digit', 'no letter']test_password.py:
from contextlib import nullcontext
import pytest
from password import check_password
@pytest.mark.parametrize(
"text,expected",
[
pytest.param("abcdef12", [], id="eight-chars-ok"),
pytest.param("abcde12", ["too short"], id="seven-chars"),
pytest.param("abcdefgh", ["no digit"], id="letters-only"),
pytest.param("12345678", ["no letter"], id="digits-only"),
pytest.param("", ["too short", "no digit", "no letter"], id="empty"),
pytest.param("ab1", ["too short"], id="short-but-mixed"),
pytest.param(
"abcdef12",
["no uppercase"],
id="needs-uppercase",
marks=pytest.mark.xfail(reason="uppercase rule not written yet"),
),
],
)
def test_problems(text, expected):
assert check_password(text) == expected
@pytest.mark.parametrize("suffix", ["", "!"])
@pytest.mark.parametrize("good", ["abcdef12", "pass1234", "9lives9lives"])
def test_good_passwords(good, suffix):
assert check_password(good + suffix) == []
@pytest.mark.parametrize(
"value,expectation",
[
pytest.param("abcdef12", nullcontext([]), id="str-ok"),
pytest.param("short", nullcontext(["too short", "no digit"]), id="str-problems"),
pytest.param(None, pytest.raises(TypeError), id="none"),
pytest.param(12345678, pytest.raises(TypeError, match="int"), id="int"),
],
)
def test_type_or_problems(value, expectation):
with expectation as expected:
assert check_password(value) == expected$ pytest -v test_password.py
collecting ... collected 17 items
test_password.py::test_problems[eight-chars-ok] PASSED [ 5%]
test_password.py::test_problems[seven-chars] PASSED [ 11%]
test_password.py::test_problems[letters-only] PASSED [ 17%]
test_password.py::test_problems[digits-only] PASSED [ 23%]
test_password.py::test_problems[empty] PASSED [ 29%]
test_password.py::test_problems[short-but-mixed] PASSED [ 35%]
test_password.py::test_problems[needs-uppercase] XFAIL (uppercase ru...) [ 41%]
test_password.py::test_good_passwords[abcdef12-] PASSED [ 47%]
test_password.py::test_good_passwords[abcdef12-!] PASSED [ 52%]
test_password.py::test_good_passwords[pass1234-] PASSED [ 58%]
test_password.py::test_good_passwords[pass1234-!] PASSED [ 64%]
test_password.py::test_good_passwords[9lives9lives-] PASSED [ 70%]
test_password.py::test_good_passwords[9lives9lives-!] PASSED [ 76%]
test_password.py::test_type_or_problems[str-ok] PASSED [ 82%]
test_password.py::test_type_or_problems[str-problems] PASSED [ 88%]
test_password.py::test_type_or_problems[none] PASSED [ 94%]
test_password.py::test_type_or_problems[int] PASSED [100%]
======================== 16 passed, 1 xfailed in 0.01s =========================لماذا كُتب بهذه الطريقة:
- زوج الحدود يأتي أولاً. تقع
eight-chars-okوseven-charsعلى جانبي قاعدة الطول، وهو المكان الذي يظهر فيه الخلط بين<و<=. وكل قاعدة أخرى تأخذ حالة واحدة تكسر تلك القاعدة وحدها، فيشير الفشل إلى سطر واحد بالضبط فيcheck_password. - الحالة
emptyتفحص ترتيب القائمة. إنها الحالة الوحيدة التي تكسر القواعد الثلاث معاً، فتثبّت أن المشكلات تعود بترتيب ثابت — وهو أمر سيعتمد عليه أي كود يعرضها على المستخدم. - القيم المتوقعة قوائم جديدة في كل حالة، والاختبار يقرؤها فقط. لا شيء يُعدَّل، فلا يمكن أن يحدث هنا فخ الكائن المشترك من قسم "الأخطاء الشائعة وحلولها".
- التكديس يناسب
test_good_passwordsلأن اللاحقة وكلمة المرور مستقلتان: كل كلمة مرور جيدة يجب أن تبقى جيدة مع!أو بدونها. والمعرّفانabcdef12-وabcdef12-!مقروءان دون مساعدة، فلم تكن هناك حاجة إلىids=. - جدول الأخطاء يستخدم معرّفات
pytest.paramلأن كائناتnullcontextوpytest.raisesكانت ستظهر لولا ذلك بالشكلexpectation0وexpectation1. ويتحققmatch="int"من أن الرسالة تذكر النوع الخاطئ، لا من أنTypeErrorما قد حدث فحسب. - حالة
xfailتدوّن قاعدة مستقبلية دون أن تكسر التشغيل. وحين تُضاف قاعدة الحرف الكبير، ستبدأ تلك الحالة بالنجاح، وسيبلغ عنها pytest بـXPASS— وهي الإشارة لإزالة الوسم.
قراءة المعرّفات من الأعلى إلى الأسفل تعطيك القواعد: ثمانية أحرف، ورقم واحد على الأقل، وحرف واحد على الأقل، ونصوص فقط، وحرف كبير مخطط له. هذا هو اختبار ما إذا كانت الأسماء جيدة بما يكفي.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz