अध्याय 28

pytest के साथ टेस्टिंग

assert से लेकर pytest तक, विफलता की रिपोर्ट पढ़ना, pytest.raises, parametrize, tmp_path और fixtures — और किस तरह का कोड टेस्ट करना आसान होता है।

37 मिनटPython 3.12
  1. 1समस्या
  2. 2समझें
  3. 3उदाहरण
  4. 4अनुमान
  5. 5स्वयं करें
  6. 6चुनौती

वह समस्या जिसे हम हल कर रहे हैं

अब तक हम अपने कोड को केवल एक ही तरीके से "टेस्ट" कर रहे थे: उसे चलाओ, आउटपुट पढ़ो, और तय करो कि वह सही दिख रहा है या नहीं।

यह तरीका एक बार काम करता है। परेशानी दूसरी बार से शुरू होती है।

पच्चीसवें अध्याय के प्रोग्राम में TAX_RATE का मान 0.15 था। मान लीजिए यह बढ़कर 0.18 हो जाता है। प्रोग्राम चलता है, हर संख्या प्रिंट होती है, कोई एरर नहीं आता — और आपके पास यह जानने का कोई तरीका नहीं है कि कुछ बदला या नहीं, जब तक कि आपको पिछला आउटपुट ठीक से याद न हो।

और जिसे याद रखना पड़े, वह अंततः भूल ही जाता है।

python
def line_total(price, quantity):
    return round(price * quantity * 1.15, 2)


assert line_total(15.0, 1) == 17.25
print("ok")
text
ok

assert एक सीधा-सादा वाक्य है: "यह सच होना चाहिए"। जब यह सच होता है, तो कुछ नहीं होता; जब यह सच नहीं होता, तो प्रोग्राम वहीं रुक जाता है।

python
def line_total(price, quantity):
    return round(price * quantity * 1.15, 2)


assert line_total(15.0, 3) == 51.0
print("ok")
text
AssertionError

यही नींव है। यह अध्याय उसी पर बना एक टूल है — pytest — जो ऐसे सैकड़ों दावों को चलाता है और जब कोई विफल होता है, तो बताता है कि क्यों।

इस अध्याय के अंत में आप कर पाएंगे

  • एक टेस्ट फ़ाइल लिखना और pytest चलाना
  • विफलता की रिपोर्ट पढ़ना और उससे समस्या को खोजना
  • pytest.raises के साथ एरर्स का परीक्षण करना
  • @pytest.mark.parametrize के साथ कई मामलों में एक ही टेस्ट चलाना
  • tmp_path के साथ फ़ाइल-हैंडलिंग कोड का परीक्षण करना
  • यह बताना कि किस तरह के कोड का परीक्षण करना आसान होता है, और क्यों

ज़रूरी शर्तें: डेटाक्लासेस और टाइप हिंट्स (Dataclasses and type hints)।


पहला टेस्ट

केवल दो नियम हैं, और बस इतना ही: फ़ाइल का नाम test_ से शुरू होता है, और फ़ंक्शन का नाम भी।

pricing.py:

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

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

text
..                                                                       [100%]
2 passed in 0.01s

दो डॉट्स (बिंदु), दो टेस्ट्स। pytest फ़ाइलों को खोजता है, फ़ंक्शंस को ढूँढता है और उन्हें चलाता है — कुछ भी अलग से रजिस्टर करने की आवश्यकता नहीं होती।

विफलता की रिपोर्ट ही असली बात है

एक टेस्ट में जानबूझकर एक ग़लत संख्या डालें:

text
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 करना चाहिए। वह व्यवहार भी एक वादा है, और इसलिए उसका परीक्षण किया जा सकता है:

python
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)
text
..                                                                       [100%]
2 passed in 0.01s

pytest.raises ब्लॉक कहता है "यहाँ यह एरर आना चाहिए"। जब यह नहीं आता, तो टेस्ट विफल हो जाता है:

text
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.01s

match= वाला भाग यह जाँचता है कि एरर मैसेज में वह टेक्स्ट शामिल है या नहीं। इसे लिखना फ़ायदेमंद है, क्योंकि चौबीसवें अध्याय का नियम — मैसेज में समस्या पैदा करने वाली वैल्यू का नाम लिखें — फिर अपने आप में एक परखा हुआ वादा बन जाता है।

एक टेस्ट, कई केसेस

चार मामलों के लिए चार अलग फ़ंक्शंस लिखना उबाऊ है, और इससे कॉपी-पेस्ट की ग़लतियाँ होने की संभावना बढ़ जाती है।

python
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) == expected

pytest -v के साथ चलाएँ:

text
============================= 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%]

एक ही फ़ंक्शन से चार अलग-अलग टेस्ट्स बने। और हर नाम अपनी वैल्यूज़ साथ रखता है, इसलिए विफलता तुरंत अपनी पहचान कराती है — यदि इसे लूप के रूप में लिखा गया होता, तो पहली विफलता बाकी को रोक देती और यह नहीं बताती कि किन मानों में गड़बड़ी थी।

जब कोई गलत होता है, तो रिपोर्ट उन मानों को भी दिखाती है:

text
________________________ test_line_total[15.0-3-51.0] _________________________

price = 15.0, quantity = 3, expected = 51.0

जब किसी फ़ाइल की आवश्यकता हो — tmp_path

तेईसवें अध्याय के कोड का परीक्षण करने के लिए एक फ़ाइल की आवश्यकता होती है। रिपॉजिटरी में फ़ाइल रखना एक बुरी आदत है: टेस्ट आपस में उलझ जाते हैं, और एक टेस्ट द्वारा फ़ाइल में किया गया बदलाव दूसरे को तोड़ देता है।

pytest हर टेस्ट को उसका अपना एक खाली फ़ोल्डर दे सकता है:

python
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) == []
text
..                                                                       [100%]
2 passed in 0.01s

tmp_path एक pathlib.Path ऑब्जेक्ट है — वही तेईसवें अध्याय वाला Path, जो उसी तरह / से जुड़ता है। पैरामीटर का नाम ही pytest को बताता है कि क्या सप्लाई करना है, और प्रत्येक टेस्ट को एक नया फ़ोल्डर मिलता है, इसलिए दो टेस्ट बिना किसी टकराव के एक ही फ़ाइल नाम का उपयोग कर सकते हैं।

बार-बार सेटअप — fixture

जब कई टेस्ट्स को एक ही इनपुट की आवश्यकता होती है, तो उसे एक बार लिखा और नाम दिया जा सकता है:

python
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
text
..                                                                       [100%]
2 passed in 0.01s

tmp_path की तरह ही, pytest पैरामीटर के नाम से मेल खाता है। और फ़ंक्शन प्रत्येक टेस्ट के लिए दोबारा चलता है, इसलिए यदि कोई टेस्ट लिस्ट में बदलाव भी करता है, तब भी अगले टेस्ट को एक ताज़ा और नई लिस्ट मिलती है — इक्कीसवें अध्याय की शेयर्ड-स्टेट (shared-state) की समस्या से बचने के बजाय उसे डिज़ाइन के ज़रिए ही हल कर दिया गया।

फ़्लोटिंग-पॉइंट का जाल

python
def test_addition():
    assert 0.1 + 0.2 == 0.3
text
F                                                                        [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

पाँचवें अध्याय का पुराना मामला, और टेस्ट ही आमतौर पर वह जगह होते हैं जहाँ यह सबसे पहले असर दिखाता है।

python
import pytest


def test_addition():
    assert 0.1 + 0.2 == pytest.approx(0.3)
text
.                                                                        [100%]
1 passed in 0.01s

pytest.approx कहता है "पर्याप्त रूप से करीब होना चलेगा"। फ़्लोट्स से जुड़े किसी भी टेस्ट में इसका उपयोग करें — जब तक कि संख्या को round() से पहले ही सीमित न कर दिया गया हो, जैसा कि line_total करता है।

क्या टेस्ट करें

एक नियम बाकी सभी से बढ़कर है: वह फ़ंक्शन जो वैल्यूज़ लेता है और एक वैल्यू लौटाता है, उसका परीक्षण करना सबसे आसान होता है।

इक्कीसवें अध्याय के शुद्ध (pure) फ़ंक्शंस याद हैं? यह अध्याय उनका फल है। line_total का परीक्षण करने के लिए किसी फ़ाइल, किसी इनपुट, किसी सेटअप की आवश्यकता नहीं थी — केवल इसे कॉल करना और परिणाम देखना था।

इसके विपरीत, एक फ़ंक्शन जो प्रिंट करता है, इनपुट मांगता है या फ़ाइल लिखता है, उसका परीक्षण करने से पहले तैयारियाँ करनी पड़ती हैं। यही कारण है कि तेईसवें अध्याय के report ने कोई फ़ाइल नहीं ली: इसने एक लिस्ट ली और एक लिस्ट लौटाई।

टेस्ट लिखने में कठिनाई आमतौर पर टेस्ट की ग़लती नहीं बल्कि कोड की संरचना की वजह से होती है।

क्या टेस्ट करें, इसे तीन श्रेणियों में समझें: सामान्य रूप से क्या होता है, किनारे (edges) (शून्य, खाली, एक), और जिसका विफल होना तय है।


पूर्ण उदाहरण

pricing.py:

python
"""क़ीमतें और टैक्स। शुद्ध फ़ंक्शंस, जो उन्हें टेस्ट करने योग्य बनाते हैं।"""

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:

python
"""क़ीमत निर्धारण के लिए टेस्ट। प्रत्येक टेस्ट उस व्यवहार का नाम बताता है जिसकी वह सुरक्षा करता है।"""

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)])
text
...........                                                              [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 का उपयोग करें।