pytest के साथ टेस्टिंग
assert से लेकर pytest तक, विफलता की रिपोर्ट पढ़ना, pytest.raises, parametrize, tmp_path और fixtures — और किस तरह का कोड टेस्ट करना आसान होता है।
- 1समस्या
- 2समझें
- 3उदाहरण
- 4अनुमान
- 5स्वयं करें
- 6चुनौती
वह समस्या जिसे हम हल कर रहे हैं
अब तक हम अपने कोड को केवल एक ही तरीके से "टेस्ट" कर रहे थे: उसे चलाओ, आउटपुट पढ़ो, और तय करो कि वह सही दिख रहा है या नहीं।
यह तरीका एक बार काम करता है। परेशानी दूसरी बार से शुरू होती है।
पच्चीसवें अध्याय के प्रोग्राम में 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 एक सीधा-सादा वाक्य है: "यह सच होना चाहिए"। जब यह सच होता है, तो कुछ नहीं होता; जब यह सच नहीं होता, तो प्रोग्राम वहीं रुक जाता है।
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
assert line_total(15.0, 3) == 51.0
print("ok")AssertionErrorयही नींव है। यह अध्याय उसी पर बना एक टूल है — pytest — जो ऐसे सैकड़ों दावों को चलाता है और जब कोई विफल होता है, तो बताता है कि क्यों।
इस अध्याय के अंत में आप कर पाएंगे
- एक टेस्ट फ़ाइल लिखना और
pytestचलाना - विफलता की रिपोर्ट पढ़ना और उससे समस्या को खोजना
pytest.raisesके साथ एरर्स का परीक्षण करना@pytest.mark.parametrizeके साथ कई मामलों में एक ही टेस्ट चलानाtmp_pathके साथ फ़ाइल-हैंडलिंग कोड का परीक्षण करना- यह बताना कि किस तरह के कोड का परीक्षण करना आसान होता है, और क्यों
ज़रूरी शर्तें: डेटाक्लासेस और टाइप हिंट्स (Dataclasses and type hints)।
पहला टेस्ट
केवल दो नियम हैं, और बस इतना ही: फ़ाइल का नाम 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.01sदो डॉट्स (बिंदु), दो टेस्ट्स। pytest फ़ाइलों को खोजता है, फ़ंक्शंस को ढूँढता है और उन्हें चलाता है — कुछ भी अलग से रजिस्टर करने की आवश्यकता नहीं होती।
विफलता की रिपोर्ट ही असली बात है
एक टेस्ट में जानबूझकर एक ग़लत संख्या डालें:
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:5: AssertionError
=========================== short test summary info ===========================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed in 0.01sएक साधारण assert हमें केवल AssertionError देता था — एक अकेला शब्द, जिसमें कोई जानकारी नहीं होती थी। यहाँ हमें मिलता है:
- कौन सा टेस्ट (
test_three_items) और कौन सी लाइन (test_pricing.py:5) - सटीक दावा जो टूटा,
>के साथ चिह्नित assert 51.75 == 51.0— वास्तव में क्या उत्पादित हुआ, बनाम क्या अपेक्षित थाwhere 51.75 = line_total(15.0, 3)— वह संख्या कहाँ से आई
इसे assertion introspection कहा जाता है, और यही pytest का मुख्य कारण है। कई अन्य भाषाओं में इसके लिए एक अलग assertEqual(a, b) की आवश्यकता होती है; यहाँ एक साधारण == ही काफ़ी है।
एरर्स भी प्रोग्राम का व्यवहार (Behaviour) हैं
चौबीसवें अध्याय में कहा गया था कि ख़राब इनपुट मिलने पर फ़ंक्शन को 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 ब्लॉक कहता है "यहाँ यह एरर आना चाहिए"। जब यह नहीं आता, तो टेस्ट विफल हो जाता है:
F [100%]
================================== FAILURES ===================================
____________________________ test_one_is_rejected _____________________________
def test_one_is_rejected():
> with pytest.raises(ValueError):
E Failed: DID NOT RAISE <class 'ValueError'>
test_pricing.py:7: Failed
=========================== short test summary info ===========================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE <class '...
1 failed in 0.01smatch= वाला भाग यह जाँचता है कि एरर मैसेज में वह टेक्स्ट शामिल है या नहीं। इसे लिखना फ़ायदेमंद है, क्योंकि चौबीसवें अध्याय का नियम — मैसेज में समस्या पैदा करने वाली वैल्यू का नाम लिखें — फिर अपने आप में एक परखा हुआ वादा बन जाता है।
एक टेस्ट, कई केसेस
चार मामलों के लिए चार अलग फ़ंक्शंस लिखना उबाऊ है, और इससे कॉपी-पेस्ट की ग़लतियाँ होने की संभावना बढ़ जाती है।
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%]एक ही फ़ंक्शन से चार अलग-अलग टेस्ट्स बने। और हर नाम अपनी वैल्यूज़ साथ रखता है, इसलिए विफलता तुरंत अपनी पहचान कराती है — यदि इसे लूप के रूप में लिखा गया होता, तो पहली विफलता बाकी को रोक देती और यह नहीं बताती कि किन मानों में गड़बड़ी थी।
जब कोई गलत होता है, तो रिपोर्ट उन मानों को भी दिखाती है:
________________________ test_line_total[15.0-3-51.0] _________________________
price = 15.0, quantity = 3, expected = 51.0जब किसी फ़ाइल की आवश्यकता हो — tmp_path
तेईसवें अध्याय के कोड का परीक्षण करने के लिए एक फ़ाइल की आवश्यकता होती है। रिपॉजिटरी में फ़ाइल रखना एक बुरी आदत है: टेस्ट आपस में उलझ जाते हैं, और एक टेस्ट द्वारा फ़ाइल में किया गया बदलाव दूसरे को तोड़ देता है।
pytest हर टेस्ट को उसका अपना एक खाली फ़ोल्डर दे सकता है:
from reader import read_names
def test_blank_lines_are_skipped(tmp_path):
path = tmp_path / "names.txt"
path.write_text("one
two
", 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 ऑब्जेक्ट है — वही तेईसवें अध्याय वाला Path, जो उसी तरह / से जुड़ता है। पैरामीटर का नाम ही pytest को बताता है कि क्या सप्लाई करना है, और प्रत्येक टेस्ट को एक नया फ़ोल्डर मिलता है, इसलिए दो टेस्ट बिना किसी टकराव के एक ही फ़ाइल नाम का उपयोग कर सकते हैं।
बार-बार सेटअप — fixture
जब कई टेस्ट्स को एक ही इनपुट की आवश्यकता होती है, तो उसे एक बार लिखा और नाम दिया जा सकता है:
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 पैरामीटर के नाम से मेल खाता है। और फ़ंक्शन प्रत्येक टेस्ट के लिए दोबारा चलता है, इसलिए यदि कोई टेस्ट लिस्ट में बदलाव भी करता है, तब भी अगले टेस्ट को एक ताज़ा और नई लिस्ट मिलती है — इक्कीसवें अध्याय की शेयर्ड-स्टेट (shared-state) की समस्या से बचने के बजाय उसे डिज़ाइन के ज़रिए ही हल कर दिया गया।
फ़्लोटिंग-पॉइंट का जाल
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पाँचवें अध्याय का पुराना मामला, और टेस्ट ही आमतौर पर वह जगह होते हैं जहाँ यह सबसे पहले असर दिखाता है।
import pytest
def test_addition():
assert 0.1 + 0.2 == pytest.approx(0.3). [100%]
1 passed in 0.01spytest.approx कहता है "पर्याप्त रूप से करीब होना चलेगा"। फ़्लोट्स से जुड़े किसी भी टेस्ट में इसका उपयोग करें — जब तक कि संख्या को round() से पहले ही सीमित न कर दिया गया हो, जैसा कि line_total करता है।
क्या टेस्ट करें
एक नियम बाकी सभी से बढ़कर है: वह फ़ंक्शन जो वैल्यूज़ लेता है और एक वैल्यू लौटाता है, उसका परीक्षण करना सबसे आसान होता है।
इक्कीसवें अध्याय के शुद्ध (pure) फ़ंक्शंस याद हैं? यह अध्याय उनका फल है। line_total का परीक्षण करने के लिए किसी फ़ाइल, किसी इनपुट, किसी सेटअप की आवश्यकता नहीं थी — केवल इसे कॉल करना और परिणाम देखना था।
इसके विपरीत, एक फ़ंक्शन जो प्रिंट करता है, इनपुट मांगता है या फ़ाइल लिखता है, उसका परीक्षण करने से पहले तैयारियाँ करनी पड़ती हैं। यही कारण है कि तेईसवें अध्याय के report ने कोई फ़ाइल नहीं ली: इसने एक लिस्ट ली और एक लिस्ट लौटाई।
टेस्ट लिखने में कठिनाई आमतौर पर टेस्ट की ग़लती नहीं बल्कि कोड की संरचना की वजह से होती है।
क्या टेस्ट करें, इसे तीन श्रेणियों में समझें: सामान्य रूप से क्या होता है, किनारे (edges) (शून्य, खाली, एक), और जिसका विफल होना तय है।
पूर्ण उदाहरण
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)
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:
"""क़ीमत निर्धारण के लिए टेस्ट। प्रत्येक टेस्ट उस व्यवहार का नाम बताता है जिसकी वह सुरक्षा करता है।"""
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 वाला केस मनमाना नहीं है। यह सबसे छोटी संभव क़ीमत है — एक किनारा (edge), और ठीक वही जगह जहाँ round() का व्यवहार सबसे संदिग्ध होता है। बग्स किनारों पर रहते हैं, बीच में नहीं।
अंतिम टेस्ट एक अप्रत्यक्ष वादा रखता है। order_total अपना कोई सत्यापन नहीं करता — यह line_total पर निर्भर करता है। यदि कोई एक दिन ख़राब लाइनों को चुपचाप छोड़ने के लिए order_total में try/except लगा देता है, तो यह टेस्ट विफल हो जाएगा और पूछेगा: "क्या आपका वास्तव में यही मतलब था?"
जब यह काम न करे
no tests ran फ़ाइल का नाम test_ से शुरू नहीं होता, या फ़ंक्शन का नाम नहीं होता। दोनों का होना आवश्यक है।
ModuleNotFoundError: No module named 'pricing' pytest को उसी फ़ोल्डर से चलाएँ जहाँ फ़ाइलें स्थित हैं।
एक टेस्ट पास हो जाता है और कुछ भी चेक नहीं करता assert ग़ायब है। केवल किसी फ़ंक्शन को कॉल करना तब तक पास होता रहेगा जब तक कि वह कोई एरर न दे।
fixture 'order' not found @pytest.fixture ग़ायब है, या नाम पैरामीटर के नाम से मेल नहीं खाता।
फ़्लोट तुलना विफल हो जाती है हालाँकि संख्याएँ सही दिखती हैं pytest.approx का प्रयोग करें, या फ़ंक्शन में ही round() लगाएँ।
DID NOT RAISE pytest.raises ब्लॉक के अंदर का कोड विफल नहीं हुआ। या तो कोड ग़लत है, या फिर टेस्ट की अपेक्षा।
एक टेस्ट अकेले में पास होता है और दूसरों के साथ विफल हो जाता है टेस्ट्स आपस में कुछ शेयर कर रहे हैं — अक्सर मॉड्यूल-स्तरीय लिस्ट या डिक्शनरी। बाईसवें और छब्बीसवें अध्याय की शेयर्ड स्टेट। इसके लिए fixture का उपयोग करें।
चरण 4 / 6 — अनुमान
अपनी समझ की जाँच करें
Running pytest -q, what is the last line of the report?
# 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)- A1 failed, 1 passed in 0.01s
- B2 failed in 0.01s
- C2 passed in 0.01s
- D1 failed in 0.01s
add(2, 2) is not 5. What does pytest -q say?
# test_a.py
def add(a, b):
return a + b
def test_add():
add(2, 2) == 5- A1 passed — the test passes
- B1 failed — as it should
- Cno tests ran
- DAn `AssertionError`
The first test put an item in the basket. What does the second see?
# 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- A2 passed — the second test gets a fresh empty list
- B1 failed, 1 passed — the second sees one item
- C2 failed
- Dfixture 'basket' not found
उत्तर देने के लिए अकाउंट आवश्यक है
अपने उत्तर जाँचने के लिए साइन इन करें
प्रश्न ऊपर दिए गए हैं, और मन में उत्तर सोचना ही मुख्य कार्य है। सही उत्तर, व्याख्या और तीन-स्तरीय संकेत देखने के लिए साइन इन करें।
आपकी बारी
सत्ताईसवें अध्याय की library.py — Book और Shelf — लें और इसके बगल में test_library.py लिखें।
कम से कम ये टेस्ट शामिल करें:
parametrizeके माध्यम सेtotal_pages()के कई मामले, जिसमें एक खाली शेल्फ़ भी शामिल होpages=0को अस्वीकार किए जाने के लिएmatch=के साथpytest.raises- खाली शेल्फ़ पर
longest()काNoneलौटाना by_authorद्वारा ठीक उसी लेखक की किताबें लौटाना, और किसी के न होने पर खाली लिस्ट लौटानाtmp_pathका उपयोग करने वाला एक टेस्ट, जो शेल्फ़ को JSON में लिखे और वापस पढ़े
फिर पाँच प्रयोग करें:
- किसी टेस्ट से
assertहटा दें और केवल फ़ंक्शन को कॉल करें। क्या टेस्ट पास होता है? - फ़ाइल का नाम बदलकर
library_test.pyकरें औरpytestचलाएँ। क्या होता है? Bookकीpages < 1वाली जाँच हटा दें। कौन से टेस्ट विफल होते हैं, और क्या रिपोर्ट बताती है कि क्यों?total_pages()में जानबूझकर कोई ग़लती करें — उदाहरण के लिएsum(...) + 1। क्या विफलता रिपोर्ट वास्तविक और अपेक्षित दोनों संख्याएँ दिखाती है?assert 0.1 + 0.2 == 0.3का दावा करने वाला एक टेस्ट लिखें। फिर इसेpytest.approxके साथ ठीक करें।
तीसरा प्रयोग सबसे ज़्यादा सिखाता है। एक अच्छा टेस्ट सुइट, जब टूटता है, तो आपको बताता है कि कौन सा वादा टूटा था — और यही छह महीने बाद भी किसी प्रोग्राम को बदलने लायक बनाए रखता है।
Step 6 of 6
चुनौती — the chapter quiz
सरल से कठिन — दस प्रश्न, अंतिम वाले जानबूझकर चुनौतीपूर्ण बनाए गए हैं।
Sign in to take the quiz