التغطية و CI — أي الأسطر لم تشغّلها اختباراتك قط؟
قياس التغطية باستخدام pytest-cov، وقراءة عمود Missing، وتغطية الفروع، وفرض حد أدنى، وتشغيل الاختبارات بالتوازي باستخدام pytest-xdist، وتشغيل المجموعة كاملة مع كل دفع عبر GitHub Actions. ولماذا لا تعني تغطية 100% أن الكود صحيح.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
إليك دالة صغيرة تحسب تكلفة شحن طرد، واختبارين لها.
shop/shipping.py:
def shipping_cost(weight_kg: float, country: str) -> float:
if weight_kg <= 0:
raise ValueError(f"weight must be positive: {weight_kg}")
if country == "BD":
base = 60.0
elif country == "IN":
base = 90.0
else:
base = 500.0
if weight_kg > 5:
base += (weight_kg - 5) * 20
return basetests/test_shipping.py:
from shop.shipping import shipping_cost
def test_light_parcel_to_bangladesh():
assert shipping_cost(1, "BD") == 60.0
def test_heavy_parcel_to_bangladesh():
assert shipping_cost(7, "BD") == 100.0$ pytest -q
.. [100%]
2 passed in 0.07sأخضر. لكن هذا الأخضر لا يقول شيئاً عن أجزاء الدالة التي لم تلمسها الاختبارات قط. هل يكلّف الطرد إلى الهند 90؟ هل يرفع الوزن الصفري خطأً فعلاً؟ لم يسأل أحد. ولو كان في فرع "IN" خطأ مطبعي لبقي هناك ينجح، حتى يكتشفه أحد العملاء.
اثنا عشر سطراً يمكنك فحصها بالعين. اثنا عشر ألفاً لا يمكنك. تحتاج إلى أن يجيب الحاسوب عن سؤال واحد: أي الأسطر لم تشغّلها اختباراتي قط؟ هذا القياس اسمه التغطية (coverage)، وهذا الفصل يدور حول الحصول عليه وقراءته وعدم الانخداع به — ثم حول تشغيل المجموعة كاملة بسرعة وتلقائياً مع كل دفع (push).
بنهاية هذا الفصل ستكون قادراً على
- قياس التغطية باستخدام
pytest-covوقراءة عمودMissing - فتح تقرير HTML لرؤية الأسطر غير المغطاة داخل الكود نفسه
- شرح لماذا قد تقول تغطية الأسطر 100% مع أن فرعاً لم يُسلك قط، وتفعيل تغطية الفروع
- إفشال التشغيل حين تنخفض التغطية تحت حدّ معيّن، واستبعاد كود عن قصد
- وضع كل ذلك في
pyproject.toml - بيان لماذا لا تعني تغطية 100% أن الكود صحيح
- تشغيل الاختبارات بالتوازي باستخدام
pytest-xdist، وشرح لماذا يجب ألا تعتمد الاختبارات بعضها على بعض - كتابة سير عمل GitHub Actions يشغّل المجموعة على عدة إصدارات من بايثون
المتطلبات المسبقة: الإعدادات والتحذيرات.
قبل أن تكتب الاختبار
الطريقة الخاطئة لاستخدام التغطية هي أن تفتح التقرير أولاً ثم تكتب أي اختبار يحوّل السطر الأحمر التالي إلى أخضر. هذا ينتج اختبارات على شكل الكود، لا على شكل الوعد الذي يقطعه الكود — وكما سترى، تفوتها الأخطاء التي ليس لها سطر خاص بها. الترتيب الصحيح هو: خطّط انطلاقاً من العقد، واكتب الاختبارات، ثم بعد ذلك فقط اسأل التغطية عمّا نسيته الخطة.
العقد. اقرأ shipping_cost على أنها وعد لا على أنها كود:
- وزن صفر أو أقل →
ValueErrorتتضمن رسالته كلمة "positive" - بنغلاديش تكلّف 60، والهند 90، وأي مكان آخر 500
- فوق 5 كغ يضيف كل كيلوغرام إضافي 20؛ وعند 5 كغ بالضبط لا يُضاف شيء
- لا آثار جانبية: تعيد رقماً ولا تلمس شيئاً آخر
ما يجب تجهيزه. بيئة افتراضية مثبّت فيها pytest وpytest-cov (وpytest-xdist لاحقاً). ويجب أن تكون الحزمة قابلة للاستيراد من الاختبارات — هنا shop، إما مثبّتة وإما يُوصل إليها عبر pythonpath = ["src"] من الفصل السابق. وتحتاج إلى معرفة اسم الاستيراد للكود الذي تقيسه، لأنه هو ما يأتي بعد --cov=.
الخطة. صف لكل سلوك، مع الحدود والمدخلات غير الصالحة عن قصد:
| الحالة | المدخل | المتوقع | |---|---|---| | المسار الطبيعي، البلد الأساسي | 1, "BD" | 60.0 | | بلد ثانٍ | 1, "IN" | 90.0 | | أي مكان آخر | 1, "US" | 500.0 | | حدّ: 5 كغ بالضبط | 5, "BD" | 60.0 (بلا رسوم إضافية) | | فوق الحد | 7, "BD" | 100.0 (60 + 2 × 20) | | غير صالح: وزن صفري | 0, "BD" | ValueError، "positive" |
ما لا تختبره. الكود الذي لم تكتبه (دالة round في بايثون، ومكتبة coverage نفسها). وكتلة __main__ لا تعمل إلا حين يشغّل إنسان الملف يدوياً. ولا تكتب أبداً اختباراً كل مهمته تشغيل سطر — فالاختبار الخالي من assert ذي معنى يرفع النسبة ولا يتحقق من شيء.
لاحظ صفّي الحدود. اختبار 5 كغ لا يشغّل أي سطر لم يشغّله اختبار 1 كغ من قبل، لذا لن يطلبه أي تقرير تغطية أبداً. وفي الحالة غير الصالحة، كان اختبار بالقيمة -1 سيغطي سطر raise تماماً كما يغطيه 0 — لكن لو كتب أحدهم weight_kg < 0 بدلاً من <= 0، فلن يفشل إلا اختبار 0. التغطية لا تميّز بين هذين الاختبارين؛ العقد يميّز. وهذه هي العلاقة كلها: الخطة تقرّر ماذا تختبر؛ والتغطية تخبرك بما فات الخطة.
الاختباران في البداية يغطيان الصفين 1 و5. وبقية الفصل تقيس تلك الفجوة، وتسدّها، ثم تجعل القياس تلقائياً.
القياس باستخدام pytest-cov
تقيس مكتبة coverage التغطية؛ وpytest-cov هي الإضافة (plugin) التي تفعّلها من سطر أوامر pytest. ثبّتها اعتماديةً للتطوير:
$ uv add --dev pytest-covثم أخبرها أي حزمة تراقب باستخدام --cov=:
$ pytest -q --cov=shop
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64%
--------------------------------------
TOTAL 11 4 64%
2 passed in 0.09sStmts هو عدد العبارات القابلة للتنفيذ في الملف. وMiss عدد ما لم يُنفّذ منها قط. وCover النسبة التي نُفّذت. إحدى عشرة عبارة، أربع منها لم تُنفّذ: 64%.
القيمة بعد --cov= هي الكود الذي تريد قياسه، لا الاختبارات. فالاختبارات تنفّذ كل أسطرها دائماً، وعدّها لن يفعل سوى تجميل المجموع.
قراءة عمود Missing
النسبة تخبرك كم. ولا تخبرك أين. أضف --cov-report=term-missing:
$ pytest -q --cov=shop --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64% 3, 6-9
------------------------------------------------
TOTAL 11 4 64%
2 passed in 0.09s3, 6-9 أرقام أسطر في shipping.py. عُدّ نزولاً في الملف:
- السطر 3 هو
raise ValueError(...)— لم يمرّر أي اختبار وزناً صفرياً أو أقل. - الأسطر من 6 إلى 9 هي
elif country == "IN":وbase = 90.0وelse:وbase = 500.0— لم يرسل أي اختبار طرداً إلى غير بنغلاديش.
السطر 8، else:، ليس عبارة بذاته، فلا يدخل في عدد الإحدى عشرة؛ وتدمجه التغطية فقط في المدى 6-9 لتبقى القائمة قصيرة.
هذا العمود هو قائمة المهام. كل رقم فيه سطر قد يكون خاطئاً تماماً وتبقى مجموعتك خضراء رغم ذلك.
تقرير HTML
في ملف كبير تصعب متابعة قائمة من الأرقام. يكتب --cov-report=html بدلاً من ذلك موقعاً صغيراً:
$ pytest -q --cov=shop --cov-report=html
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Coverage HTML written to dir htmlcov
2 passed in 0.11sافتح htmlcov/index.html في المتصفح. كل ملف مدرج مع نسبته؛ انقر على أحدها لترى الشيفرة المصدرية، وقد عُلّمت الأسطر المنفّذة بالأخضر والفائتة بالأحمر. إنها المعلومات نفسها التي في Missing، لكن مفروشة فوق الكود.
كلٌّ من htmlcov/ وملف البيانات .coverage مُخرَج مولَّد. ضعهما في .gitignore.
تغطية الفروع — لماذا تكذب تغطية الأسطر
إليك دالة تقول عنها تغطية الأسطر إن كل شيء على ما يرام:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)from shop.coupons import final_price
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0$ pytest -q --cov=shop --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/coupons.py 4 0 100%
------------------------------------------------
TOTAL 4 0 100%
1 passed in 0.09s100%. نُفّذ كل سطر. لكن لم يستدعِ أحد final_price دون قسيمة قط. لعبارة if بلا else مخرجان — الدخول إلى جسمها، أو تجاوزه مباشرة — ولم يُجرَّب إلا أحدهما. ولو كسر أحدهم لاحقاً مسار عدم وجود قسيمة، فلن يلاحظ هذا التقرير شيئاً.
تغطية الأسطر تسأل "هل نُفّذ هذا السطر؟". وتغطية الفروع تسأل "هل ذهب كل قرار في الاتجاهين؟". فعّلها باستخدام --cov-branch:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
1 passed in 0.10sعمودان جديدان. Branch عدد القفزات الممكنة (يمكن لعبارة if في السطر 2 أن تذهب إلى السطر 3 أو إلى السطر 4: اثنتان). وBrPart يعدّ القرارات التي ذهبت في اتجاه واحد فقط. ويُظهر Missing الآن 2->4: القفزة من السطر 2 مباشرة إلى السطر 4 — مسار "لا قسيمة" — لم تحدث قط.
تغطية الفروع أكثر صرامة وأقرب إلى الحقيقة. ولا يكاد يوجد سبب لعدم إبقائها مفعّلة دائماً.
جعل الرقم بوابة: --cov-fail-under
التقرير الذي لا يقرؤه أحد لا يغيّر شيئاً. يحوّل --cov-fail-under المجموع إلى شرط نجاح أو فشل:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing --cov-fail-under=90
.
ERROR: Coverage failure: total of 83 is less than fail-under=90
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
FAIL Required test coverage of 90% not reached. Total coverage: 83.33%
1 passed in 0.12sاقرأ السطر الأخير بعناية: 1 passed. نجح كل اختبار، ومع ذلك ينتهي الأمر بحالة خروج 1. وفي CI هذا بناء أحمر. ليست الفكرة أن 90 رقم سحري؛ بل أن التغطية لم تعد قادرة على الانزلاق بصمت حين يضيف أحدهم كوداً بلا اختبارات.
اختر حداً أدنى قليلاً مما أنت عليه اليوم، وارفعه مع الوقت. فوضع 100 من اليوم الأول ينتهي غالباً بأناس يكتبون اختبارات بلا معنى لإرضاء الرقم.
استبعاد الكود عن قصد: # pragma: no cover
بعض الأسطر لا تستحق الاختبار — كتلة لا تعمل إلا حين تشغّل الملف يدوياً، مثلاً. أضف اختباراً ثانياً، assert final_price(200.0, None) == 200.0، لتصبح final_price مغطاة بالكامل — ثم أضف إلى الوحدة كتلة __main__:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
if __name__ == "__main__":
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 6 1 4 1 80% 8
--------------------------------------------------------------
TOTAL 6 1 4 1 80%
2 passed in 0.11sالسطر 8، أي print، لا يعمل أبداً تحت pytest — ولن يعمل. علِّم عبارة if بتعليق تفهمه التغطية:
if __name__ == "__main__": # pragma: no cover
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 0 100%
--------------------------------------------------------------
TOTAL 4 0 2 0 100%
2 passed in 0.08sحين يقع الـ pragma على سطر يفتح كتلة، تُستبعد الكتلة كلها. استخدمه باعتدال وبأمانة. فكل pragma سطرٌ قررت ألا تتحقق منه؛ وإن وجدت نفسك تضيف واحداً لـ"إخفاء" فرع صعب، فذلك الفرع على الأرجح هو الأحوج إلى اختبار.
وضع كل شيء في pyproject.toml
كتابة أربعة خيارات --cov في كل مرة هي الطريق إلى نسيانها. تقرأ التغطية إعداداتها من pyproject.toml، وaddopts الذي رأيته في الفصل السابق يجعل pytest يفعّلها:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true
[tool.coverage.report]
fail_under = 90
exclude_also = [
'if __name__ == "__main__":',
][tool.coverage.run]يتحكم في القياس:sourceهو ما تجب مراقبته (لذا يكفي--covبلا قيمة)، وbranch = trueيعادل--cov-branch.[tool.coverage.report]يتحكم في التقرير:fail_underيعادل--cov-fail-under، وexclude_alsoقائمة أنماط تُستبعد إضافةً إلى# pragma: no cover— هنا كل كتلة__main__، دون حاجة إلى أي تعليق في الكود.
الآن يقيس pytest العادي ويُبلغ ويفرض القاعدة. ستراه يعمل في المثال المتكامل أدناه.
تغطية 100% لا تعني الصحة
تجيب التغطية عن "هل نُفّذ هذا السطر؟". ولا تجيب عن "هل تحقق أحد من أن النتيجة صحيحة؟". مثال صغير:
def is_leap_year(year: int) -> bool:
return year % 4 == 0from shop.calendar_rules import is_leap_year
def test_2024_is_a_leap_year():
assert is_leap_year(2024)
def test_2023_is_not():
assert not is_leap_year(2023)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
2 passed in 0.12sعلامة كاملة. والدالة خاطئة: السنة القابلة للقسمة على 100 ليست كبيسة ما لم تقبل القسمة على 400 أيضاً. اختبار آخر، اختير بالتفكير في القاعدة لا في الأسطر:
def test_1900_is_not():
assert not is_leap_year(1900)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
..F [100%]
=================================== FAILURES ===================================
_______________________________ test_1900_is_not _______________________________
def test_1900_is_not():
> assert not is_leap_year(1900)
E assert not True
E + where True = is_leap_year(1900)
tests/test_calendar_rules.py:13: AssertionError
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
=========================== short test summary info ============================
FAILED tests/test_calendar_rules.py::test_1900_is_not - assert not True
1 failed, 2 passed in 0.12sما تزال 100% — والآن فشل. ما كان للتغطية أن تشير إلى هذا الخطأ، لأنه لم يكن هناك سطر مفقود؛ المفقود كان حالة. تعامل مع التغطية على أنها خريطة للأماكن التي لم تنظر إليها حتماً، لا على أنها دليل أبداً على أن ما نظرت إليه صحيح.
تشغيل الاختبارات بالتوازي باستخدام pytest-xdist
كلما كبرت المجموعة أبطأت، والمجموعة البطيئة تُشغَّل أقل. يوزّع pytest-xdist الاختبارات على عدة عمليات. ثمانية اختبارات يستغرق كل منها نصف ثانية:
import time
import pytest
@pytest.mark.parametrize("n", range(8))
def test_slow_check(n):
time.sleep(0.5)
assert n >= 0$ pytest -q test_slow.py
........ [100%]
8 passed in 4.10s
$ pytest -n auto test_slow.py
============================= test session starts ==============================
created: 4/4 workers
4 workers [8 items]
........ [100%]
============================== 8 passed in 1.52s ===============================يشغّل -n auto عاملاً (worker) لكل نواة معالج (أربعة على هذا الجهاز) ويسلّم كلاً منها اختبارات لتشغيلها. ويعمل مع --cov؛ إذ تُدمج بيانات التغطية من كل العمّال في تقرير واحد.
لكن لهذا ثمن. كل عامل عملية منفصلة، ولم يعد ترتيب تشغيل الاختبارات هو ترتيبها في الملف. وأي اختبار كان يعتمد بصمت على أن اختباراً آخر قد عمل قبله سينكسر:
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1$ pytest -q test_cart.py
.. [100%]
2 passed in 0.07s
$ pytest -n 2 test_cart.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.F [100%]
=================================== FAILURES ===================================
____________________________ test_cart_has_one_item ____________________________
[gw1] linux -- Python 3.12.3 /path/to/venv/bin/python
def test_cart_has_one_item():
> assert len(CART) == 1
E assert 0 == 1
E + where 0 = len([])
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_cart_has_one_item - assert 0 == 1
========================= 1 failed, 1 passed in 0.37s ==========================لم ينجح الاختبار الثاني إلا لأن الأول كان قد ملأ القائمة المشتركة. وعلى عامل آخر كانت القائمة فارغة. وتشغيل pytest test_cart.py::test_cart_has_one_item وحده يفشل بالطريقة نفسها — لم يُنشئ xdist الخطأ، بل كشفه.
والحل هو نفسه الذي في فصول الـ fixtures: أعطِ كل اختبار حالته الخاصة.
import pytest
@pytest.fixture
def cart():
return ["pen"]
def test_add_item(cart):
cart.append("bag")
assert cart == ["pen", "bag"]
def test_cart_has_one_item(cart):
assert len(cart) == 1$ pytest -n 2 test_cart_fixed.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.. [100%]
============================== 2 passed in 0.39s ===============================يجب أن ينجح الاختبار وحده، وبأي ترتيب، وعلى أي عامل. هناك إضافة اسمها pytest-randomly مبنية بالكامل على هذه الفكرة: تخلط ترتيب الاختبارات في كل تشغيل، فيظهر الاعتماد الخفي مبكراً على جهازك، لا بعد شهور في CI. لست بحاجة إليها لمتابعة هذه الدورة؛ يكفي أن تعرف أن "المجموعة لا تنجح إلا بترتيب الملف" خطأ برمجي.
التشغيل مع كل دفع: GitHub Actions
الخطوة الأخيرة هي التوقف عن الاعتماد على تذكّر الناس لتشغيل المجموعة. صرّح بأدوات الاختبار مجموعةً للتطوير في pyproject.toml، ليثبّتها uv sync:
[dependency-groups]
dev = [
"pytest>=9",
"pytest-cov>=7",
"pytest-xdist>=3.8",
]ثم أضف .github/workflows/tests.yml:
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --locked
- name: Run tests
run: uv run pytest -n auto --cov-report=xml
- name: Keep the coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v7
with:
name: coverage-xml
path: coverage.xmlما يفعله كل جزء:
on:— شغّل مع كل دفع إلىmainومع كل طلب سحب (pull request).matrix:— تعمل المهمة ثلاث مرات، مرة لكل إصدار بايثون، جنباً إلى جنب. ويسمحfail-fast: falseللثلاث بالاكتمال، فترى هل يقتصر الفشل على إصدار واحد.- يثبّت
setup-uvأداة uv ويثبّت إصدار بايثون لتلك النسخة من المهمة. - يثبّت
uv sync --lockedبالضبط ما يقولهuv.lock، ويفشل إن كان ملف القفل قديماً — فيختبر CI ما قمت بإيداعه (commit) فعلاً. - يشغّل
uv run pytest -n auto --cov-report=xmlبالتوازي؛ وإعدادات التغطية، ومنهاfail_under، تأتي منpyproject.toml، فأي انخفاض في التغطية يُفشل البناء. ويُنشأ تقرير XML إلى جانب تقرير الطرفية، جاهزاً لخدمة تغطية أو للتنزيل.
محلياً، بعد uv sync، يعطي الأمر نفسه النتيجة نفسها (الأسطر الأخيرة من المخرجات):
$ uv run pytest -n auto --cov-report=xml
TOTAL 15 0 10 1 96%
Coverage XML written to file coverage.xml
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.43s ===============================مثال تطبيقي متكامل
مشروع ببنية src، وفيه الوحدتان من هذا الفصل، وكل الإعدادات في مكان واحد.
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.toml هو نفسه الذي في قسم الإعدادات أعلاه. وcoupons.py يحتفظ بكتلة __main__ دون pragma — يتكفّل بها exclude_also. وtest_coupons.py ما يزال فيه اختبار SAVE10 وحده. أما test_shipping.py فهو الآن خطة الاختبار من بداية الفصل، صفاً بصف:
import pytest
from shop.shipping import shipping_cost
@pytest.mark.parametrize(
("weight", "country", "expected"),
[
(1, "BD", 60.0),
(1, "IN", 90.0),
(1, "US", 500.0),
(5, "BD", 60.0),
(7, "BD", 100.0),
],
)
def test_shipping_cost(weight, country, expected):
assert shipping_cost(weight, country) == expected
def test_zero_weight_is_rejected():
with pytest.raises(ValueError, match="positive"):
shipping_cost(0, "BD")$ pytest
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
collected 7 items
tests/test_coupons.py . [ 14%]
tests/test_shipping.py ...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/coupons.py 4 0 2 1 83% 2->4
src/shop/shipping.py 11 0 8 0 100%
------------------------------------------------------------------
TOTAL 15 0 10 1 96%
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.12s ===============================ثلاثة أمور تستحق الملاحظة.
أولاً، فعل pytest العادي كل شيء — بلا خيارات. القياس وفحص الفروع وقائمة المفقود والحد الأدنى جاءت كلها من pyproject.toml.
ثانياً، انتقل shipping.py من 64% إلى 100% لأن الخطة كُتبت، لا لأننا طاردنا الأسطر الحمراء. ثم تؤكد التغطية أن الخطة بلغت كل سطر وكل فرع. صف (5, "BD", 60.0) واختيار 0 بدلاً من -1 للوزن غير الصالح لا يضيفان شيئاً إلى النسبة — إنهما موجودان لأن للعقد حواف، والحواف هي حيث تعيش الأخطاء المطبعية مثل < بدلاً من <=.
ثالثاً، ينجح البناء عند 96% مع أن 2->4 ما يزال مدرجاً. الحد الأدنى أرضية لا خط نهاية. وما يزال التقرير يخبرك بالضبط أي اختبار تكتب تالياً: final_price بلا قسيمة.
الأخطاء الشائعة وحلولها
error: unrecognized arguments: --cov=shop pytest-cov غير مثبّت في البيئة التي يعمل منها pytest. ثبّته (uv add --dev pytest-cov) وشغّل عبر تلك البيئة، مثلاً بـ uv run pytest. والرسالة نفسها مع -n تعني أن pytest-xdist غير موجود.
CoverageWarning: Module shopp was never imported. (module-not-imported) تليها No data was collected. الاسم بعد --cov= لا يطابق أي حزمة استوردتها اختباراتك — هنا خطأ مطبعي. تحقّق من التهجئة، ومع بنية src أعطِ اسم الاستيراد (shop) لا اسم المجلد (src).
WARNING: Failed to generate report: No data to report. السبب نفسه كما أعلاه: لم يُقَس شيء، فلا شيء يُبلَّغ عنه. ومع ذلك نجحت الاختبارات — ولهذا بالضبط يسهل أن يفوتك هذا التحذير.
FAIL Required test coverage of 90% not reached. Total coverage: 83.33% ليس اختباراً مكسوراً — إنه الحد الأدنى. انظر إلى عمود Missing واكتب الاختبار الذي يشير إليه. وخفض fail_under لجعل البناء أخضر يُبطل الغاية من وجوده.
اختبار ينجح وحده لكنه يفشل تحت -n auto (أو العكس) الاختبارات تتشارك الحالة: قائمة أو قاموس على مستوى الوحدة، أو ملف في مسار ثابت، أو متغير بيئة، أو صف في قاعدة بيانات. ابحث عمّا يفترض الاختبار الفاشل أنه موجود مسبقاً، وأعطه إياه عبر fixture أو tmp_path أو monkeypatch بدلاً من ذلك.
التغطية 100% لكن المستخدمين ما زالوا يصادفون أخطاء هذا متوقع. التغطية تُظهر ما نُفّذ، لا ما تم التحقق منه. ابحث عن الحالات لا عن الأسطر — الحدود، والمدخلات الفارغة، وسنة 1900.
Step 4 of 6 — Predict
Check your understanding
يوجد اختبار واحد فقط، وتغطية الفروع مفعّلة. ماذا يُظهر عمود Missing في سطر coupons.py؟
# shop/coupons.py
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
# tests/test_coupons.py
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0
# command: pytest -q --cov=shop --cov-branch --cov-report=term-missing- A3
- Bلا شيء — تغطية 100%
- C2->4
- D4
مع pytest ينجح الاختباران، لكن مع pytest -n 2 يفشل test_cart_has_one_item. ما المشكلة الحقيقية؟
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1- Aفي pytest-xdist خطأ برمجي؛ والاختبارات سليمة
- Bالخيار `-n 2` يشغّل كل اختبار مرتين
- C`CART` على مستوى الوحدة، لذلك لا يُنشأ أبداً في العمّال
- Dالاختبار الثاني يعتمد على تشغيل الأول قبله — الاختباران يتشاركان الحالة نفسها
دالة تغطيتها للأسطر وللفروع 100%، وكل الاختبارات تنجح. ماذا يمكنك أن تستنتج؟
- Aالدالة صحيحة ولا تحتاج إلى مزيد من الاختبارات
- Bفقط أن كل سطر وكل اتجاه لكل قرار نُفّذ مرة واحدة على الأقل — لا أن النتائج صحيحة
- Cلا يمكن أن يكون في الدالة خطأ عند الحدود
- Dمع `fail_under = 100` لا يمكن لأي خطأ أن يدخل الدالة مجدداً
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.
دورك الآن
ابدأ مشروعاً صغيراً بهذه الوحدة، src/shop/loyalty.py:
def loyalty_points(total: float, is_member: bool) -> int:
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
points = int(total // 100)
if is_member:
points *= 2
return pointsالعقد: نقطة واحدة عن كل 100 كاملة تُنفق، مضاعفة للأعضاء؛ والمجموع السالب خطأ؛ والصفر مسموح. ويأتي معها اختبار واحد فقط، tests/test_loyalty.py:
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4وملف pyproject.toml يقيس تغطية الفروع في كل تشغيل:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true- شغّل
pytest -qواقرأ عمودMissing. قل بالكلمات ماذا يعني كل مدخل فيه. - قبل كتابة أي اختبار، اكتب جدول خطة اختبار للعقد — بما في ذلك الحد عند 100 بالضبط والحافة التي تحته مباشرة.
- حوّل الخطة إلى اختبار واحد بـ parametrize واختبار واحد بـ
pytest.raises. - أضف
fail_under = 100تحت[tool.coverage.report]وتأكد أن التشغيل ينجح. - احذف صفوف غير الأعضاء وتأكد أن التشغيل يفشل رغم أن كل اختبار متبقٍّ ينجح.
- شغّل المجموعة باستخدام
pytest -n autoوتأكد أن النتيجة مطابقة.
الحل
الخطوة 1 — قراءة التقرير.
$ pytest -q
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 1 4 2 73% 3, 5->7
------------------------------------------------------------------
TOTAL 7 1 4 2 73%
1 passed in 0.09s3 هو سطر raise: لم يمرّر أي اختبار مجموعاً سالباً. و5->7 قفزة من if is_member: مباشرة إلى return points — مسار غير العضو، لم يُسلك قط. ولولا تغطية الفروع لما ظهر 5->7 أصلاً، لأن السطر 6 قد نُفّذ (للعضو).
الخطوة 2 — الخطة.
| الحالة | المدخل | المتوقع | |---|---|---| | عضو، مضاعفة | 250, True | 4 | | غير عضو | 250, False | 2 | | أقل من أول 100 مباشرة | 99.99, False | 0 | | حدّ: 100 بالضبط | 100, False | 1 | | الصفر مسموح | 0, True | 0 | | غير صالح: سالب | -1, True | ValueError، "cannot be negative" |
الخطوة 3 — الاختبارات. tests/test_loyalty.py:
import pytest
from shop.loyalty import loyalty_points
@pytest.mark.parametrize(
("total", "is_member", "expected"),
[
(250, True, 4), # member: points doubled
(250, False, 2), # non-member: the `if` is skipped
(99.99, False, 0), # just under the first 100
(100, False, 1), # exactly on the boundary
(0, True, 0), # zero is allowed, not an error
],
)
def test_loyalty_points(total, is_member, expected):
assert loyalty_points(total, is_member) == expected
def test_negative_total_is_rejected():
with pytest.raises(ValueError, match="cannot be negative"):
loyalty_points(-1, True)الخطوة 4 — البوابة. أضف إلى pyproject.toml:
[tool.coverage.report]
fail_under = 100$ pytest -q
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
6 passed in 0.09sالخطوة 5 — إثبات أن البوابة تعمل. بعد حذف صفوف False الثلاثة:
$ pytest -q
...
ERROR: Coverage failure: total of 91 is less than fail-under=100
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 1 91% 5->7
------------------------------------------------------------------
TOTAL 7 0 4 1 91%
FAIL Required test coverage of 100.0% not reached. Total coverage: 90.91%
3 passed in 0.12s3 passed، وحالة الخروج 1. ما يزال كل سطر يُنفّذ — الفرع وحده غائب — والبوابة تلتقط ذلك. أعد الصفوف إلى مكانها.
الخطوة 6 — بالتوازي.
$ pytest -n auto
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
created: 4/4 workers
4 workers [6 items]
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
============================== 6 passed in 0.63s ===============================الاختبارات الستة نفسها، و100% نفسها: دُمجت تغطية العمّال الأربعة في تقرير واحد.
سبب كل قرار.
- الخطة جاءت قبل الاختبارات. من الصفوف الستة لم يطلب التقرير إلا اثنين (
3و5->7). أما99.99و100فموجودان لأن للعقد حافة عند 100، و0, Trueلأن العقد يقول إن الصفر مسموح — إنه اختبار لعدم رفع خطأ. وكانت التغطية ستقتنع دون أي منها. - لماذا يصلح `-1` هنا. الشرط هو
total < 0، فالحافة نفسها هي0، وصف0, Trueيثبّتها مسبقاً على أنها صالحة. لو غيّر أحدهم الشرط إلىtotal <= 0لفشل ذلك الصف. وبعدها يصلح أي رقم سالب للجانب غير الصالح. - لماذا `fail_under = 100` هنا وليس في مشروع حقيقي. وحدة بهذا الصغر يمكن تغطيتها بالكامل بشكل معقول. أما في قاعدة كود كبيرة فابدأ أدنى قليلاً من رقم اليوم وارفعه.
- لماذا يثبت تشغيل `-n auto` شيئاً. كل حالة تحصل على وسائطها الخاصة ولا شيء مشترك بين الاختبارات، فلا يهم الترتيب ولا العامل. ولو تغيّرت النتيجة تحت
-n autoلكان ذلك هو الخطأ الذي يجب إصلاحه أولاً.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz