pytest के साथ टेस्टिंग
assert से लेकर pytest तक, फ़ेल की रिपोर्ट पढ़ना, pytest.raises, parametrize, tmp_path और fixtures — और किस तरह का कोड टेस्ट करना आसान होता है।
आप कैसे सीखना चाहते हैं?
वह समस्या जिसे हम हल कर रहे हैं
अब तक हमने कोड को एक ही तरह से “टेस्ट” किया है: चलाकर देखा, और आउटपुट पढ़कर सोचा कि सही लग रहा है या नहीं।
यह एक बार काम करता है। दिक़्क़त दूसरी बार से शुरू होती है।
अध्याय 25 के प्रोग्राम में TAX_RATE 0.15 था। मान लीजिए उसे बदलकर 0.18 कर दिया गया। प्रोग्राम चलेगा, सारी संख्याएँ प्रिंट होंगी, कहीं कोई एरर नहीं आएगा — और आपको पता भी नहीं चलेगा कि कुछ बदला या नहीं, जब तक पिछला आउटपुट आपको याद न हो।
और जो याद रखना पड़ता है, वह एक दिन याद नहीं रहता।
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 1) == 17.25
print("ok")okassert एक सीधा वाक्य है: “यह सच होना चाहिए”। सच हो तो कुछ नहीं होता; ग़लत हो तो प्रोग्राम AssertionError के साथ रुक जाता है।
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorयही नींव है। यह अध्याय उसी नींव पर बने एक टूल के बारे में है — pytest — जो ऐसे सैकड़ों assert चलाता है, और कोई फ़ेल हो तो बताता है कि क्यों फ़ेल हुआ।
इस अध्याय के अंत तक आप कर पाएँगे
- एक टेस्ट फ़ाइल लिखना और
pytestचलाना - फ़ेल हुए टेस्ट की रिपोर्ट पढ़कर समस्या ढूँढना
pytest.raisesसे एरर टेस्ट करना@pytest.mark.parametrizeसे एक टेस्ट में कई केस चलानाtmp_pathसे फ़ाइल पर निर्भर कोड टेस्ट करना- बताना कि कौन-सा कोड टेस्ट करना आसान है और क्यों
पहले क्या आना चाहिए: डेटाक्लास और टाइप हिंट्स।
पहला टेस्ट
दो नियम, बस: फ़ाइल का नाम test_ से शुरू होगा, और टेस्ट फ़ंक्शन का नाम भी। (pytest _test.py पर ख़त्म होने वाली फ़ाइलें भी ढूँढता है, लेकिन इस कोर्स में हम नाम test_ से ही शुरू करेंगे।)
pricing.py:
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)test_pricing.py:
from pricing import line_total
def test_one_item():
assert line_total(15.0, 1) == 17.25
def test_three_items():
assert line_total(15.0, 3) == 51.75फिर pytest -q:
.. [100%]
2 passed in 0.00sदो डॉट, दो पास हुए टेस्ट। pytest ख़ुद फ़ाइलें ढूँढ लेता है, फ़ंक्शन ढूँढ लेता है, और चलाता है — कहीं अलग से रजिस्टर नहीं करना पड़ता। आख़िर का समय (0.00s) आपकी मशीन पर थोड़ा अलग हो सकता है।
फ़ेल की रिपोर्ट ही असली चीज़ है
test_pricing.py के दूसरे टेस्ट में जानबूझकर ग़लत संख्या रखते हैं — 51.75 की जगह 51.0 — और फिर से pytest -q चलाते हैं:
.F [100%]
=================================== FAILURES ===================================
_______________________________ test_three_items _______________________________
def test_three_items():
> assert line_total(15.0, 3) == 51.0
E assert 51.75 == 51.0
E + where 51.75 = line_total(15.0, 3)
test_pricing.py:9: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed, 1 passed in 0.01sपहली लाइन के .F का मतलब है कि पहला टेस्ट पास हुआ, दूसरा फ़ेल। सादे assert से हमें सिर्फ़ AssertionError मिला था — एक शब्द, बिना किसी जानकारी के। यहाँ मिलता है:
- कौन-सा टेस्ट (
test_three_items) और कौन-सी लाइन (test_pricing.py:9) >निशान से ठीक कौन-साassertटूटाassert 51.75 == 51.0— असल में क्या मिला, और क्या उम्मीद थीwhere 51.75 = line_total(15.0, 3)— वह संख्या कहाँ से आई- आख़िरी लाइन में कुल हिसाब:
1 failed, 1 passed
इसे assertion introspection कहते हैं, और pytest इस्तेमाल करने की मुख्य वजह यही है। कई टेस्टिंग टूल में इसके लिए assertEqual(a, b) जैसे अलग मेथड सीखने पड़ते हैं; यहाँ सादा == लिखना ही काफ़ी है।
एरर भी टेस्ट करने होते हैं
अध्याय 24 में हमने सीखा कि ग़लत इनपुट मिलने पर फ़ंक्शन को raise करना चाहिए। यह बर्ताव भी एक वादा है, इसलिए इसे भी टेस्ट किया जा सकता है:
import pytest
from pricing import line_total
def test_zero_is_rejected():
with pytest.raises(ValueError):
line_total(15.0, 0)
def test_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3).. [100%]
2 passed in 0.01spytest.raises ब्लॉक कहता है “इसके अंदर यह एरर आना चाहिए”। न आए तो टेस्ट फ़ेल होता है। जैसे, test_one_is_rejected नाम का टेस्ट अगर line_total(15.0, 1) से ValueError की उम्मीद करे — जबकि मात्रा 1 तो सही है:
F [100%]
=================================== FAILURES ===================================
_____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE ValueError
test_pricing.py:7: Failed
=========================== short test summary info ============================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE ValueError
1 failed in 0.01smatch= वाला हिस्सा देखता है कि एरर मैसेज के अंदर वह टेक्स्ट है या नहीं। इसे लिखना काम का है, क्योंकि अध्याय 24 का नियम — जिस वैल्यू ने समस्या की, उसे मैसेज में रखिए — तब ख़ुद एक टेस्ट किया हुआ वादा बन जाता है।
एक टेस्ट, कई केस
चार केस के लिए चार फ़ंक्शन लिखना उबाऊ है, और कॉपी-पेस्ट की ग़लतियों को न्योता देता है।
import pytest
from pricing import line_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(100.0, 2, 230.0),
],
)
def test_line_total(price, quantity, expected):
assert line_total(price, quantity) == expectedpytest -v चलाने पर:
============================= test session starts ==============================
collecting ... collected 4 items
test_pricing.py::test_line_total[15.0-1-17.25] PASSED [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED [100%]
============================== 4 passed in 0.01s ===============================एक फ़ंक्शन से चार अलग टेस्ट। हर टेस्ट के नाम में उसकी वैल्यू लिखी है, इसलिए कोई फ़ेल हो तो तुरंत पता चल जाता है कि कौन-सा। लूप से लिखते तो पहले फ़ेल पर ही बाक़ी रुक जाते, और यह भी पता नहीं चलता कि कौन-सी वैल्यू दोषी है।
दूसरे केस में जानबूझकर 51.75 की जगह 51.0 लिखकर pytest -q चलाएँ, तो रिपोर्ट की शुरुआत ऐसी दिखती है — वैल्यू भी दिखती हैं:
.F.. [100%]
=================================== FAILURES ===================================
_________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0फ़ाइल चाहिए हो तो — tmp_path
अध्याय 23 जैसे फ़ाइल पढ़ने वाले कोड को टेस्ट करने के लिए एक फ़ाइल चाहिए। उसे रिपॉज़िटरी में रख देना बुरी आदत है: टेस्ट तब आपस में उलझ जाते हैं, और एक टेस्ट फ़ाइल बदले तो दूसरा टूट जाता है।
मान लीजिए reader.py में यह फ़ंक्शन है — एक टेक्स्ट फ़ाइल से ख़ाली न होने वाली लाइनें लौटाता है:
from pathlib import Path
def read_names(path: Path) -> list[str]:
"""Return the non-blank lines of a text file, without spaces at the ends."""
text = path.read_text(encoding="utf-8")
return [line.strip() for line in text.splitlines() if line.strip()]pytest हर टेस्ट को उसका अपना एक ख़ाली फ़ोल्डर दे सकता है। test_reader.py:
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one\n\ntwo\n", encoding="utf-8")
assert read_names(path) == ["one", "two"]
def test_empty_file_gives_empty_list(tmp_path):
path = tmp_path / "names.txt"
path.write_text("", encoding="utf-8")
assert read_names(path) == [].. [100%]
2 passed in 0.01stmp_path एक pathlib.Path है — अध्याय 23 वाला वही Path, और / से पाथ जोड़ना भी वहीं से। पैरामीटर का नाम ही pytest को बताता है कि क्या देना है। हर टेस्ट को एक नया फ़ोल्डर मिलता है — इसलिए दो टेस्ट में एक ही नाम की फ़ाइल हो, तब भी वे अलग हैं।
बार-बार लगने वाली तैयारी — fixture
एक ही इनपुट कई टेस्ट में चाहिए हो तो उसे एक बार लिखकर एक नाम दिया जा सकता है। यहाँ order_total pricing.py का एक और फ़ंक्शन है, जो पूरे ऑर्डर का टोटल निकालता है — पूरी फ़ाइल नीचे पूरे उदाहरण में है:
import pytest
from pricing import order_total
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1)]
def test_total(order):
assert order_total(order) == 1029.25
def test_one_line_removed(order):
assert order_total(order[:1]) == 51.75.. [100%]
2 passed in 0.01stmp_path की तरह ही: पैरामीटर का नाम देखकर pytest मिला देता है। और फ़ंक्शन हर टेस्ट के लिए फिर से चलता है, इसलिए एक टेस्ट लिस्ट बदल भी दे, तब भी अगले को नई लिस्ट मिलती है — अध्याय 21 की साझा डेटा वाली समस्या यहाँ डिज़ाइन से ही हटा दी गई है।
फ़्लोट का जाल
def test_addition():
assert 0.1 + 0.2 == 0.3F [100%]
=================================== FAILURES ===================================
________________________________ test_addition _________________________________
def test_addition():
> assert 0.1 + 0.2 == 0.3
E assert (0.1 + 0.2) == 0.3
test_money.py:2: AssertionError
=========================== short test summary info ============================
FAILED test_money.py::test_addition - assert (0.1 + 0.2) == 0.3
1 failed in 0.01sअध्याय 5 वाली वही पुरानी बात, और टेस्ट में आकर अक्सर यह पहली बार परेशान करती है।
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.00spytest.approx कहता है “लगभग बराबर हो तो चलेगा”। फ़्लोट वाले किसी भी टेस्ट में यही इस्तेमाल कीजिए — जब तक संख्या को round() से पहले ही तय न कर दिया हो, जैसा line_total करता है।
क्या टेस्ट करें
एक नियम बाक़ी सब नियमों से ज़्यादा काम आता है: जो फ़ंक्शन वैल्यू लेता है और वैल्यू लौटाता है, उसे टेस्ट करना आसान है।
अध्याय 21 के pure फ़ंक्शन याद हैं? इस अध्याय में उनका फ़ायदा दिखता है। line_total को टेस्ट करने के लिए न कोई फ़ाइल लगी, न कोई इनपुट, न कुछ सजाना पड़ा — बस कॉल करके नतीजा देखना।
उल्टी तरफ़, जो फ़ंक्शन प्रिंट करता है, इनपुट माँगता है, या फ़ाइल लिखता है — उसे टेस्ट करने के लिए अलग इंतज़ाम करना पड़ता है। इसीलिए अध्याय 23 के प्रोग्राम में report ने कोई फ़ाइल नहीं ली: उसने एक लिस्ट ली और एक लिस्ट लौटाई।
टेस्ट लिखना मुश्किल लगे, तो आम तौर पर दोष टेस्ट का नहीं — कोड की बनावट का होता है।
क्या टेस्ट करें — तीन तरह के केस: जो आम तौर पर होता है, किनारे वाले केस (शून्य, ख़ाली, एक), और जो फ़ेल होना चाहिए।
एक पूरा उदाहरण
pricing.py:
"""Prices and tax. Pure functions, which is what makes them testable."""
TAX_RATE = 0.15
def line_total(price: float, quantity: int) -> float:
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
return round(price * quantity * (1 + TAX_RATE), 2)
def order_total(lines: list[tuple[str, float, int]]) -> float:
return round(sum(line_total(price, qty) for _, price, qty in lines), 2)test_pricing.py:
"""Tests for pricing. Each one names the behaviour it protects."""
import pytest
from pricing import line_total, order_total
@pytest.mark.parametrize(
"price, quantity, expected",
[
(15.0, 1, 17.25),
(15.0, 3, 51.75),
(0.0, 5, 0.0),
(0.01, 1, 0.01),
],
)
def test_line_total_applies_tax(price, quantity, expected):
assert line_total(price, quantity) == expected
@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
with pytest.raises(ValueError, match="at least 1"):
line_total(15.0, quantity)
def test_error_message_names_the_value():
with pytest.raises(ValueError, match="at least 1: -3"):
line_total(15.0, -3)
@pytest.fixture
def order():
return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]
def test_order_total_sums_the_lines(order):
assert order_total(order) == 1305.25
def test_empty_order_totals_zero():
assert order_total([]) == 0
def test_one_bad_line_stops_the_order(order):
with pytest.raises(ValueError):
order_total(order + [("clip", 5.0, 0)])........... [100%]
11 passed in 0.01sचार बातें ध्यान देने लायक़ हैं।
ग्यारह टेस्ट, छह टेस्ट फ़ंक्शन। यह फ़र्क़ parametrize का काम है, और हर केस अलग से गिना जा रहा है।
नाम बर्ताव बताते हैं, कोड नहीं। test_quantity_below_one_is_rejected पढ़कर पता चलता है कि प्रोग्राम क्या वादा करता है; test_line_total_2 पढ़कर कुछ पता नहीं चलता। फ़ेल की रिपोर्ट में सबसे पहले यही नाम दिखेगा, इसलिए नाम एक वाक्य जैसा हो तो अच्छा।
0.01 वाला केस यूँ ही नहीं चुना गया। यह सबसे छोटी संभव क़ीमत है — एक किनारे वाला केस, और round() का बर्ताव ठीक वहीं सबसे संदिग्ध होता है। बग आम तौर पर किनारों पर होते हैं, बीच में नहीं।
आख़िरी टेस्ट एक अप्रत्यक्ष वादा थामे हुए है। order_total ख़ुद कोई जाँच नहीं करता — वह line_total पर निर्भर है। अगर कोई एक दिन order_total में try/except लगाकर ख़राब लाइनों को चुपचाप छोड़ना चाहे, तो यह टेस्ट फ़ेल होकर पूछेगा — “क्या आप सच में यही चाहते हैं?”
जब कुछ टूटता है
no tests ran फ़ाइल का नाम test_ से शुरू नहीं हुआ (या _test.py पर ख़त्म नहीं हुआ), या फ़ंक्शन का नाम test_ से शुरू नहीं हुआ। दोनों चाहिए।
ModuleNotFoundError: No module named 'pricing' जिस फ़ोल्डर में फ़ाइलें हैं, वहीं से pytest चलाइए।
टेस्ट पास होता है, लेकिन कुछ जाँचता नहीं assert लिखना भूल गए हैं। सिर्फ़ फ़ंक्शन कॉल करने पर, वह एरर न दे तो टेस्ट पास।
fixture 'order' not found @pytest.fixture लिखना भूल गए, या नाम पैरामीटर के नाम से नहीं मिलता।
फ़्लोट की तुलना फ़ेल हो रही है, जबकि संख्याएँ सही हैं pytest.approx इस्तेमाल कीजिए, या फ़ंक्शन के अंदर ही round() कीजिए।
DID NOT RAISE pytest.raises ब्लॉक के अंदर के कोड ने एरर नहीं दिया। या तो कोड ग़लत है, या टेस्ट की उम्मीद।
एक टेस्ट अकेले चलाने पर पास होता है, सबके साथ चलाने पर फ़ेल टेस्ट कुछ साझा कर रहे हैं — अक्सर मॉड्यूल-लेवल की कोई लिस्ट या डिक्शनरी। अध्याय 21 और 26 वाला साझा म्यूटेबल डेटा। fixture इस्तेमाल कीजिए।
चरण 4 / 6 — अनुमान
अपनी समझ की जाँच करें
pytest -q चलाने पर रिपोर्ट की आख़िरी लाइन क्या होगी?
# checks.py
def is_even(n):
return n % 2 == 0
# test_checks.py
from checks import is_even
def test_even():
assert is_even(4)
def test_odd():
assert is_even(7)- A2 failed in 0.01s
- B1 failed, 1 passed in 0.01s
- C1 failed in 0.01s
- D2 passed in 0.01s
add(2, 2) तो 5 नहीं है। pytest -q क्या कहेगा?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- Aएक `AssertionError`
- B1 failed — जैसा होना चाहिए
- Cno tests ran
- D1 passed — टेस्ट पास हो जाता है
पहले टेस्ट ने टोकरी में एक चीज़ रखी है। दूसरा क्या देखेगा?
# helpers.py
def build():
return []
# test_helpers.py
import pytest
from helpers import build
@pytest.fixture
def basket():
return build()
def test_first(basket):
basket.append("one")
assert len(basket) == 1
def test_second(basket):
assert len(basket) == 0- Afixture 'basket' not found
- B2 failed
- C2 passed — दूसरे टेस्ट को एक नई ख़ाली लिस्ट मिलती है
- D1 failed, 1 passed — दूसरा एक चीज़ देखता है
उत्तर देने के लिए अकाउंट आवश्यक है
अपने उत्तर जाँचने के लिए साइन इन करें
प्रश्न ऊपर दिए गए हैं, और मन में उत्तर सोचना ही मुख्य कार्य है। सही उत्तर, व्याख्या और तीन-स्तरीय संकेत देखने के लिए साइन इन करें।
आपकी बारी
अध्याय 27 की library.py लीजिए — Book और Shelf — और उसके बगल में test_library.py लिखिए।
कम से कम ये हों:
parametrizeसेtotal_pages()के कुछ केस, ख़ाली शेल्फ़ समेतpytest.raisesसेpages=0रिजेक्ट होता है या नहीं,match=के साथ- ख़ाली शेल्फ़ पर
longest()काNoneलौटाना by_authorका ठीक उसी लेखक की किताबें लौटाना, और न हों तो ख़ाली लिस्टtmp_pathइस्तेमाल करने वाला एक टेस्ट, जिसमें शेल्फ़ को JSON में लिखकर फिर पढ़ा जाए
फिर पाँच प्रयोग:
- किसी टेस्ट से
assertहटा दीजिए, सिर्फ़ फ़ंक्शन कॉल कीजिए। टेस्ट पास होता है? - टेस्ट फ़ाइल का नाम बदलकर
check_library.pyकीजिए औरpytestचलाइए। क्या होता है? (library_test.pyनाम दें तो मिल जाती है — क्यों?) Bookकीpages < 1वाली जाँच हटा दीजिए। कौन-से टेस्ट फ़ेल होते हैं, और क्या रिपोर्ट बताती है कि क्यों?total_pages()में जानबूझकर एक ग़लती डालिए — जैसेsum(...) + 1। फ़ेल की रिपोर्ट में असली और उम्मीद वाली दोनों संख्याएँ दिखती हैं?- एक टेस्ट लिखिए जो
0.1 + 0.2 == 0.3काassertकरे। फिरpytest.approxसे ठीक कीजिए।
तीसरा प्रयोग सबसे ज़्यादा सिखाता है। एक अच्छा टेस्ट सूट टूटने पर आपको बताता है कि कौन-सा वादा टूटा — और यही किसी प्रोग्राम को छह महीने बाद भी बेफ़िक्र होकर बदलने लायक़ रखता है।
चरण 6 / 6
चुनौती — अध्याय की क्विज़
सरल से कठिन — दस प्रश्न, अंतिम वाले जानबूझकर चुनौतीपूर्ण बनाए गए हैं।
क्विज़ देने के लिए साइन इन करें