اكتشاف الاختبارات وتشغيلها — ما الذي يشغّله pytest وكيف تختار
أي الملفات والدوال والأصناف يعدّها pytest اختبارات، واستيراد كودك من بنية src/ و tests/، ومعرّفات العقد، وتشغيل ما تحتاجه بالضبط بـ -k و -x و --lf و --ff و --co.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
حتى الآن عاش كل اختبار في ملف واحد، بجوار الكود الذي يختبره مباشرةً، وكان يكفي أن تكتب pytest وحده. لكن المشاريع الحقيقية لا تبقى بهذا الصغر. ينتقل الكود إلى حزمة (package)، وتنتقل الاختبارات إلى مجلد خاص بها، ويبدو التشغيل التالي مباشرةً هكذا:
$ pytest -q
==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_cart.py ______________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_cart.py:1: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sلم يُنفَّذ اختبار واحد. الكود سليم؛ كل ما في الأمر أن pytest لم يستطع العثور عليه.
وما إن يُصلَح ذلك حتى تظهر مشكلة ثانية: صار لديك عشرات الاختبارات، يفشل واحد منها، وفي كل إعادة تشغيل تُنفَّذ كلها من جديد. أنت تريد تشغيل ذلك الاختبار وحده — أو تلك التي فشلت في المرة الماضية فقط.
المشكلتان تنبعان من المكان نفسه. قبل أن يشغّل pytest أي شيء، فإنه يجمع (collect): يتجوّل في المجلدات، ويستورد الملفات، ويبني قائمة بالاختبارات. هذا الفصل يدور حول كيفية بناء تلك القائمة، وكيفية الاختيار منها.
بنهاية هذا الفصل ستكون قادراً على
- ذكر القواعد التي يقرر بها
pytestما هو الاختبار - تنظيم مشروع باستخدام
src/وtests/، وتمكين الاختبارات من استيراد الكود - تجميع الاختبارات في أصناف (classes)، وقراءة معرّف عقدة (node ID) مثل
tests/test_cart.py::TestCart::test_total_adds_tax - تشغيل ملف واحد، أو صنف واحد، أو اختبار واحد، أو مجموعة مختارة بـ
-k - استخدام
-xو--maxfailو--lfو--ffو--coو--durationsفي العمل اليومي - قراءة رمز الخروج (exit code) الذي يعيده
pytestوتفسير معناه
المتطلبات المسبقة: assert وتقارير الفشل.
قبل أن تكتب الاختبار
في هذا الفصل، السؤال الذي يسبق أول سطر من كود الاختبار ليس ماذا سنختبر فحسب، بل أين سيعيش الاختبار وكيف سيعثر عليه `pytest`. الاختبار الذي لا يُجمَع أبداً أسوأ من عدم وجود اختبار: يبدو حمايةً وهو ليس كذلك.
1. اعرف الوعد. الكود الذي سنختبره حزمة صغيرة لمتجر:
apply_discount(price, percent)تعيد السعر بعد خفضه بنسبةpercent، مقرّباً إلى منزلتين عشريتين. والنسبة خارج المدى 0–100 تطلقValueError.add_tax(price, rate=0.15)تعيد السعر مضافاً إليه الضريبة، مقرّباً إلى منزلتين عشريتين.Cartتجمع عناصر على شكل(name, price, quantity)؛ وsubtotal()تضرب في الكمية؛ وtotal()هي المجموع الفرعي مع الضريبة.
2. جهّز البيئة. بيئة افتراضية مثبّت فيها pytest؛ وكود منظّم في src/shop/؛ و — الجزء الذي يتناوله هذا الفصل — طريقة تمكّن الاختبارات من تنفيذ import shop. إن غاب هذا الجزء الأخير فلا قيمة لشيء آخر.
3. خطّط للحالات، وقرّر أين تعيش كل حالة. ملف اختبار لكل وحدة (tests/test_pricing.py يقابل src/shop/pricing.py)، واختبارات السلة مجمّعة في صنف واحد. كتابة معرّف العقدة في الخطة تجبرك على اتخاذ هذا القرار مسبقاً:
| الحالة | المدخل | المتوقع | Node ID | |---|---|---|---| | خصم عادي | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | حدّ: بلا خصم | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | الضريبة الافتراضية | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | حالة طرفية: سلة فارغة | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | الكمية تُحتسب | 3 أقلام بسعر 15.0 | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | الإجمالي يشمل الضريبة | حقيبة واحدة بسعر 100.0 | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | نسبة غير صالحة | apply_discount(200.0, 150) | ValueError | لاحقاً — لاختبار الاستثناءات فصل خاص |
4. قرّر ما لن تختبره. لا تختبر round() ولا list.append — سلوك بايثون نفسه ليس وعدك أنت. ولا تختبر أن Cart.items قائمة من الصفوف (tuples)؛ فهذه تفصيلة داخلية قد تغيّرها غداً. اختبر ما يراه المستدعي: subtotal() و total().
والآن يبني الفصل هذه الخطة بالضبط، وكل مشكلة على الطريق هي مشكلة اكتشاف.
قواعد الاكتشاف
لا يشغّل pytest كل ما يجده. إنه يتبع أربع قواعد، وكل ما يقع خارجها يُتجاهَل دون كلمة واحدة:
- الملفات المسمّاة
test_*.pyأو*_test.py - الدوال في تلك الملفات التي تبدأ أسماؤها بـ
test - الأصناف التي تبدأ أسماؤها بـ
Test— والتي لا تملك دالة__init__ - الدوال الأعضاء (methods) داخل هذا الصنف التي تبدأ أسماؤها بـ
test
لماذا قواعد أصلاً بدلاً من "شغّل كل دالة"؟ لأن مشروعك مليء بدوال ليست اختبارات — دوال مساعدة، والكود المختبَر نفسه — ويحتاج pytest إلى طريقة يميّز بها بينها دون أن تسجّل أنت أي شيء.
أسرع طريقة لتصديق القواعد هي كسرها عمداً. test_rules.py:
def test_found():
assert True
def check_not_found():
assert False
class TestGroup:
def test_method_found(self):
assert True
def helper(self):
assert False
class CartTests:
def test_ignored(self):
assert False
class TestWithInit:
def __init__(self):
self.value = 1
def test_never_runs(self):
assert self.value == 1وبجانبه يحتوي rules_test.py على اختبار ناجح واحد هو test_suffix_style، ويحتوي helpers.py على test_in_helpers الذي ينفّذ assert False. والآن pytest -v:
collecting ... collected 3 items
rules_test.py::test_suffix_style PASSED [ 33%]
test_rules.py::test_found PASSED [ 66%]
test_rules.py::TestGroup::test_method_found PASSED [100%]
=============================== warnings summary ===============================
test_rules.py:22
/home/you/disc/test_rules.py:22: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: test_rules.py)
class TestWithInit:
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.01s =========================في المجلد خمسة أسطر assert False، والتشغيل أخضر. ما الذي تُرك، ولماذا:
check_not_found— الاسم لا يبدأ بـtest.TestGroup.helper— داخل صنف اختبار، لكن اسمه هو لا يبدأ بـtest. الدوال المساعدة مسموح بها؛ إنها فقط ليست اختبارات.CartTests— يجب أن يبدأ اسم الصنف بـTest.TestWithInit— علىpytestأن ينشئ الصنف بنفسه، دون أي وسائط، مرة لكل اختبار. والصنف الذي يملك__init__خاصاً به لا يمكن الوثوق بأنه يسمح بذلك، لذا يُتخطّى — مع تحذير، وهو الصوت الوحيد في التشغيل كله.helpers.py— اسم الملف لا يطابق أيّاً من النمطين، فلا يُستورد أصلاً.
الجزء الصامت هو الجزء الخطِر. إن كتبتdef tset_total():خطأً، بقيت مجموعة الاختبارات خضراء إلى الأبد. وحين "ينجح" اختبار جديد بسهولة مريبة، تحقّق من أنه جُمع أصلاً — و--coأدناه هو الوسيلة.
بنية حقيقية: src/ و tests/
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py
│ └── pricing.py
└── tests/
├── test_cart.py
└── test_pricing.pyيعيش الكود في src/shop/؛ وتعيش الاختبارات في tests/ ولا تُشحن مع الحزمة أبداً. هذه هي بنية src (src layout). وغايتها ألا يُستورد الكود "بالصدفة" لمجرد أنك تقف في مجلد المشروع — فتستورده الاختبارات بالطريقة نفسها التي يستورده بها مستخدم حقيقي.
src/shop/pricing.py:
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError(f"percent must be between 0 and 100: {percent}")
return round(price * (100 - percent) / 100, 2)
def add_tax(price, rate=0.15):
return round(price * (1 + rate), 2)src/shop/cart.py:
from shop.pricing import add_tax
class Cart:
def __init__(self):
self.items = []
def add(self, name, price, quantity=1):
self.items.append((name, price, quantity))
def subtotal(self):
return sum(price * quantity for _, price, quantity in self.items)
def total(self):
return add_tax(self.subtotal())tests/test_pricing.py — أول ثلاثة صفوف من الخطة:
from shop.pricing import add_tax, apply_discount
def test_discount_ten_percent():
assert apply_discount(200.0, 10) == 180.0
def test_discount_zero():
assert apply_discount(200.0, 0) == 200.0
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0سطر from shop.pricing import ... هذا هو بالضبط ما أنتج خطأ ModuleNotFoundError في بداية الفصل.
لماذا لا يُعثر على shop
ينجح import shop فقط إذا احتوى مجلدٌ ما على sys.path مجلداً باسم shop. الحزمة موجودة في src/، و src/ ليس على sys.path. يضيف pytest مجلد كل ملف اختبار (هنا tests/) كي يستطيع الاختبار استيراد جيرانه — لكن لا أحد يضيف src/.
الطريقة الخاطئة هي ترقيع sys.path داخل ملف الاختبار:
import sys
sys.path.insert(0, "src")
from shop.cart import Cart
def test_empty_cart_subtotal():
assert Cart().subtotal() == 0من مجلد المشروع ينجح. ثم يشغّله أحدهم من داخل tests/:
$ cd tests
$ pytest -q
==================================== ERRORS ====================================
________________________ ERROR collecting test_cart.py _________________________
ImportError while importing test module '/home/you/shop/tests/test_cart.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_cart.py:5: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" مسار نسبي يُحسب من أي مكان تصادف أن تكون فيه، فصار الاختبار يعتمد على المجلد الذي تكتب فيه الأمر. ويحتاج كل ملف اختبار إلى الأسطر الثلاثة نفسها. أما الطريقتان الصحيحتان فهما:
الطريقة الصحيحة 1: أخبر pytest أين الكود. في pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]يضيف pythonpath المجلد src/ إلى sys.path قبل استيراد أي اختبار — محسوباً من جذر المشروع، لا من حيث تقف. ويحدّد testpaths أين يبحث pytest حين تكتبه وحده. والآن:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_cart.py ... [ 50%]
tests/test_pricing.py ... [100%]
============================== 6 passed in 0.01s ===============================السطر configfile: pyproject.toml في الترويسة هو الدليل على أن pytest قرأ إعداداتك. لملفات الإعداد فصل خاص بها لاحقاً؛ هذان السطران هما كل ما تحتاجه الآن.
الطريقة الصحيحة 2: ثبّت الحزمة. إن كان pyproject.toml يصف حزمة حقيقية (جدول [build-system] وجدول [project])، فشغّل pip install -e . مرة واحدة. هذا يثبّت الحزمة في وضع قابل للتحرير (editable): صار import shop يعمل في كل مكان — في الاختبارات و REPL والسكربتات — وتظهر التعديلات في src/ دون إعادة تثبيت. بعدها يعطي pytest -q النتيجة نفسها 6 passed دون أي pythonpath. هذا ما تفعله أغلب المشاريع المحزّمة؛ و pythonpath هو الخيار الأسرع حين لا يكون المشروع حزمة.
تجميع الاختبارات في أصناف
يضع tests/test_cart.py صفوف السلة من الخطة في صنف واحد:
from shop.cart import Cart
class TestCart:
def test_empty_cart_subtotal(self):
assert Cart().subtotal() == 0
def test_subtotal_counts_quantity(self):
cart = Cart()
cart.add("pen", 15.0, quantity=3)
assert cart.subtotal() == 45.0
def test_total_adds_tax(self):
cart = Cart()
cart.add("bag", 100.0)
assert cart.total() == 115.0$ pytest -q tests/test_cart.py
... [100%]
3 passed in 0.01sلا يوجد صنف أساسي يجب الوراثة منه — TestCart صنف عادي، و self مجرد المعامل الأول الإلزامي. فلماذا الصنف؟ لأنه يمنح الاختبارات المترابطة اسماً واحداً، ويصير ذلك الاسم شيئاً يمكنك اختياره: شغّل "كل اختبارات السلة" بوسيط واحد.
أما ما ليس الصنف من أجله فهو مشاركة الحالة. ينشئ pytest نسخة جديدة لكل دالة اختبار، فالقيمة المخزّنة على self في اختبار تختفي في الذي يليه. وهذا مقصود — فالاختبارات التي تعتمد على بقايا بعضها تنجح أو تفشل بحسب ترتيب تشغيلها. كل اختبار أعلاه ينشئ Cart() الخاص به. والأداة الصحيحة للإعداد المشترك هي fixtures، في فصل لاحق.
معرّفات العقد: عنوان الاختبار
اطلب من pytest أن يسرد ما جمعه دون تشغيل أي شيء — --collect-only، أو اختصاراً --co:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
6 tests collected in 0.01sقارنها بالعمود الأخير من الخطة: كل صف موجود، في العنوان الذي اخترته. كل سطر هو معرّف عقدة (node ID) — مسار الملف، ثم ::، ثم الصنف إن وُجد، ثم :: واسم الاختبار. وهو النص نفسه الذي يظهر بعد FAILED في ملخص الفشل، فيمكنك نسخه من الفشل ولصقه مباشرةً في سطر الأوامر.
اختيار ما تشغّله
بالمسار أو معرّف العقدة
الطريقة الخاطئة لتشغيل اختبار واحد هي تعليق الباقي، أو إعادة تسميتها كي يتخطّاها pytest — ثم نسيان التراجع عن ذلك. والطريقة الصحيحة هي إعطاء pytest عنواناً:
$ pytest -q tests/test_pricing.py
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart::test_total_adds_tax
. [100%]
1 passed in 0.01sمجلد، ملف، صنف، اختبار واحد — كل مستوى من معرّف العقدة يضيّق الاختيار، ولا يُمسّ الكود أبداً.
بالاسم: -k
يأخذ -k تعبيراً ويُبقي الاختبارات التي تطابقه أسماؤها:
$ pytest -q -k discount
.. [100%]
2 passed, 4 deselected in 0.01sالمطابقة بحث عن جزء من النص لا يميّز بين الأحرف الكبيرة والصغيرة، ويمكن ربط الكلمات بـ and و or و not والأقواس. ضع التعبير بين علامتي اقتباس كي يمرّره الـ shell وسيطاً واحداً:
$ pytest -q -k "discount or tax"
.... [100%]
4 passed, 2 deselected in 0.01s
$ pytest -q -k "TestCart and not tax"
.. [100%]
2 passed, 4 deselected in 0.01sولهذا تستحق أسماء الاختبارات أن تُختار بعناية: يمكن العثور على test_discount_zero بـ discount أو بـ zero أو بكليهما. أما اسم مثل test_2 فلا يُعثر عليه بشيء.
تفصيلة واحدة توقع الجميع مرة. يطابق -k اسم الاختبار و الأسماء التي فوقه — صنفه وملفه:
$ pytest --co -q -k "not cart"
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
3/6 tests collected (3 deselected) in 0.01sلا يحتوي اسم أيٍّ من اختبارات TestCart الثلاثة على cart، ومع ذلك أُسقطت الثلاثة — لأنها تعيش في test_cart.py. حين يفاجئك اختيار بـ -k، أضف --co وانظر قبل أن تشغّل.
أما الاختيار بوسم تضعه أنت بدلاً من الاسم، فهو عمل العلامات (markers) و -m، في الفصل الثاني عشر.
حين يفشل شيء
اكسر الكود عمداً: غيّر القيمة الافتراضية لـ rate في add_tax من 0.15 إلى 0.18. الآن يفشل اختباران. (يخفي --tb=no تقارير الفشل كي يظهر شكل التشغيل؛ وقد تعلّمت قراءة تلك التقارير في الفصل السابق.)
$ pytest -q --tb=no
..F..F [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01sيتوقف -x عند أول فشل. حين تُفشل دالة معطوبة واحدة عشرة اختبارات، يكون التقرير الأول هو الجدير بالقراءة؛ والتسعة الباقية أصداء له:
$ pytest -q -x --tb=no
..F
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 2 passed in 0.01sيتوقف --maxfail=N بعد N من حالات الفشل. و -x هو بالضبط --maxfail=1.
يعيد --lf (last failed) تشغيل ما فشل في المرة الماضية فقط. يتذكّر pytest حالات الفشل في مجلد .pytest_cache داخل المشروع:
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 2 deselected in 0.01sلم يُشغَّل إلا الاختباران الفاشلان. أعد النسبة كما كانت، فيشغّل --lf هذين الاثنين مجدداً وينجحان. شغّل --lf مرة أخرى ولم يبقَ شيء فاشل، فيطبع pytest العبارة run-last-failure: no previously failed tests, not deselecting items. ويشغّل كل شيء.
يشغّل --ff (failed first) الاختبارات الفاشلة أولاً، ثم كل ما تبقّى. تأتي الإجابة السريعة في الأعلى، والفحص الكامل بعدها:
$ pytest -q --ff --tb=no
FF.... [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01sFF.... — نُفّذ الاختباران الفاشلان أولاً، قبل الأربعة الناجحة.
دورة جيدة أثناء إصلاح خطأ: pytest --lf -x حتى يصير أخضر، ثم pytest وحده مرة واحدة. لماذا الخطوة الأخيرة؟ لأن --lf يعيد تشغيل الأحمر فقط — ولا يستطيع أن يخبرك بأن إصلاحك كسر شيئاً كان أخضر.
مخرجات اختباراتك: الالتقاط و -s
يبدو أن print داخل الاختبار لا يفعل شيئاً. (الخيار --tb=short أدناه يقصّر تقرير الفشل فقط.) يلتقط pytest (capture) كل ما يكتبه الاختبار على الشاشة، ولا يعرضه إلا إذا فشل ذلك الاختبار:
def test_quiet():
print("subtotal is", 45.0)
assert 45.0 == 45.0
def test_loud():
print("subtotal is", 40.0)
assert 40.0 == 45.0$ pytest -q --tb=short
.F [100%]
=================================== FAILURES ===================================
__________________________________ test_loud ___________________________________
test_print.py:8: in test_loud
assert 40.0 == 45.0
E assert 40.0 == 45.0
----------------------------- Captured stdout call -----------------------------
subtotal is 40.0
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sاختفت مخرجات الاختبار الناجح؛ أما مخرجات الفاشل فمرفقة بتقريره تحت Captured stdout call. ومع مئات الاختبارات هذا ما تريده — صمت حين يكون كل شيء بخير، ودليل حين لا يكون. ولرؤية كل شيء لحظة حدوثه، يوقف -s الالتقاط:
$ pytest -q -s --tb=no
subtotal is 45.0
.subtotal is 40.0
F
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sرموز الخروج
حين ينتهي pytest يسلّم الـ shell رقماً. وأنظمة CI تقرّر "أخضر" أو "أحمر" من هذا الرقم وحده:
| الرمز | المعنى | |---|---| | 0 | نجحت كل الاختبارات المجموعة | | 1 | نُفّذت الاختبارات وفشل واحد منها على الأقل | | 2 | انقطع التشغيل — خطأ أثناء الجمع، أو Ctrl+C | | 3 | خطأ داخلي في pytest أو في إضافة (plugin) | | 4 | خطأ في الاستخدام — خيار غير معروف، أو مسار أو معرّف عقدة غير موجود | | 5 | لم يُجمع أي اختبار |
الرمز 5 يستحق الانتباه. فـ -k الذي لا يطابق شيئاً، أو المجلد الذي لا يحوي ملفات بأسماء صحيحة، ليس نجاحاً — وأنظمة CI تعامله فشلاً، وهذا هو الصواب تماماً:
$ pytest -q -k refund
6 deselected in 0.01s
$ echo $?
5انتهى خطأ ModuleNotFoundError في بداية الفصل بالعبارة Interrupted: 1 error during collection — رمز الخروج 2. خطأ الجمع ليس اختباراً فاشلاً؛ فـ pytest لم يصل أصلاً إلى تشغيل الاختبارات.
مثال تطبيقي متكامل
نُفّذت الخطة وهي خضراء. والآن ينضم اختبار بطيء إلى tests/test_cart.py، في صنف خاص به:
from shop.cart import Cart
class TestBigCart:
def test_many_items(self):
cart = Cart()
for _ in range(300_000):
cart.add("pen", 1.0)
assert cart.subtotal() == 300_000.0ما زال الملف يحتوي TestCart فوق هذا الصنف. تحقّق أولاً مما يراه pytest، قبل تشغيل أي شيء:
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_cart.py::TestBigCart::test_many_items
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
7 tests collected in 0.01sسبعة اختبارات، لكل منها عنوان. شغّلها كلها واطلب أبطأ ثلاثة:
$ pytest -q --durations=3
....... [100%]
============================= slowest 3 durations ==============================
0.04s call tests/test_cart.py::TestBigCart::test_many_items
(2 durations < 0.005s hidden. Use -vv to show these durations.)
7 passed in 0.12sاختبار واحد فقط كان بطيئاً بما يكفي ليُدرج. في مشروع حقيقي، --durations=10 هو الطريقة للعثور على الحفنة من الاختبارات التي تجعل المجموعة كلها تبدو بطيئة. وأثناء العمل على السلة، استبعد البطيء:
$ pytest -q -k "cart and not many"
... [100%]
3 passed, 4 deselected in 0.01sثلاثة أمور جعلت هذا ممكناً. pythonpath = ["src"] هو ما يجعل from shop.cart import Cart ينجح أصلاً. ويُجمَع TestBigCart لأن اسمه يبدأ بـ Test ولا يملك __init__. أما -k "cart and not many" فأبقى اختبارات TestCart بالضبط: طابقت cart كل ما في test_cart.py عبر اسم الملف، وأزالت not many الاختبار البطيء.
الأخطاء الشائعة وحلولها
ModuleNotFoundError: No module named 'shop' أثناء الجمع الكود المختبَر ليس على sys.path. في بنية src/ أضف pythonpath = ["src"] تحت [tool.pytest.ini_options]، أو ثبّت الحزمة بـ pip install -e .. وينتهي التشغيل بـ Interrupted: 1 error during collection ورمز الخروج 2.
يعمل مع python -m pytest لكن لا يعمل مع pytest يضع python -m pytest المجلد الحالي أيضاً على sys.path؛ أما pytest وحده فلا. لذا فالوحدة الموجودة في جذر المشروع تُستورد بطريقة ولا تُستورد بالأخرى. لا تعتمد على هذا الفرق — اضبط pythonpath، أو ثبّت الحزمة.
PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor يجب ألا تعرّف أصناف الاختبار __init__. احذفها، وأنشئ ما يحتاجه كل اختبار داخل الاختبار نفسه.
no tests ran، رمز الخروج 5 لم يطابق شيء قواعد الاكتشاف. تحقّق من اسم الملف (test_*.py أو *_test.py)، وأسماء الدوال (test...)، وأسماء الأصناف (Test...). وإن استخدمت -k فلعلّه لا يطابق شيئاً.
ERROR: not found: .../tests/test_cart.py::test_total_adds_tax معرّف العقدة خاطئ. هذا الاختبار يعيش داخل TestCart، فعنوانه tests/test_cart.py::TestCart::test_total_adds_tax. انسخ معرّفات العقد من pytest --co -q بدلاً من كتابتها يدوياً. رمز الخروج 4.
ERROR: file or directory not found: tests/test_carts.py خطأ مطبعي في المسار. رمز الخروج 4.
pytest: error: unrecognized arguments: --maxfial=2 خيار مكتوب خطأً؛ يسرد pytest --help الخيارات الحقيقية. رمز الخروج 4.
Step 4 of 6 — Predict
Check your understanding
حُفظ هذا الملف باسم test_rules.py. عند تشغيل pytest -q، كم اختباراً ناجحاً يذكر السطر الأخير؟
def test_found():
assert True
def check_total():
assert True
class TestGroup:
def test_method(self):
assert True
def helper(self):
assert True
class CartTests:
def test_ignored(self):
assert True- A3 passed
- B4 passed
- C2 passed
- D1 passed
الكود موجود في src/shop/pricing.py. ويحتوي pyproject.toml على pythonpath = ["shop"] تحت [tool.pytest.ini_options]. ومع ذلك يبلّغ pytest عن ModuleNotFoundError: No module named 'shop'. ما الخطأ؟
# tests/test_pricing.py
from shop.pricing import add_tax
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0- Aيجب أن ينتهي اسم ملف الاختبار بـ `_test.py`
- Bيجب أن يشير `pythonpath` إلى المجلد الذي *يحتوي* الحزمة `shop`: `["src"]`
- Cيجب أن يبدأ اسم الدالة بـ `Test`
- Dيحتاج `tests/` إلى ملف `__init__.py` قبل أن يمكن استيراد أي شيء
كان اختباران يفشلان وقد أصلحت الخطأ. أي أمر يعيد تشغيل هذين الاختبارين فقط؟
- Apytest --ff
- Bpytest -x
- Cpytest --co
- Dpytest --lf
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.
دورك الآن
تفرض مكتبة غرامة على الكتب المتأخرة. ابنِ هذا المشروع:
library/
├── src/
│ └── library/
│ ├── __init__.py
│ └── fees.py
└── tests/
└── test_fees.pysrc/library/fees.py:
def late_fee(days_late, daily_rate=5):
if days_late < 0:
raise ValueError(f"days_late cannot be negative: {days_late}")
return min(days_late * daily_rate, 100)- اكتب خطة اختبار أولاً: في الموعد، متأخر بضعة أيام، عند الحد الأقصى (cap) تماماً، بعد الحد الأقصى، معدّل يومي مخصّص.
- اكتب
tests/test_fees.pyبهذه الحالات كدوال أعضاء في صنف واحد هوTestLateFee. شغّلpytestدونpyproject.toml، واقرأ الخطأ، وتحقّق منecho $?. - أضف
pyproject.tomlكي تستطيع الاختبارات استيرادlibrary. واصل حتى يصير أخضر. - اسرد معرّفات العقد بـ
--co -q. شغّل اختبار "بعد الحد الأقصى" وحده بمعرّف عقدته. - استخدم
-kلتشغيل اختبارات الحد الأقصى فقط، ثم كل شيء ما عداها. - غيّر الحد الأقصى في
fees.pyمن100إلى90. شغّل المجموعة، ثم--lf. أعده كما كان وشغّل--lf، ثم كل شيء.
الحل
الخطة:
| الحالة | المدخل | المتوقع | |---|---|---| | في الموعد | late_fee(0) | 0 | | متأخر بضعة أيام | late_fee(3) | 15 | | حدّ: عند الحد الأقصى تماماً | late_fee(20) | 100 | | بعد الحد الأقصى | late_fee(30) | 100 | | معدّل مخصّص | late_fee(3, daily_rate=2) | 6 | | أيام سالبة | late_fee(-1) | ValueError — لاحقاً، مع pytest.raises |
tests/test_fees.py:
from library.fees import late_fee
class TestLateFee:
def test_on_time_costs_nothing(self):
assert late_fee(0) == 0
def test_three_days_late(self):
assert late_fee(3) == 15
def test_fee_reaches_cap_exactly(self):
assert late_fee(20) == 100
def test_fee_never_exceeds_cap(self):
assert late_fee(30) == 100
def test_custom_daily_rate(self):
assert late_fee(3, daily_rate=2) == 6دون إعداد، يتوقف التشغيل عند الجمع بـ ModuleNotFoundError: No module named 'library' و Interrupted: 1 error during collection، ويطبع echo $? الرقم 2. ثم pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
..... [100%]
5 passed in 0.01s
$ pytest --co -q
tests/test_fees.py::TestLateFee::test_on_time_costs_nothing
tests/test_fees.py::TestLateFee::test_three_days_late
tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly
tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
tests/test_fees.py::TestLateFee::test_custom_daily_rate
5 tests collected in 0.01s
$ pytest -q tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
. [100%]
1 passed in 0.01s
$ pytest -q -k cap
.. [100%]
2 passed, 3 deselected in 0.01s
$ pytest -q -k "not cap"
... [100%]
3 passed, 2 deselected in 0.01sبعد تغيير الحد الأقصى إلى 90:
$ pytest -q --tb=no
..FF. [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 passed in 0.01s
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 deselected in 0.01sوبعد إعادته إلى 100، يعطي pytest -q --lf النتيجة 2 passed, 3 deselected، ويعطي pytest -q الكامل 5 passed.
لماذا هذه القرارات:
- اختباران للحد الأقصى، لا واحد. يقع
late_fee(20)على الحد تماماً، أول يوم تبلغ فيه الغرامة الحد الأقصى؛ وlate_fee(30)بعده بكثير. أخطاء الإزاحة بواحد (off-by-one) تعيش عند الحدود، لذا ينال الحد اختباره الخاص — ويثبت الثاني أن الحد الأقصى يصمد بعده أيضاً. - كلمة
capموجودة في اسمي اختباري الحد الأقصى، وفيهما فقط. هذا ما جعل-k capو-k "not cap"يختاران بنظافة — فقد اختيرت الأسماء مع وضع الاختيار في الحسبان. - صنف واحد، بلا
__init__، بلا حالة مشتركة. كل دالة تستدعيlate_feeبنفسها؛ والصنف يمنح المجموعة عنواناً واحداً فقط،tests/test_fees.py::TestLateFee. pythonpathبدلاً من سطرsys.pathفي ملف الاختبار، كي تنجح الاختبارات من أي مجلد تشغّلها منه.- التشغيل الكامل بعد
--lf. يثبت--lfأن الاختبارين الأحمرين صارا أخضرين؛ والتشغيل الكامل وحده يثبت أن الثلاثة الأخرى ما زالت خضراء.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz