अध्याय 14

Coverage और CI — टेस्ट ने कौन-सी लाइनें कभी नहीं चलाईं

pytest-cov से coverage मापना, Missing कॉलम पढ़ना, branch coverage, न्यूनतम सीमा लागू करना, pytest-xdist से टेस्ट समानांतर चलाना, और GitHub Actions में हर push पर पूरा सूट चलाना। साथ ही, 100% coverage का मतलब सही कोड क्यों नहीं है।

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 से coverage मापना और Missing कॉलम पढ़ना
  • HTML रिपोर्ट खोलकर कवर न हुई लाइनों को कोड के भीतर ही देखना
  • समझाना कि कोई शाखा कभी न चलने पर भी लाइन coverage 100% क्यों बता सकता है, और branch coverage चालू करना
  • coverage एक सीमा से नीचे जाने पर रन को फ़ेल करना, और जान-बूझकर कुछ कोड को बाहर रखना
  • यह सब pyproject.toml में रखना
  • बताना कि 100% coverage का मतलब सही कोड क्यों नहीं है
  • pytest-xdist से टेस्ट समानांतर चलाना, और समझाना कि टेस्ट एक-दूसरे पर निर्भर क्यों नहीं होने चाहिए
  • ऐसा GitHub Actions workflow लिखना जो कई पायथन वर्ज़न पर सूट चलाए

ज़रूरी शर्तें: कॉन्फ़िगरेशन और चेतावनियाँ।


टेस्ट लिखने से पहले

coverage इस्तेमाल करने का ग़लत तरीक़ा है पहले रिपोर्ट खोलना, फिर कोई भी ऐसा टेस्ट लिख देना जो अगली लाल लाइन को हरा कर दे। इससे ऐसे टेस्ट बनते हैं जिनका आकार कोड जैसा होता है, कोड के वादे जैसा नहीं — और, जैसा आप देखेंगे, उन बगों को वे पकड़ नहीं पाते जिनकी अपनी कोई लाइन नहीं होती। सही क्रम है: अनुबंध (contract) से योजना बनाइए, टेस्ट लिखिए, और उसके बाद ही coverage से पूछिए कि योजना क्या भूल गई।

अनुबंध। shipping_cost को कोड की तरह नहीं, एक वादे की तरह पढ़िए:

  • वज़न शून्य या उससे कम → ValueError, जिसके संदेश में "positive" लिखा हो
  • बांग्लादेश का ख़र्च 60, भारत का 90, बाक़ी कहीं भी 500
  • 5 किलो से ऊपर हर अतिरिक्त किलो पर 20 जुड़ते हैं; ठीक 5 किलो पर कुछ नहीं जुड़ता
  • कोई साइड इफ़ेक्ट नहीं: यह एक संख्या लौटाता है और किसी और चीज़ को नहीं छूता

क्या तैयार होना चाहिए। एक वर्चुअल एनवायरनमेंट जिसमें pytest और pytest-cov इंस्टॉल हों (और बाद में pytest-xdist)। पैकेज टेस्ट से import हो सकना चाहिए — यहाँ shop, या तो इंस्टॉल किया हुआ, या पिछले अध्याय के pythonpath = ["src"] के ज़रिए। और जिस कोड को माप रहे हैं उसका import नाम पता होना चाहिए, क्योंकि --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 किलो वाला पहले से न चलाता हो, इसलिए कोई coverage रिपोर्ट इसे कभी नहीं माँगेगी। और अमान्य केस में, -1 वाला टेस्ट raise लाइन को ठीक 0 जितना ही कवर करता — लेकिन अगर कोई <= 0 की जगह weight_kg < 0 लिख दे, तो सिर्फ़ 0 वाला टेस्ट फ़ेल होगा। coverage इन दो टेस्टों में फ़र्क़ नहीं कर सकता; अनुबंध कर सकता है। पूरा रिश्ता यही है: क्या टेस्ट करना है यह योजना तय करती है; योजना से क्या छूटा, यह coverage बताता है।

शुरुआत वाले दो टेस्ट पंक्ति 1 और 5 को कवर करते हैं। अध्याय का बाक़ी हिस्सा उस अंतर को मापता है, उसे भरता है, और फिर मापने को अपने-आप होने वाला बनाता है।

pytest-cov से मापना

coverage को 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:, अपने आप में स्टेटमेंट नहीं है, इसलिए ग्यारह की गिनती में नहीं है; coverage सूची छोटी रखने के लिए बस उसे 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 में डालिए।

Branch coverage — लाइन coverage झूठ क्यों बोलता है

यह एक ऐसा फ़ंक्शन है जहाँ लाइन coverage कहता है सब ठीक है:

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 को नहीं बुलाया। बिना else वाले if से निकलने के दो रास्ते हैं — भीतर के हिस्से में जाना, या सीधे आगे निकल जाना — और आज़माया सिर्फ़ एक गया। अगर बाद में कोई बिना-कूपन वाला रास्ता तोड़ दे, तो इस रिपोर्ट को पता भी नहीं चलेगा।

लाइन coverage पूछता है "क्या यह लाइन चली?"। Branch coverage पूछता है "क्या हर निर्णय दोनों दिशाओं में गया?"। इसे --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 संभावित जंप की संख्या है (लाइन 2 का if लाइन 3 पर जा सकता है या लाइन 4 पर: दो)। BrPart उन निर्णयों को गिनता है जो सिर्फ़ एक ही दिशा में गए। और Missing अब 2->4 दिखाता है: लाइन 2 से सीधे लाइन 4 का जंप — "कोई कूपन नहीं" वाला रास्ता — कभी नहीं हुआ।

Branch coverage ज़्यादा सख़्त है और सच के ज़्यादा क़रीब। इसे हमेशा चालू न रखने की शायद ही कोई वजह है।

संख्या को दरवाज़ा बनाना: --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। हर टेस्ट पास हुआ, फिर भी कमांड status 1 के साथ ख़त्म होती है। CI में यह लाल बिल्ड है। बात यह नहीं कि 90 कोई जादुई संख्या है; बात यह है कि जब कोई बिना टेस्ट के कोड जोड़ेगा, तो coverage चुपचाप नीचे नहीं खिसक पाएगा।

आज आप जहाँ हैं उससे थोड़ा नीचे की सीमा चुनिए, और समय के साथ बढ़ाइए। पहले ही दिन 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 को ऐसी टिप्पणी से चिह्नित कीजिए जिसे coverage समझता है:

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 फ़्लैग टाइप करना ही उन्हें भूलने का रास्ता है। coverage अपनी सेटिंग 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% coverage का मतलब सही नहीं

coverage जवाब देता है "क्या यह लाइन चली?"। यह जवाब नहीं देता "क्या किसी ने जाँचा कि नतीजा सही है?"। एक छोटा उदाहरण:

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% — और अब एक फ़ेल। coverage इस बग की ओर इशारा कर ही नहीं सकता था, क्योंकि कोई लाइन छूटी नहीं थी; छूटा था एक केस। coverage को इस नक्शे की तरह देखिए कि आपने कहाँ निश्चित रूप से नहीं देखा, इस सबूत की तरह कभी नहीं कि जहाँ आपने देखा वह सही है।

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 हर CPU core के लिए एक worker शुरू करता है (इस मशीन पर चार) और हर एक को चलाने के लिए टेस्ट देता है। यह --cov के साथ भी काम करता है; सभी workers का coverage डेटा मिलकर एक रिपोर्ट बनता है।

इसकी एक क़ीमत है। हर worker एक अलग प्रोसेस है, और टेस्ट चलने का क्रम अब फ़ाइल का क्रम नहीं रहता। जो टेस्ट चुपचाप इस भरोसे पर था कि कोई दूसरा टेस्ट पहले चल चुका होगा, वह टूटेगा:

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 ==========================

दूसरा टेस्ट सिर्फ़ इसलिए पास हुआ था कि पहले टेस्ट ने साझा सूची पहले ही भर दी थी। दूसरे worker पर सूची ख़ाली थी। pytest test_cart.py::test_cart_has_one_item को अकेले चलाने पर भी यह इसी तरह फ़ेल होता है — xdist ने बग नहीं बनाया, उसे उजागर किया।

हल वही है जो fixture वाले अध्यायों में था: हर टेस्ट को उसकी अपनी state दीजिए।

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 ===============================

एक टेस्ट को अकेले, किसी भी क्रम में, किसी भी worker पर पास होना चाहिए। pytest-randomly नाम का एक plugin पूरी तरह इसी विचार पर बना है: यह हर रन में टेस्टों का क्रम बदल देता है, ताकि छिपी हुई निर्भरता महीनों बाद CI में नहीं, जल्दी, आपकी मशीन पर ही सामने आ जाए। इस कोर्स के लिए इसकी ज़रूरत नहीं; इतना जानना काफ़ी है कि "सूट सिर्फ़ फ़ाइल के क्रम में पास होता है" — यह एक बग है।

हर push पर चलाना: 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 पर हर push और हर pull request पर चलाओ।
  • matrix: — job तीन बार चलता है, हर पायथन वर्ज़न के लिए एक बार, साथ-साथ। fail-fast: false तीनों को पूरा होने देता है, ताकि आप देख सकें कि कोई फ़ेल किसी एक वर्ज़न तक सीमित है या नहीं।
  • setup-uv uv इंस्टॉल करता है और job की उस कॉपी के लिए पायथन वर्ज़न तय करता है।
  • uv sync --locked ठीक वही इंस्टॉल करता है जो uv.lock कहता है, और lock फ़ाइल पुरानी हो तो फ़ेल हो जाता है — ताकि CI वही टेस्ट करे जो आपने commit किया है।
  • uv run pytest -n auto --cov-report=xml समानांतर चलाता है; fail_under समेत coverage की सेटिंग pyproject.toml से आती है, इसलिए coverage गिरा तो बिल्ड फ़ेल। XML रिपोर्ट टर्मिनल वाली के साथ बनती है, किसी coverage सेवा या डाउनलोड के लिए तैयार।

अपनी मशीन पर, 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 layout वाला एक प्रोजेक्ट, इस अध्याय के दोनों मॉड्यूल, और हर सेटिंग एक जगह।

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 ने सब कुछ किया — बिना किसी फ़्लैग के। मापना, branch जाँचना, छूटी लाइनों की सूची और सीमा — सब pyproject.toml से आया।

दूसरी, shipping.py 64% से 100% पर इसलिए पहुँचा कि योजना लिखी गई थी, इसलिए नहीं कि हम लाल लाइनों के पीछे भागे। फिर coverage पुष्टि करता है कि योजना हर लाइन और हर शाखा तक पहुँची। (5, "BD", 60.0) वाली पंक्ति और अमान्य वज़न के लिए -1 की जगह 0 चुनना — दोनों प्रतिशत में कुछ नहीं जोड़ते। ये इसलिए हैं क्योंकि अनुबंध के किनारे हैं, और <= की जगह < जैसे टाइपो किनारों पर ही रहते हैं।

तीसरी, 2->4 सूची में होने के बावजूद बिल्ड 96% पर पास है। सीमा फ़र्श है, फ़िनिश लाइन नहीं। रिपोर्ट अब भी ठीक-ठीक बता रही है कि अगला टेस्ट कौन-सा लिखना है: बिना कूपन के final_price।


जब यह काम न करे

error: unrecognized arguments: --cov=shop pytest जिस एनवायरनमेंट से चल रहा है उसमें pytest-cov इंस्टॉल नहीं है। इसे इंस्टॉल करें (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= के बाद का नाम आपके टेस्ट द्वारा import किए गए किसी पैकेज से मेल नहीं खाता — यहाँ एक टाइपो। वर्तनी जाँचिए, और src layout में फ़ोल्डर (src) नहीं, import नाम (shop) दीजिए।

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 में फ़ेल (या उल्टा) टेस्ट state साझा कर रहे हैं: मॉड्यूल-स्तर की कोई list या dict, किसी तय पाथ पर फ़ाइल, कोई एनवायरनमेंट वेरिएबल, डेटाबेस की कोई पंक्ति। पता कीजिए कि फ़ेल होने वाला टेस्ट क्या पहले से मौजूद मान रहा है, और वह उसे fixture, tmp_path या monkeypatch के ज़रिए दीजिए।

coverage 100% है पर उपयोगकर्ता फिर भी बग से टकराते हैं यह अपेक्षित है। coverage दिखाता है कि क्या चला, यह नहीं कि क्या जाँचा गया। लाइनें नहीं, केस खोजिए — सीमाएँ, ख़ाली इनपुट, साल 1900।