تثبيت pytest واختبارك الأول
لماذا يتوقف التحقق اليدوي عن النفع، ولماذا لا تخبرك assert المجردة بشيء تقريباً، وكيف تثبّت pytest وتكتب أول ملف اختبار وتشغّله. مع قراءة النقاط وسطر الملخص و -q و -v ورموز الخروج.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك دالة صغيرة، والطريقة التي يتحقق بها معظمنا من دالة كهذه — نشغّلها وننظر إلى ما يخرج منها:
def discounted(price, percent):
return price - price * percent / 100
print(discounted(200, 10))
print(discounted(80, 25))
print(discounted(99, 0))180.0
60.0
99.0تقرأ الأرقام الثلاثة، وتجري الحسابات في ذهنك، ثم تقرر أنها صحيحة. هذا اختبار. لكنه اختبار يدوي، وفيه مشكلتان.
الأولى أنه لا يتوسع. ثلاثة أرقام يسهل التحقق منها بالعين. أما ثلاثون دالة لكل منها خمس حالات، يُعاد التحقق منها بعد كل تعديل، فلا — ولا أحد يفعل ذلك فعلاً.
والثانية أسوأ. بعد شهر يأتي أحدهم و"يرتّب" الدالة:
def discounted(price, percent):
return price * (1 - percent // 100)$ python -c "from prices import discounted; print(discounted(200, 10))"
200تبدو معقولة، وتعمل دون أي خطأ. لكن // قسمة صحيحة، فيكون 10 // 100 مساوياً لـ 0، وقد اختفى كل خصم بصمت. الكود الذي كان يعمل ثم توقف عن العمل يسمى تراجعاً (regression)، والتراجع هو بالضبط ما يفوت التحقق اليدوي — لأن أحداً لا يعود ليتحقق من الأرقام القديمة من جديد.
الحل أن تكتب الإجابات المتوقعة في الكود نفسه، كي تتحقق منها الآلة في كل مرة. ولدى بايثون أصلاً تعليمة لهذا الغرض: assert. ضع عمليات التحقق في ملف باسم check_prices.py بجوار prices.py:
from prices import discounted
assert discounted(200, 10) == 180.0
assert discounted(80, 25) == 60.0
assert discounted(99, 0) == 99
print("all good")مع الدالة الأصلية:
$ python check_prices.py
all goodومع النسخة "المرتّبة":
$ 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. مجلد واحد للمشروع، وبيئة واحدة. كل ما في هذا الفصل يقع في مجلد واحد:
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. لذا، قبل كتابة أي اختبار، تحقق من أن هذا الاستيراد بعينه يعمل من مجلد المشروع:
$ 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، فشغّل هذا الأمر داخل مجلد المشروع:
$ 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، فيحصل كل من يُعِدّ المشروع لاحقاً على الأداة نفسها:
[dependency-groups]
dev = [
"pytest>=9.1.1",
]وإذا كنت تستخدم pip العادي داخل بيئة افتراضية، ففعّل البيئة ثم شغّل:
$ python -m pip install pytestيطبع pip تقدّم التنزيل، ثم ينتهي بالسطر التالي:
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 وحده يضمن أن الحزمة تُثبَّت في نسخة بايثون نفسها التي ستشغّلها — وهذا مصدر شائع لعبارة "ثبّتها لكنها غير موجودة".
تحقّق الآن من نجاح التثبيت:
$ 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:
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 من مجلد المشروع:
$ 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، الذي سنتعرف عليه جيداً بعد قليل:
$ 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، أي هادئ) يحذف الترويسة:
$ pytest -q
... [100%]
3 passed in 0.01sوالخيار -v (verbose، أي مفصّل) يعطي كل اختبار سطراً خاصاً به، باسمه الكامل:
$ 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) عند انتهائه. في صدفة يونكس يحمل المتغير $? آخر رمز:
$ pytest -q
... [100%]
3 passed in 0.01s
$ echo $?
0ومع الدالة المعطوبة، بعد التشغيل الذي انتهى بـ 2 failed, 1 passed:
$ echo $?
10— جُمعت كل الاختبارات، ونجحت كلها1— جرى تشغيل الاختبارات، وأخفق واحد منها على الأقل
(في PowerShell اسم المتغير $LASTEXITCODE.) هذا ما يتيح تشغيل الاختبارات آلياً قبل دمج الكود: رمز الخروج غير الصفري يوقف العملية. وهناك رموز أخرى قليلة، ستقابل أحدها في قسم "الأخطاء الشائعة وحلولها" أدناه.
أين توضع ملفات الاختبار
في هذا الفصل يقع ملف الاختبار مباشرة بجوار الكود:
shop/
├── prices.py
└── test_prices.pyهذا مناسب لعدد قليل من الملفات. أما معظم المشاريع فتجمع الاختبارات في مجلد مستقل باسم tests/. وفي الحالتين يعثر عليها pytest من خلال أسمائها؛ أما كيفية تنظيم مشروع حقيقي، وما الذي يتغير حين تفعل ذلك، فهو موضوع الفصل الثالث.
مثال تطبيقي متكامل
دالة تحوّل الدرجة إلى تقدير بحرف، في الملف grades.py:
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:
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"$ 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 — وهي زلّة سهلة الوقوع — وشغّل الصيغة الهادئة:
$ 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 ...:
$ 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 مفقودة:
from prices import discounted
def test_quarter_off():
discounted(80, 25) == 999$ 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?
Step 4 of 6 — Predict
Check your understanding
حُفظ هذا الملف باسم test_double.py ثم شُغّل الأمر pytest -q. ما الأحرف التي يبدأ بها السطر الأول من المخرجات؟
def double(n):
return n * 2
def test_two():
assert double(2) == 4
def test_zero():
assert double(0) == 1
def test_negative():
assert double(-3) == -6- AF..
- B..F
- C.F.
- DF
من الواضح أن 999 إجابة خاطئة، ومع ذلك يقول pytest إن النتيجة 1 passed. لماذا؟
def discounted(price, percent):
return price - price * percent / 100
def test_quarter_off():
discounted(80, 25) == 999- Aيتحقق pytest فقط من أن الدالة عملت، لا من نتيجتها
- Bلا يمكن مقارنة الأعداد العشرية باستخدام `==`
- Cاسم الدالة لا يبدأ بـ `test_`
- Dالسطر يخلو من `assert` — تُحسب المقارنة ثم تُهمل نتيجتها
ملف يحتوي على اختبارات صحيحة اسمه double_checks.py. ماذا يحدث عند تشغيل pytest في ذلك المجلد؟
- Aتعمل كل الاختبارات، لأن أسماء الدوال تبدأ بـ `test_`
- B`no tests ran` — اسم الملف لا يبدأ بـ `test_` فلا يُبحث فيه أصلاً؛ ورمز الخروج `5`
- Cيتوقف pytest بخطأ `ImportError`
- Dيعيد pytest تسمية الملف إلى `test_double_checks.py` تلقائياً
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.
دورك الآن
أنشئ مجلداً فيه ملف temperature.py يحتوي على دالتين:
c_to_f(celsius)— تعيدcelsius * 9 / 5 + 32f_to_c(fahrenheit)— تعيد(fahrenheit - 32) * 5 / 9
ثم ثبّت pytest في بيئة ذلك المجلد، وتأكد من التثبيت بالأمر pytest --version.
اكتب الملف test_temperature.py وفيه خمسة اختبارات على الأقل. ضمّنها درجة تجمّد الماء (0 و 32)، ودرجة غليانه (100 و 212)، والدرجة -40 التي تتساوى في المقياسين. أعطِ كل اختبار اسماً يدل على ما يتحقق منه.
شغّله بثلاث طرق — pytest و pytest -q و pytest -v — وتحقق من رمز الخروج بعد إحداها.
ثم أعطبه عمداً: غيّر 9 / 5 إلى 5 / 9 في c_to_f. وقبل التشغيل، توقّع أي الاختبارات ستخفق وكيف سيبدو سطر التقدم. شغّل pytest -q، وقارن، وتحقق من رمز الخروج مجدداً.
وأخيراً، أعِد تسمية test_temperature.py إلى temperature_check.py وشغّل pytest. اقرأ ما يقوله، ورمز خروجه. ثم أعِد الاسم كما كان. معرفة شكل "لم أجد شيئاً" ستوفّر عليك ظهيرة كاملة من الحيرة.
الحل
أولاً الخطة، قبل أي كود — الجدول نفسه كما في السابق:
| الحالة | المدخل | المتوقع | | --- | --- | --- | | الماء يتجمّد | c_to_f(0)، f_to_c(32) | 32، 0 | | الماء يغلي | c_to_f(100)، f_to_c(212) | 212، 100 | | المقياسان يلتقيان | c_to_f(-40)، f_to_c(-40) | -40، -40 |
الملف temperature.py:
def c_to_f(celsius):
return celsius * 9 / 5 + 32
def f_to_c(fahrenheit):
return (fahrenheit - 32) * 5 / 9قبل كتابة الاختبارات، اختبار الاستيراد من مجلد المشروع:
$ python -c "import temperature; print('importable')"
importableالملف test_temperature.py:
from temperature import c_to_f, f_to_c
def test_water_freezes_at_32_f():
assert c_to_f(0) == 32
def test_water_boils_at_212_f():
assert c_to_f(100) == 212
def test_32_f_is_zero_c():
assert f_to_c(32) == 0
def test_212_f_is_100_c():
assert f_to_c(212) == 100
def test_minus_40_is_the_same_in_both_scales():
assert c_to_f(-40) == -40
assert f_to_c(-40) == -40$ pytest -q
..... [100%]
5 passed in 0.01s
$ echo $?
0
$ 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/temperature
collecting ... collected 5 items
test_temperature.py::test_water_freezes_at_32_f PASSED [ 20%]
test_temperature.py::test_water_boils_at_212_f PASSED [ 40%]
test_temperature.py::test_32_f_is_zero_c PASSED [ 60%]
test_temperature.py::test_212_f_is_100_c PASSED [ 80%]
test_temperature.py::test_minus_40_is_the_same_in_both_scales PASSED [100%]
============================== 5 passed in 0.01s ===============================والآن الخلل المتعمَّد، بتغيير 9 / 5 إلى 5 / 9 في c_to_f:
$ pytest -q
.F..F [100%]
=================================== FAILURES ===================================
__________________________ test_water_boils_at_212_f ___________________________
def test_water_boils_at_212_f():
> assert c_to_f(100) == 212
E assert 87.55555555555556 == 212
E + where 87.55555555555556 = c_to_f(100)
test_temperature.py:9: AssertionError
___________________ test_minus_40_is_the_same_in_both_scales ___________________
def test_minus_40_is_the_same_in_both_scales():
> assert c_to_f(-40) == -40
E assert 9.777777777777779 == -40
E + where 9.777777777777779 = c_to_f(-40)
test_temperature.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_temperature.py::test_water_boils_at_212_f - assert 87.55555555555...
FAILED test_temperature.py::test_minus_40_is_the_same_in_both_scales - assert...
2 failed, 3 passed in 0.01s
$ echo $?
1والملف بعد إعادة تسميته:
$ pytest -q
no tests ran in 0.01s
$ echo $?
5لماذا كُتب الحل بهذه الطريقة:
- سلوك واحد في كل اختبار، باسم يُقرأ كجملة. في قائمة
-vيقول كل سطر ما هو الوعد. وحين يخفقtest_water_boils_at_212_fتعرف ما الذي انكسر قبل أن تقرأ أي شيء آخر. - قيم معروفة يمكن التحقق منها.
0/32و100/212و-40حقائق تستطيع التأكد منها دون آلة حاسبة، فيشير الإخفاق إلى الكود، لا إلى قيمة متوقعة مشكوك فيها. - الاتجاهان كلاهما.
c_to_fوf_to_cكودان منفصلان، فيحتاج كل منهما إلى اختباراته الخاصة؛ وصحة أحدهما لا تقول شيئاً عن الآخر. - تشغيل الخلل هو الجزء الأكثر دلالة. أخفق اختباران فقط، ولم يكن
test_water_freezes_at_32_fبينهما:0 * 5 / 9 + 32ما زال32، لأن ضرب الصفر يُخفي أي معامل ضرب. وهذا بالضبط سبب احتواء الخطة على أكثر من حالة — مجموعة اختبارات تقتصر على نقطة التجمّد كانت ستنجح والخلل موجود. اختبار-40فيه سطراassert؛ أخفق الأول، فلم يُنفَّذ الثاني أبداً. وهذا مقبول هنا لأن كليهما يصف الوعد نفسه. - رموز الخروج —
0و1و5— هي الثلاثة التي ستقابلها أكثر من غيرها: نجح كل شيء، أخفق شيء ما، لم يُعثر على شيء.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz