Coverage और CI — टेस्ट ने कौन-सी लाइनें कभी नहीं चलाईं
pytest-cov से coverage मापना, Missing कॉलम पढ़ना, branch coverage, न्यूनतम सीमा लागू करना, pytest-xdist से टेस्ट समानांतर चलाना, और GitHub Actions में हर push पर पूरा सूट चलाना। साथ ही, 100% coverage का मतलब सही कोड क्यों नहीं है।
- 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से 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 की कमांड लाइन से चालू करता है। इसे डेवलपमेंट डिपेंडेंसी के तौर पर इंस्टॉल करें:
$ 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:, अपने आप में स्टेटमेंट नहीं है, इसलिए ग्यारह की गिनती में नहीं है; coverage सूची छोटी रखने के लिए बस उसे 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 में डालिए।
Branch coverage — लाइन coverage झूठ क्यों बोलता है
यह एक ऐसा फ़ंक्शन है जहाँ लाइन coverage कहता है सब ठीक है:
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 को नहीं बुलाया। बिना else वाले if से निकलने के दो रास्ते हैं — भीतर के हिस्से में जाना, या सीधे आगे निकल जाना — और आज़माया सिर्फ़ एक गया। अगर बाद में कोई बिना-कूपन वाला रास्ता तोड़ दे, तो इस रिपोर्ट को पता भी नहीं चलेगा।
लाइन coverage पूछता है "क्या यह लाइन चली?"। Branch coverage पूछता है "क्या हर निर्णय दोनों दिशाओं में गया?"। इसे --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 संभावित जंप की संख्या है (लाइन 2 का if लाइन 3 पर जा सकता है या लाइन 4 पर: दो)। BrPart उन निर्णयों को गिनता है जो सिर्फ़ एक ही दिशा में गए। और Missing अब 2->4 दिखाता है: लाइन 2 से सीधे लाइन 4 का जंप — "कोई कूपन नहीं" वाला रास्ता — कभी नहीं हुआ।
Branch coverage ज़्यादा सख़्त है और सच के ज़्यादा क़रीब। इसे हमेशा चालू न रखने की शायद ही कोई वजह है।
संख्या को दरवाज़ा बनाना: --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। हर टेस्ट पास हुआ, फिर भी कमांड status 1 के साथ ख़त्म होती है। CI में यह लाल बिल्ड है। बात यह नहीं कि 90 कोई जादुई संख्या है; बात यह है कि जब कोई बिना टेस्ट के कोड जोड़ेगा, तो coverage चुपचाप नीचे नहीं खिसक पाएगा।
आज आप जहाँ हैं उससे थोड़ा नीचे की सीमा चुनिए, और समय के साथ बढ़ाइए। पहले ही दिन 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 को ऐसी टिप्पणी से चिह्नित कीजिए जिसे coverage समझता है:
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 फ़्लैग टाइप करना ही उन्हें भूलने का रास्ता है। coverage अपनी सेटिंग 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% coverage का मतलब सही नहीं
coverage जवाब देता है "क्या यह लाइन चली?"। यह जवाब नहीं देता "क्या किसी ने जाँचा कि नतीजा सही है?"। एक छोटा उदाहरण:
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% — और अब एक फ़ेल। coverage इस बग की ओर इशारा कर ही नहीं सकता था, क्योंकि कोई लाइन छूटी नहीं थी; छूटा था एक केस। coverage को इस नक्शे की तरह देखिए कि आपने कहाँ निश्चित रूप से नहीं देखा, इस सबूत की तरह कभी नहीं कि जहाँ आपने देखा वह सही है।
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 हर CPU core के लिए एक worker शुरू करता है (इस मशीन पर चार) और हर एक को चलाने के लिए टेस्ट देता है। यह --cov के साथ भी काम करता है; सभी workers का coverage डेटा मिलकर एक रिपोर्ट बनता है।
इसकी एक क़ीमत है। हर worker एक अलग प्रोसेस है, और टेस्ट चलने का क्रम अब फ़ाइल का क्रम नहीं रहता। जो टेस्ट चुपचाप इस भरोसे पर था कि कोई दूसरा टेस्ट पहले चल चुका होगा, वह टूटेगा:
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 ==========================दूसरा टेस्ट सिर्फ़ इसलिए पास हुआ था कि पहले टेस्ट ने साझा सूची पहले ही भर दी थी। दूसरे worker पर सूची ख़ाली थी। pytest test_cart.py::test_cart_has_one_item को अकेले चलाने पर भी यह इसी तरह फ़ेल होता है — xdist ने बग नहीं बनाया, उसे उजागर किया।
हल वही है जो fixture वाले अध्यायों में था: हर टेस्ट को उसकी अपनी state दीजिए।
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 ===============================एक टेस्ट को अकेले, किसी भी क्रम में, किसी भी worker पर पास होना चाहिए। pytest-randomly नाम का एक plugin पूरी तरह इसी विचार पर बना है: यह हर रन में टेस्टों का क्रम बदल देता है, ताकि छिपी हुई निर्भरता महीनों बाद CI में नहीं, जल्दी, आपकी मशीन पर ही सामने आ जाए। इस कोर्स के लिए इसकी ज़रूरत नहीं; इतना जानना काफ़ी है कि "सूट सिर्फ़ फ़ाइल के क्रम में पास होता है" — यह एक बग है।
हर push पर चलाना: 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पर हर push और हर pull request पर चलाओ।matrix:— job तीन बार चलता है, हर पायथन वर्ज़न के लिए एक बार, साथ-साथ।fail-fast: falseतीनों को पूरा होने देता है, ताकि आप देख सकें कि कोई फ़ेल किसी एक वर्ज़न तक सीमित है या नहीं।setup-uvuv इंस्टॉल करता है और 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 के बाद, वही कमांड वही नतीजा देती है (आउटपुट की आख़िरी लाइनें):
$ 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 वाला एक प्रोजेक्ट, इस अध्याय के दोनों मॉड्यूल, और हर सेटिंग एक जगह।
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 ने सब कुछ किया — बिना किसी फ़्लैग के। मापना, 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।
चरण 4 / 6 — अनुमान
अपनी समझ की जाँच करें
सिर्फ़ एक टेस्ट है, और branch coverage चालू है। coupons.py वाली पंक्ति में Missing कॉलम क्या दिखाएगा?
# 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- Apytest-xdist में बग है; टेस्ट ठीक हैं
- B`-n 2` हर टेस्ट को दो बार चलाता है
- C`CART` मॉड्यूल-स्तर पर है, इसलिए वह workers पर बनता ही नहीं
- Dदूसरा टेस्ट पहले टेस्ट के चलने पर निर्भर है — दोनों टेस्ट एक ही state साझा करते हैं
एक फ़ंक्शन की line और branch coverage दोनों 100% हैं, और सभी टेस्ट पास हैं। इससे क्या निष्कर्ष निकलता है?
- Aफ़ंक्शन सही है और अब और टेस्ट की ज़रूरत नहीं
- Bसिर्फ़ इतना कि हर लाइन और हर निर्णय की हर दिशा कम से कम एक बार चली — यह नहीं कि नतीजे सही हैं
- Cफ़ंक्शन में कोई boundary बग नहीं हो सकता
- D`fail_under = 100` रखने पर फ़ंक्शन में फिर कोई बग नहीं आ सकता
उत्तर देने के लिए अकाउंट आवश्यक है
अपने उत्तर जाँचने के लिए साइन इन करें
प्रश्न ऊपर दिए गए हैं, और मन में उत्तर सोचना ही मुख्य कार्य है। सही उत्तर, व्याख्या और तीन-स्तरीय संकेत देखने के लिए साइन इन करें।
आपकी बारी
इस मॉड्यूल के साथ एक छोटा प्रोजेक्ट शुरू कीजिए, 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 जो हर रन में branch coverage मापता है:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = truepytest -qचलाइए औरMissingकॉलम पढ़िए। हर एंट्री का मतलब शब्दों में बताइए।- कोई भी टेस्ट लिखने से पहले, अनुबंध के लिए टेस्ट योजना की तालिका लिखिए — ठीक 100 वाली सीमा और उसके ठीक नीचे वाले किनारे के साथ।
- योजना को एक parametrize किए गए टेस्ट और एक
pytest.raisesटेस्ट में बदलिए। [tool.coverage.report]के नीचेfail_under = 100जोड़िए और पक्का कीजिए कि रन पास होता है।- ग़ैर-सदस्य वाली पंक्तियाँ हटाइए और पक्का कीजिए कि रन फ़ेल होता है — भले ही बाक़ी हर टेस्ट पास हो।
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 पर जंप है — ग़ैर-सदस्य वाला रास्ता, कभी नहीं लिया गया। branch coverage के बिना 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, exit status 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%: चार workers का coverage मिलकर एक रिपोर्ट बना।
हर फ़ैसले की वजह।
- योजना टेस्ट से पहले आई। छह पंक्तियों में से रिपोर्ट ने सिर्फ़ दो माँगी थीं (
3और5->7)।99.99और100इसलिए हैं क्योंकि अनुबंध में 100 पर एक किनारा है, और0, Trueइसलिए कि अनुबंध कहता है शून्य मान्य है — यह इस बात का टेस्ट है कि यहाँ एरर नहीं आता। इनमें से किसी के बिना भी coverage संतुष्ट रहता। - यहाँ `-1` क्यों ठीक है। शर्त
total < 0है, इसलिए किनारा ख़ुद0है, और0, Trueवाली पंक्ति उसे पहले ही मान्य के रूप में बाँध देती है। अगर कोई शर्त कोtotal <= 0कर दे, तो वह पंक्ति फ़ेल होगी। फिर अमान्य पक्ष के लिए कोई भी ऋणात्मक संख्या काम करती है। - यहाँ `fail_under = 100` क्यों, पर असली प्रोजेक्ट में क्यों नहीं। इतना छोटा मॉड्यूल उचित रूप से पूरा कवर किया जा सकता है। बड़े कोडबेस में आज की संख्या से ठीक नीचे से शुरू कीजिए और बढ़ाइए।
- `-n auto` वाला रन कुछ साबित क्यों करता है। हर केस को अपने आर्गुमेंट मिलते हैं और टेस्टों के बीच कुछ साझा नहीं होता, इसलिए क्रम या worker से फ़र्क़ नहीं पड़ता। अगर
-n autoमें नतीजा बदलता, तो वही सबसे पहले ठीक करने वाला बग होता।
Step 6 of 6
चुनौती — the chapter quiz
सरल से कठिन — दस प्रश्न, अंतिम वाले जानबूझकर चुनौतीपूर्ण बनाए गए हैं।
Sign in to take the quiz