الفصل 01

تثبيت pytest واختبارك الأول

لماذا يتوقف التحقق اليدوي عن النفع، ولماذا لا تخبرك assert المجردة بشيء تقريباً، وكيف تثبّت pytest وتكتب أول ملف اختبار وتشغّله. مع قراءة النقاط وسطر الملخص و -q و -v ورموز الخروج.

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

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

إليك دالة صغيرة، والطريقة التي يتحقق بها معظمنا من دالة كهذه — نشغّلها وننظر إلى ما يخرج منها:

python
def discounted(price, percent):
    return price - price * percent / 100


print(discounted(200, 10))
print(discounted(80, 25))
print(discounted(99, 0))
text
180.0
60.0
99.0

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

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

والثانية أسوأ. بعد شهر يأتي أحدهم و"يرتّب" الدالة:

python
def discounted(price, percent):
    return price * (1 - percent // 100)
text
$ python -c "from prices import discounted; print(discounted(200, 10))"
200

تبدو معقولة، وتعمل دون أي خطأ. لكن // قسمة صحيحة، فيكون 10 // 100 مساوياً لـ 0، وقد اختفى كل خصم بصمت. الكود الذي كان يعمل ثم توقف عن العمل يسمى تراجعاً (regression)، والتراجع هو بالضبط ما يفوت التحقق اليدوي — لأن أحداً لا يعود ليتحقق من الأرقام القديمة من جديد.

الحل أن تكتب الإجابات المتوقعة في الكود نفسه، كي تتحقق منها الآلة في كل مرة. ولدى بايثون أصلاً تعليمة لهذا الغرض: assert. ضع عمليات التحقق في ملف باسم check_prices.py بجوار prices.py:

python
from prices import discounted

assert discounted(200, 10) == 180.0
assert discounted(80, 25) == 60.0
assert discounted(99, 0) == 99
print("all good")

مع الدالة الأصلية:

text
$ python check_prices.py
all good

ومع النسخة "المرتّبة":

text
$ python check_prices.py
Traceback (most recent call last):
  File "/home/you/shop/check_prices.py", line 3, in <module>
    assert discounted(200, 10) == 180.0
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

لقد اكتُشف التراجع، وهذا تقدّم. لكن انظر إلى ما يُقال لك: AssertionError، ولا شيء غيره. لا تعرف ما الذي أعادته discounted(200, 10) فعلاً. كما أن السكربت توقف عند أول إخفاق، فلا تعرف إن كانت عمليتا التحقق الأخريان تنجحان.

في هذا الفصل نثبّت الأداة التي تعالج المشكلتين معاً: pytest.

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

  • شرح سبب قدرة الاختبارات الآلية على اكتشاف التراجعات التي يفوتها التحقق اليدوي
  • تثبيت pytest باستخدام uv أو pip، والتأكد من ذلك بالأمر pytest --version
  • كتابة ملف اختبار يعثر عليه pytest من تلقاء نفسه، وتشغيله
  • قراءة النقاط وسطر الملخص، والتبديل بين -q و -v
  • توضيح معنى رمزي الخروج 0 و 1، ولماذا يهمّ ذلك

المتطلبات المسبقة: قبل أن تكتب اختباراً. وتحتاج كذلك إلى Python 3 وإلى معرفة كيفية كتابة دالة.


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

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

1. مجلد واحد للمشروع، وبيئة واحدة. كل ما في هذا الفصل يقع في مجلد واحد:

text
shop/
├── .venv/            the project's own Python environment
├── pyproject.toml    created by uv; lists pytest as a dev dependency
├── prices.py         the code under test
└── test_prices.py    the tests

لماذا بيئة خاصة بالمشروع؟ لأن pytest يجب أن يُثبَّت في نسخة بايثون نفسها التي ستستورد كودك. فإذا كان pytest في نسخة بايثون، وحزم مشروعك في نسخة أخرى، فستحصل على أخطاء تبدو كأن الكود مفقود، بينما المشكلة الحقيقية أداة مفقودة. وجود .venv لكل مشروع يزيل هذا التخمين.

2. تثبيت pytest في تلك البيئة — وهذا ما يفعله القسم التالي.

3. أن يكون الكود قابلاً للاستيراد. ملف الاختبار بايثون عادي: سطره الأول سيكون from prices import discounted. لذا، قبل كتابة أي اختبار، تحقق من أن هذا الاستيراد بعينه يعمل من مجلد المشروع:

text
$ python -c "from prices import discounted; print(discounted(200, 10))"
180.0

إذا أخفق هذا السطر، فسيخفق pytest بالطريقة نفسها — لكن داخل تقرير أطول. التحقق منه وحده يستغرق خمس ثوانٍ، ويفصل بين "لا يمكن العثور على كودي" و"كودي خاطئ". ويعني ذلك أيضاً أن اسم الوحدة يجب أن يكون اسماً صالحاً في بايثون: يمكن استيراد prices.py، أما my-prices.py و 2prices.py فلا.

4. الوعد مكتوباً على شكل حالات. قبل كتابة الاختبار، دوّن ما تَعِد به discounted، صفاً لكل حالة:

| الحالة | المدخل | المتوقع | | --- | --- | --- | | خصم معتاد | discounted(200, 10) | 180.0 | | خصم ثانٍ مختلف | discounted(80, 25) | 60.0 | | حالة حدّية: بلا خصم إطلاقاً | discounted(99, 0) | 99 |

وبالقدر نفسه من التعمّد، دوّن ما ليس في القائمة. النسبة السالبة، أو التي تتجاوز 100: الدالة لا تَعِد بشيء بشأنهما حتى الآن، والاختبار لن يفعل سوى تجميد ما يصادف أنها تفعله اليوم. (رفض المدخلات الخاطئة بخطأ وعدٌ مستقل بذاته — الفصل الرابع.) وكذلك الحساب في بايثون نفسها: أنت تختبر صيغتك أنت، لا أن * تضرب.

لماذا حالتان عاديتان لا حالة واحدة؟ انظر إلى الصف الثالث مقابل الدالة "المرتّبة" من بداية الفصل: 99 * (1 - 0 // 100) ما زال 99. حالة الخصم صفر بالمئة تنجح حتى مع وجود الخلل. الخطة المكوّنة من حالات حدّية فقط قد تفوّت الخطأ الذي يهمّ بالذات، والخطة المكوّنة من حالة معتادة واحدة تفوّت الحدود. كل صف موجود ليلتقط شيئاً لا تلتقطه الصفوف الأخرى.

بقية الفصل تحوّل هذا الجدول إلى ملف اختبار.

تثبيت pytest

ليس pytest جزءاً من بايثون؛ بل هو حزمة تثبّتها في مشروعك. وهو أداة لـتطوير الكود، لا شيء يحتاجه البرنامج أثناء تشغيله — لذا يُضاف بوصفه اعتمادية تطوير (development dependency).

إذا كان مشروعك يستخدم uv، فشغّل هذا الأمر داخل مجلد المشروع:

text
$ uv add --dev pytest
Resolved 7 packages in 59ms
Installed 5 packages in 18ms
 + iniconfig==2.3.1
 + packaging==26.3
 + pluggy==1.6.0
 + pygments==2.21.0
 + pytest==9.1.1

يسجّل uv الحزمة في ملف pyproject.toml ضمن مجموعة dev، فيحصل كل من يُعِدّ المشروع لاحقاً على الأداة نفسها:

toml
[dependency-groups]
dev = [
    "pytest>=9.1.1",
]

وإذا كنت تستخدم pip العادي داخل بيئة افتراضية، ففعّل البيئة ثم شغّل:

text
$ python -m pip install pytest

يطبع pip تقدّم التنزيل، ثم ينتهي بالسطر التالي:

text
Successfully installed iniconfig-2.3.1 packaging-26.3 pluggy-1.6.0 pygments-2.21.0 pytest-9.1.1

استخدام python -m pip بدلاً من pip وحده يضمن أن الحزمة تُثبَّت في نسخة بايثون نفسها التي ستشغّلها — وهذا مصدر شائع لعبارة "ثبّتها لكنها غير موجودة".

تحقّق الآن من نجاح التثبيت:

text
$ pytest --version
pytest 9.1.1

ملاحظة قبل المتابعة: مع uv تعمل الأوامر داخل بيئة المشروع حين تسبقها بـ uv run — أي uv run pytest --version. أما مع بيئة افتراضية مفعّلة فيكفي pytest وحده. في بقية هذه الدورة نكتب pytest مجرداً؛ أضف uv run قبله إن كانت هذه طريقتك في العمل.

ملف الاختبار الأول

قاعدتان للتسمية، ولا يحتاج pytest منك شيئاً آخر:

  • اسم الملف يبدأ بـ test_ (أو ينتهي بـ _test.py)
  • كل دالة تريد تشغيلها يبدأ اسمها بـ test

أعِد ملف prices.py الأصلي الصحيح، وأنشئ بجواره الملف test_prices.py:

python
from prices import discounted


def test_ten_percent_off():
    assert discounted(200, 10) == 180.0


def test_quarter_off():
    assert discounted(80, 25) == 60.0


def test_no_discount():
    assert discounted(99, 0) == 99

صار كل تحقق من check_prices.py دالة صغيرة مستقلة، ويدل اسم كل منها على ما يجري التحقق منه. لا يوجد print، ولا قائمة اختبارات تحتاج إلى تسجيل، ولا if __name__ == "__main__". شغّل الآن pytest من مجلد المشروع:

text
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 3 items

test_prices.py ...                                                       [100%]

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

بحث pytest في المجلد، فوجد test_prices.py بفضل اسمه، ووجد الدوال الثلاث بفضل أسمائها، وشغّل كلاً منها، ثم أعدّ تقريره.

قراءة المخرجات

لنأخذها سطراً سطراً.

  • platform ... — أي نسخة من بايثون ومن pytest جرى تشغيلها. يستحق نظرة سريعة حين تختلف النتائج بين جهازين.
  • rootdir: /home/you/shop — المجلد الذي يعدّه pytest قمة المشروع.
  • collected 3 items — عدد الاختبارات التي عثر عليها. ويسمى العثور على الاختبارات التجميع (collection). إذا كان هذا الرقم أقل مما تتوقع، فهناك اسم خاطئ في مكان ما.
  • test_prices.py ... — حرف واحد لكل اختبار، بترتيب تشغيلها. النقطة . تعني النجاح.
  • [100%] — مقدار التقدم في التشغيل كله.
  • 3 passed in 0.01s — سطر الملخص. وهو السطر الذي تقرؤه أولاً.

أعِد الآن الدالة discounted "المرتّبة" وشغّل مرة أخرى. هذه المرة مع -q، الذي سنتعرف عليه جيداً بعد قليل:

text
$ pytest -q
FF.                                                                      [100%]
=================================== FAILURES ===================================
_____________________________ test_ten_percent_off _____________________________

    def test_ten_percent_off():
>       assert discounted(200, 10) == 180.0
E       assert 200 == 180.0
E        +  where 200 = discounted(200, 10)

test_prices.py:5: AssertionError
_______________________________ test_quarter_off _______________________________

    def test_quarter_off():
>       assert discounted(80, 25) == 60.0
E       assert 80 == 60.0
E        +  where 80 = discounted(80, 25)

test_prices.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_prices.py::test_ten_percent_off - assert 200 == 180.0
FAILED test_prices.py::test_quarter_off - assert 80 == 60.0
2 failed, 1 passed in 0.01s

صار سطر التقدم الآن FF.: حرف F لكل إخفاق. وقارن هذا بـ AssertionError المجرد الذي رأيناه من قبل:

  • كل الاختبارات جرى تشغيلها — إخفاق أحدها لم يوقف البقية، فترى أن الخصم صفر بالمئة ما زال يعمل
  • كل إخفاق يذكر اسم الاختبار ورقم السطر
  • assert 200 == 180.0 تُظهر القيمة التي أعادتها الدالة فعلاً

قراءة هذا التقرير كاملاً هي موضوع الفصل التالي بأكمله. يكفيك الآن أن تلاحظ أن سطر الملخص تغيّر إلى 2 failed, 1 passed — وأن assert نفسها التي كنت تعرفها صارت الآن تشرح نفسها.

-q و -v: أقل وأكثر

المخرجات الافتراضية حلٌّ وسط. وهناك خياران يحرّكانها في أي من الاتجاهين.

الخيار -q (quiet، أي هادئ) يحذف الترويسة:

text
$ pytest -q
...                                                                      [100%]
3 passed in 0.01s

والخيار -v (verbose، أي مفصّل) يعطي كل اختبار سطراً خاصاً به، باسمه الكامل:

text
$ pytest -v
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /home/you/shop
collecting ... collected 3 items

test_prices.py::test_ten_percent_off PASSED                              [ 33%]
test_prices.py::test_quarter_off PASSED                                  [ 66%]
test_prices.py::test_no_discount PASSED                                  [100%]

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

test_prices.py::test_ten_percent_off هو معرّف العقدة (node ID) للاختبار: اسم الملف، ثم نقطتان مزدوجتان، ثم اسم الدالة. ستستخدمه لاحقاً لتشغيل اختبار واحد بعينه. وهنا أيضاً تظهر فائدة الأسماء الوصفية — في قائمة -v يخبرك test_quarter_off بما نجح، أما test_2 فلا يخبرك بشيء.

استخدم -q حين تشغّل الاختبارات باستمرار ولا تريد إلا الحكم النهائي، و -v حين تريد أن ترى بالضبط أي الاختبارات جرى تشغيلها.

ما هو .pytest_cache؟ مجلد ينشئه pytest ليتذكر أشياء بين مرات التشغيل، مثل الاختبارات التي أخفقت في المرة السابقة. حذفه آمن، ولا مكان له في نظام التحكم بالإصدارات.

رموز الخروج: كيف تقرأ الآلة النتيجة

أنت تقرأ سطر الملخص. أما السكربت أو المحرر أو خادم البناء فلا يستطيع ذلك — إنه يقرأ رمز الخروج (exit code)، وهو الرقم الذي يعيده كل برنامج إلى الصدفة (shell) عند انتهائه. في صدفة يونكس يحمل المتغير $? آخر رمز:

text
$ pytest -q
...                                                                      [100%]
3 passed in 0.01s
$ echo $?
0

ومع الدالة المعطوبة، بعد التشغيل الذي انتهى بـ 2 failed, 1 passed:

text
$ echo $?
1
  • 0 — جُمعت كل الاختبارات، ونجحت كلها
  • 1 — جرى تشغيل الاختبارات، وأخفق واحد منها على الأقل

(في PowerShell اسم المتغير $LASTEXITCODE.) هذا ما يتيح تشغيل الاختبارات آلياً قبل دمج الكود: رمز الخروج غير الصفري يوقف العملية. وهناك رموز أخرى قليلة، ستقابل أحدها في قسم "الأخطاء الشائعة وحلولها" أدناه.

أين توضع ملفات الاختبار

في هذا الفصل يقع ملف الاختبار مباشرة بجوار الكود:

text
shop/
├── prices.py
└── test_prices.py

هذا مناسب لعدد قليل من الملفات. أما معظم المشاريع فتجمع الاختبارات في مجلد مستقل باسم tests/. وفي الحالتين يعثر عليها pytest من خلال أسمائها؛ أما كيفية تنظيم مشروع حقيقي، وما الذي يتغير حين تفعل ذلك، فهو موضوع الفصل الثالث.


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

دالة تحوّل الدرجة إلى تقدير بحرف، في الملف grades.py:

python
def letter_grade(score):
    """Turn a score from 0 to 100 into a letter."""
    if score >= 80:
        return "A"
    if score >= 60:
        return "B"
    if score >= 40:
        return "C"
    return "F"

واختباراتها في الملف test_grades.py:

python
from grades import letter_grade


def test_top_score_is_an_a():
    assert letter_grade(95) == "A"


def test_exactly_eighty_is_still_an_a():
    assert letter_grade(80) == "A"


def test_just_below_eighty_is_a_b():
    assert letter_grade(79) == "B"


def test_middle_score_is_a_c():
    assert letter_grade(50) == "C"


def test_zero_is_an_f():
    assert letter_grade(0) == "F"
text
$ pytest -v
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
cachedir: .pytest_cache
rootdir: /home/you/grades
collecting ... collected 5 items

test_grades.py::test_top_score_is_an_a PASSED                            [ 20%]
test_grades.py::test_exactly_eighty_is_still_an_a PASSED                 [ 40%]
test_grades.py::test_just_below_eighty_is_a_b PASSED                     [ 60%]
test_grades.py::test_middle_score_is_a_c PASSED                          [ 80%]
test_grades.py::test_zero_is_an_f PASSED                                 [100%]

============================== 5 passed in 0.01s ===============================

هناك أمران يستحقان الملاحظة.

الأول: الاختباران عند 80 و 79. يقعان على جانبي حدٍّ فاصل، وهناك بالذات تكمن الأخطاء. غيّر score >= 80 إلى score > 80 — وهي زلّة سهلة الوقوع — وشغّل الصيغة الهادئة:

text
$ pytest -q
.F...                                                                    [100%]
=================================== FAILURES ===================================
______________________ test_exactly_eighty_is_still_an_a _______________________

    def test_exactly_eighty_is_still_an_a():
>       assert letter_grade(80) == "A"
E       AssertionError: assert 'B' == 'A'
E         
E         - A
E         + B

test_grades.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_grades.py::test_exactly_eighty_is_still_an_a - AssertionError: as...
1 failed, 4 passed in 0.01s

الاختبار الثاني، test_exactly_eighty_is_still_an_a، هو الذي يخفق: الدرجة 80 تحصل الآن على 'B'. اختبار عند 95 وحده ما كان لينتبه إلى ذلك أبداً.

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


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

zsh: command not found: pytest (أو bash: pytest: command not found) لا تستطيع الصدفة العثور على برنامج باسم pytest. إما أنه غير مثبّت، وإما أنه مثبّت في بيئة افتراضية غير مفعّلة. مع uv شغّل uv run pytest؛ ومع pip فعّل البيئة أولاً. ويعمل كذلك python -m pytest، فيشغّل pytest بأي نسخة بايثون يشير إليها الأمر python.

/usr/bin/python3: No module named pytest شغّلت python -m pytest، ونسخة بايثون هذه لا تحتوي على pytest. المسار في بداية الرسالة يخبرك أي نسخة كانت — هنا نسخة النظام، لا نسخة مشروعك. ثبّت pytest في البيئة التي تقصد استخدامها.

ModuleNotFoundError: No module named 'price' أثناء التجميع لم يستطع ملف الاختبار استيراد كودك — هنا بسبب خطأ إملائي، from price import ... بدلاً من from prices import ...:

text
$ pytest -q
==================================== ERRORS ====================================
_______________________ ERROR collecting test_prices.py ________________________
ImportError while importing test module '/home/you/shop/test_prices.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_prices.py:1: in <module>
    from price import discounted
E   ModuleNotFoundError: No module named 'price'
=========================== short test summary info ============================
ERROR test_prices.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s

لاحظ كلمة ERROR لا FAILED: لم يُشغَّل أي اختبار إطلاقاً، لأن الملف لم يُحمَّل أصلاً، ورمز الخروج 2. اقرأ آخر سطر يبدأ بـ E، ثم شغّل اختبار الاستيراد من قسم "قبل أن تكتب الاختبار" — python -c "from prices import discounted" — إلى أن ينجح وحده.

no tests ran in 0.01s اشتغل pytest، لكنه لم يجمع شيئاً. إما أن اسم الملف لا يبدأ بـ test_ (فالأسماء tests_prices.py و prices_tests.py و test.py كلها تُتجاهل)، وإما أن أسماء الدوال لا تبدأ بـ test (فالدالة check_quarter_off تُتجاهل بصمت). رمز الخروج هنا 5 لا 0، كي لا يخلط التشغيل الآلي بين "لم أجد شيئاً" و"نجح كل شيء".

اختبار ينجح بينما يُفترض بوضوح أن يخفق ابحث عن assert مفقودة:

python
from prices import discounted


def test_quarter_off():
    discounted(80, 25) == 999
text
$ pytest -q
.                                                                        [100%]
1 passed in 0.01s

تُحسب المقارنة ثم تُرمى نتيجتها. لا يخفق الاختبار إلا إذا رُفع استثناء بداخله؛ ومن دون assert لا يُرفع شيء.

PytestReturnNotNoneWarning: Test functions should return None كتبت return discounted(99, 0) == 99 بدلاً من assert .... يتجاهل pytest ما يعيده الاختبار، فينجح هذا الاختبار سواء كانت المقارنة صحيحة أم خاطئة. بل إن التحذير نفسه يسألك: Did you mean to use assert instead of return?