الفصل 14

التغطية و CI — أي الأسطر لم تشغّلها اختباراتك قط؟

قياس التغطية باستخدام pytest-cov، وقراءة عمود Missing، وتغطية الفروع، وفرض حد أدنى، وتشغيل الاختبارات بالتوازي باستخدام pytest-xdist، وتشغيل المجموعة كاملة مع كل دفع عبر GitHub Actions. ولماذا لا تعني تغطية 100% أن الكود صحيح.

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

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

إليك دالة صغيرة تحسب تكلفة شحن طرد، واختبارين لها.

shop/shipping.py:

python
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 base

tests/test_shipping.py:

python
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
text
$ 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. ثبّتها اعتماديةً للتطوير:

text
$ uv add --dev pytest-cov

ثم أخبرها أي حزمة تراقب باستخدام --cov=:

text
$ 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.09s

Stmts هو عدد العبارات القابلة للتنفيذ في الملف. وMiss عدد ما لم يُنفّذ منها قط. وCover النسبة التي نُفّذت. إحدى عشرة عبارة، أربع منها لم تُنفّذ: 64%.

القيمة بعد --cov= هي الكود الذي تريد قياسه، لا الاختبارات. فالاختبارات تنفّذ كل أسطرها دائماً، وعدّها لن يفعل سوى تجميل المجموع.

قراءة عمود Missing

النسبة تخبرك كم. ولا تخبرك أين. أضف --cov-report=term-missing:

text
$ 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.09s

3, 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 بدلاً من ذلك موقعاً صغيراً:

text
$ 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.

تغطية الفروع — لماذا تكذب تغطية الأسطر

إليك دالة تقول عنها تغطية الأسطر إن كل شيء على ما يرام:

python
def final_price(total: float, coupon: str | None) -> float:
    if coupon == "SAVE10":
        total = total * 0.9
    return round(total, 2)
python
from shop.coupons import final_price


def test_coupon_takes_ten_percent_off():
    assert final_price(200.0, "SAVE10") == 180.0
text
$ 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.09s

100%. نُفّذ كل سطر. لكن لم يستدعِ أحد final_price دون قسيمة قط. لعبارة if بلا else مخرجان — الدخول إلى جسمها، أو تجاوزه مباشرة — ولم يُجرَّب إلا أحدهما. ولو كسر أحدهم لاحقاً مسار عدم وجود قسيمة، فلن يلاحظ هذا التقرير شيئاً.

تغطية الأسطر تسأل "هل نُفّذ هذا السطر؟". وتغطية الفروع تسأل "هل ذهب كل قرار في الاتجاهين؟". فعّلها باستخدام --cov-branch:

text
$ 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 المجموع إلى شرط نجاح أو فشل:

text
$ 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__:

python
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"))
text
$ 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 بتعليق تفهمه التغطية:

python
if __name__ == "__main__":  # pragma: no cover
    print(final_price(200.0, "SAVE10"))
text
$ 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 يفعّلها:

toml
[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% لا تعني الصحة

تجيب التغطية عن "هل نُفّذ هذا السطر؟". ولا تجيب عن "هل تحقق أحد من أن النتيجة صحيحة؟". مثال صغير:

python
def is_leap_year(year: int) -> bool:
    return year % 4 == 0
python
from 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)
text
$ 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 أيضاً. اختبار آخر، اختير بالتفكير في القاعدة لا في الأسطر:

python
def test_1900_is_not():
    assert not is_leap_year(1900)
text
$ 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 الاختبارات على عدة عمليات. ثمانية اختبارات يستغرق كل منها نصف ثانية:

python
import time

import pytest


@pytest.mark.parametrize("n", range(8))
def test_slow_check(n):
    time.sleep(0.5)
    assert n >= 0
text
$ 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؛ إذ تُدمج بيانات التغطية من كل العمّال في تقرير واحد.

لكن لهذا ثمن. كل عامل عملية منفصلة، ولم يعد ترتيب تشغيل الاختبارات هو ترتيبها في الملف. وأي اختبار كان يعتمد بصمت على أن اختباراً آخر قد عمل قبله سينكسر:

python
CART = []


def test_add_item():
    CART.append("pen")
    assert CART == ["pen"]


def test_cart_has_one_item():
    assert len(CART) == 1
text
$ 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: أعطِ كل اختبار حالته الخاصة.

python
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
text
$ 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:

toml
[dependency-groups]
dev = [
    "pytest>=9",
    "pytest-cov>=7",
    "pytest-xdist>=3.8",
]

ثم أضف .github/workflows/tests.yml:

yaml
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، يعطي الأمر نفسه النتيجة نفسها (الأسطر الأخيرة من المخرجات):

text
$ 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، وفيه الوحدتان من هذا الفصل، وكل الإعدادات في مكان واحد.

text
shop-project/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       ├── coupons.py
│       └── shipping.py
└── tests/
    ├── test_coupons.py
    └── test_shipping.py

pyproject.toml هو نفسه الذي في قسم الإعدادات أعلاه. وcoupons.py يحتفظ بكتلة __main__ دون pragma — يتكفّل بها exclude_also. وtest_coupons.py ما يزال فيه اختبار SAVE10 وحده. أما test_shipping.py فهو الآن خطة الاختبار من بداية الفصل، صفاً بصف:

python
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")
text
$ 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.