अध्याय 00

टेस्ट लिखने से पहले — असल में क्या चाहिए

टेस्ट कोड से पहले की सोच: अस्पष्ट requirement को सटीक contract में बदलना, कोड को टेस्ट करने लायक बनाना, boundaries के साथ केस चुनना, कोड चलाए बिना अपेक्षित जवाब जानना, और तय करना कि क्या टेस्ट नहीं करना — सब सादे पायथन और assert से।

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

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

यह रहा एक फ़ंक्शन जो परीक्षा के अंकों की सूची का औसत निकालता है, और वह तरीका जिससे हम में से ज़्यादातर लोग ऐसे फ़ंक्शन को जाँचते हैं — उसे एक-दो बार चलाओ और देखो:

python
def average(scores):
    return sum(scores) / len(scores)


print(average([80, 90, 70]))
print(average([100]))
text
80.0
100.0

दोनों जवाब सही हैं। फ़ंक्शन "काम करता है", और प्रोग्राम में चला जाता है। एक हफ़्ते बाद एक ऐसी क्लास, जिसके अभी कोई नतीजे नहीं आए, उसी लाइन तक पहुँचती है:

python
def average(scores):
    return sum(scores) / len(scores)


print(average([]))
text
Traceback (most recent call last):
  File "/home/you/school/report.py", line 5, in <module>
    print(average([]))
          ^^^^^^^^^^^
  File "/home/you/school/report.py", line 2, in average
    return sum(scores) / len(scores)
           ~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero

"जब मैंने चलाकर देखा तब तो काम कर रहा था" — यह बात सच थी। बस इसका मतलब हमेशा इतना ही था कि "यह उन दो इनपुट के लिए काम करता है जो मैंने संयोग से आज़माए"। यह घटना मैन्युअल जाँच की दो कमज़ोरियाँ उजागर करती है।

पहली यह कि यह सिर्फ़ उतना ही कवर करती है जितना आपको उस पल सूझा, और खाली सूची ठीक वैसी चीज़ है जो उस पल किसी को नहीं सूझती।

दूसरी यह कि यह दोहराई नहीं जाती। दोनों print एक बार चलाए गए, एक बार पढ़े गए, और मिटा दिए गए। अगले महीने जब फ़ंक्शन बदलेगा, तब उन्हें दोबारा कोई नहीं चलाएगा।

ऑटोमेटेड टेस्ट दूसरी कमज़ोरी को ठीक करते हैं, और इस कोर्स का बाकी हिस्सा pytest के साथ उन्हें लिखने के बारे में है। लेकिन पहली कमज़ोरी को ध्यान से देखिए, क्योंकि कोई टूल उसे ठीक नहीं करता। मान लीजिए आप अभी खाली सूची के लिए एक जाँच लिखना चाहते हैं। वह क्या कहेगी? क्या average([]) को 0 लौटाना चाहिए? None लौटाना चाहिए? एरर raise करनी चाहिए? किसी ने तय ही नहीं किया। जिस व्यवहार को किसी ने तय नहीं किया, उसका टेस्ट आप नहीं लिख सकते, और जो जवाब आपको पहले से पता नहीं, उसे आप जाँच नहीं सकते।

यही इस अध्याय का विषय है: वह सोच जो टेस्ट कोड की पहली लाइन से पहले होनी चाहिए। इसे सही कर लीजिए तो टेस्ट लगभग अपने आप लिख जाते हैं। इसे छोड़ दीजिए तो आपके पास ऐसे टेस्ट होंगे जो पास होते हैं और फिर भी बग चूक जाते हैं।

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

  • एक वाक्य में बताना कि टेस्ट क्या है: व्यवहार के बारे में एक ऐसा दावा जिसे चलाया जा सके
  • एक धुँधली ज़रूरत (requirement) को एक सटीक contract में बदलना — इनपुट, आउटपुट, एरर, side effects
  • ऐसे कोड को पहचानना जिसे टेस्ट करना मुश्किल है, और उसे एक pure core और पतले shell में ढालना
  • equivalence classes और boundary values से केसों की योजना एक टेबल के रूप में बनाना
  • अपेक्षित जवाब कोड से स्वतंत्र रूप से निकालना — यानी oracle
  • तय करना कि क्या टेस्ट नहीं करना है, और कब टेस्ट काफ़ी हो गए
  • योजना को सादे assert जाँचों के रूप में लिखना, उन्हें चलाना, और बताना कि वे अभी भी काफ़ी क्यों नहीं हैं

ज़रूरी शर्तें: कोई नहीं — कोर्स यहीं से शुरू होता है। आपको Python 3 चाहिए और फ़ंक्शन लिखना आना चाहिए। अभी pytest की ज़रूरत नहीं है; इस अध्याय की हर चीज़ सादे पायथन से चलती है।


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

इस कोर्स के हर अध्याय में इसी नाम का एक सेक्शन है, और हर एक अपने विषय के लिए इन्हीं छह सवालों का जवाब देता है। ये सवाल इसी अध्याय से आते हैं, इसलिए यहाँ ये एक जगह दिए गए हैं। इन्हें सँभालकर रखिए; बाकी कोर्स इन्हें चेकलिस्ट की तरह इस्तेमाल करता है।

  1. ठीक-ठीक वादा क्या है? Contract: इन इनपुट के लिए यह आउटपुट; इन इनपुट के लिए यह एरर; और ये side effects, या कोई नहीं।
  2. क्या टेस्ट इस कोड को बुला सकता है? क्या यह अपने इनपुट arguments के रूप में लेता है और नतीजा लौटाता है — या कीबोर्ड पढ़ता है, प्रिंट करता है, और घड़ी देखता है?
  3. कौन-से केस? हर तरह के इनपुट से एक, हर किनारे (edge) के दोनों तरफ़, अमान्य इनपुट, खाली और बहुत बड़े।
  4. मुझे सही जवाब कैसे पता है? हाथ से या specification से निकाला गया — टेस्ट हो रहे कोड को चलाकर कभी नहीं।
  5. क्या-क्या तैयार होना चाहिए? पायथन वर्ज़न, एक environment, टेस्ट फ़ाइलें कहाँ रहेंगी — और आगे के अध्यायों में, वे फ़ाइलें, सेटिंग्स और टूल जिनकी उस विषय को ज़रूरत है।
  6. मैं क्या टेस्ट नहीं करूँगा? ख़ुद पायथन, दूसरों की लाइब्रेरी, इतना सरल कोड कि ग़लत हो ही न सके, और वह व्यवहार जिसका किसी ने वादा नहीं किया।

यह रहा ऊपर वाले average पर इनका इस्तेमाल, जो इतना छोटा है कि पूरा किया जा सके।

Contract में एक कमी है, इसलिए पहला कदम उसे भरना है। मान लीजिए आप पूछते हैं, और जवाब मिलता है: "बिना अंकों का औसत बेमानी है; ValueError raise करो"। फ़ंक्शन पहले से टेस्ट करने लायक है — यह एक सूची लेता है और एक संख्या लौटाता है। इस अध्याय के लिए बस Python 3 तैयार होना चाहिए। और केस:

| केस | इनपुट | अपेक्षित | क्यों | | --- | --- | --- | --- | | सामान्य | [80, 90, 70] | 80.0 | आम इस्तेमाल: (80 + 90 + 70) / 3 | | सिर्फ़ एक अंक | [100] | 100.0 | सबसे छोटी सूची जिसका औसत होता है | | खाली | [] | ValueError | वही केस जो टूटा था; अब तय हो चुका |

सूची में क्या नहीं है: sum सही जोड़ता है या नहीं, या / भाग देता है या नहीं — यह पायथन का काम है, और इसे उससे कहीं ज़्यादा अच्छी तरह टेस्ट किया गया है जितना आप या मैं कभी करेंगे।

अध्याय का बाकी हिस्सा इन छह सवालों को एक-एक करके लेता है, और अंत में पूरी प्रक्रिया को एक असली जैसे फ़ंक्शन पर चलाता है।

टेस्ट असल में क्या है

टेस्ट व्यवहार के बारे में एक ऐसा दावा है जिसे चलाया जा सके: यह इनपुट दिया, तो यह नतीजा अपेक्षित है। "नतीजा" ठीक तीन तरह का होता है:

  • एक return value — average([80, 90, 70]) देता है 80.0
  • एक एरर — average([]) raise करता है ValueError
  • एक side effect — add_score(scores, 70) के बाद सूची scores के अंत में 70 है

दावा लिखने के लिए पायथन में पहले से एक स्टेटमेंट है: assert। शर्त सच हो तो यह कुछ नहीं करता, और झूठ हो तो AssertionError raise करता है। यह रहा हर तरह का एक दावा, जहाँ average अब अपने तय किए गए contract का पालन करता है:

python
def average(scores):
    if not scores:
        raise ValueError("average() of an empty list")
    return sum(scores) / len(scores)


def add_score(scores, new_score):
    scores.append(new_score)


# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0

# 2. An error: given this input, expect this exception.
try:
    average([])
except ValueError:
    pass
else:
    raise AssertionError("average([]) should raise ValueError")

# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]

print("3 claims checked")
text
3 claims checked

ध्यान दीजिए कि हर दावे को क्या चाहिए: एक इनपुट जो आप चुनते हैं, एक नतीजा जो आपको पहले से पता है, और कोड को बुलाकर यह देखने का तरीका कि उसने क्या किया। छह सवाल इसीलिए हैं कि शुरू करने से पहले ये तीनों आपके पास हों। यह भी देखिए कि एरर वाली जाँच कितनी भद्दी है — "यह raise होना चाहिए" कहने के लिए try/except/else की पाँच लाइनें। अध्याय चार में pytest इसे एक लाइन बना देता है।

1. Contract: धुँधली ज़रूरत से सटीक वादे तक

ज़रूरतें अक्सर एक वाक्य के रूप में आती हैं: "सदस्यों को बड़े ऑर्डर पर 10% छूट मिलती है।" सुनने में पूरा लगता है। इसे दो सावधान डेवलपरों को दीजिए और देखिए:

python
# "Members get 10% off big orders." Two honest readings of the same sentence.

def discount_by_asha(total, is_member):
    if is_member and total > 1000:
        return total * 0.10
    return 0


def discount_by_ravi(total, is_member):
    if is_member and total >= 1000:
        return round(total * 0.10)
    return 0


for total in [500, 1000, 1234.56]:
    print(total, discount_by_asha(total, True), discount_by_ravi(total, True))
text
500 0 0
1000 0 100
1234.56 123.456 123

दोनों में से किसी ने ग़लती नहीं की। वाक्य ने यह नहीं बताया कि ठीक 1000 "बड़ा" गिना जाएगा या नहीं, या राउंड कैसे करना है। दोनों ने यह कमी अलग-अलग तरह से भरी, और 1000 के ऑर्डर को एक से 0 छूट मिलती है और दूसरे से 100। कोई टेस्ट नहीं बता सकता कि दोनों में कौन सही है, क्योंकि "सही" कभी परिभाषित ही नहीं किया गया।

Contract वह ज़रूरत है जिसे इतना सटीक बना दिया गया हो कि उसे टेस्ट किया जा सके। वाक्य से contract तक पहुँचने के लिए सवाल पूछिए — हर बार वही सवाल:

  • इनपुट। कौन-से टाइप? कौन-सी रेंज? कोई सीमा है, और क्या सीमा ख़ुद शामिल है? शून्य, ऋणात्मक, खाली, None का क्या?
  • आउटपुट। ठीक-ठीक क्या लौटता है — छूट, या नया कुल? कौन-सा टाइप? किस हद तक राउंड?
  • एरर। कौन-से इनपुट अस्वीकार होते हैं, और कैसे — कौन-सा exception?
  • Side effects। क्या यह कुछ बदलता है — पास की गई सूची, कोई फ़ाइल, डेटाबेस — या कुछ प्रिंट करता है? या कुछ भी नहीं?

छूट के बारे में पूछे जाने पर ये सवाल जवाब देते हैं, और जवाब वहीं जाते हैं जहाँ कोड है — docstring में:

python
def member_discount(total, is_member):
    """Return the discount on an order, in currency units.

    - total: the order total before discount, a number >= 0.
      A negative total raises ValueError.
    - is_member: True or False.
    - Members get 10% of the total when the total is 1000 or more
      (1000 itself qualifies). Everyone else gets 0.
    - The result is rounded to 2 decimal places.
    - Returns the discount amount, not the new total.
    - No side effects: reads nothing, prints nothing, changes nothing.
    """
    if total < 0:
        raise ValueError(f"total must not be negative, got {total}")
    if is_member and total >= 1000:
        return round(total * 0.10, 2)
    return 0


print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
    member_discount(-5, True)
except ValueError as error:
    print("ValueError:", error)
text
0
100.0
123.46
0
ValueError: total must not be negative, got -5

अब docstring की हर लाइन ऐसी चीज़ है जिसे टेस्ट जाँच सकता है, और हर जाँच का अपेक्षित मान साफ़ है। अच्छे contract की कसौटी यही है: क्या कोई अजनबी, कोड पढ़े बिना, किसी भी इनपुट का अपेक्षित मान लिख सकता है? अगर नहीं, तो अभी कोई सवाल पूछना बाकी है।

सवालों का जवाब कौन देता है? जिसकी वह ज़रूरत है — प्रोडक्ट ओनर, शिक्षक, क्लाइंट, या आप ख़ुद। जब पूछने के लिए कोई न हो, तो तय कीजिए, फ़ैसला contract में लिखिए, और आगे बढ़िए। लिखा हुआ फ़ैसला रिव्यू हो सकता है और बदला जा सकता है; चुपचाप लिया गया फ़ैसला बस कोड में पड़ा रहता है, जब तक किसी को चौंका न दे।

2. टेस्ट करने लायक कोड: pure core और पतला shell

टेस्ट किसी फ़ंक्शन को चुने हुए इनपुट से बुलाता है और जो वापस आता है उसकी तुलना करता है। कुछ फ़ंक्शन ऐसा होने ही नहीं देते। यह रहा एक, एक कैफ़े के happy hour के लिए — 17:00 से 19:00 के बीच ड्रिंक्स पर 20% छूट:

python
from datetime import datetime


def drink_price():
    price = float(input("Price: "))
    hour = datetime.now().hour
    if 17 <= hour < 19:
        price = price * 0.8
    print(f"You pay {price:.2f}")

चलाकर कीमत टाइप करें तो यह काम करता है। अब इसे दूसरी फ़ाइल, check_drinks.py, से जाँचने की कोशिश कीजिए:

python
from drinks import drink_price

result = drink_price()
print("returned:", result)

ऑटोमेटेड जाँच तब चलती है जब कीबोर्ड पर कोई नहीं होता। < /dev/null प्रोग्राम को ठीक यही देता है — एक ऐसा इनपुट जिसमें कुछ नहीं है:

text
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
  File "/home/you/cafe/check_drinks.py", line 3, in <module>
    result = drink_price()
             ^^^^^^^^^^^^^
  File "/home/you/cafe/drinks.py", line 5, in drink_price
    price = float(input("Price: "))
                  ^^^^^^^^^^^^^^^^
EOFError: EOF when reading a line

इसे कीमत दीजिए तो यह चल जाता है — लेकिन देखिए क्या वापस आता है:

text
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: None

तीन अलग समस्याएँ, और हर एक आम है:

  • यह अपना इनपुट ख़ुद पढ़ता है input() से, उसे argument के रूप में लेने के बजाय, इसलिए टेस्ट इनपुट चुन नहीं सकता।
  • यह अपना नतीजा प्रिंट करता है लौटाने के बजाय, इसलिए टेस्ट को None मिलता है और तुलना के लिए कुछ नहीं होता।
  • यह घड़ी ख़ुद पढ़ता है। वह रन सुबह सात बजने से ठीक पहले हुआ था, इसलिए जवाब 10.00 था; शाम साढ़े पाँच बजे वही कमांड 8.00 देती है। इस पर बनी जाँच इस पर निर्भर करती कि वह कब चली, और उसी हिसाब से पास या फ़ेल होती — एक flaky टेस्ट, जो न होने से भी बुरा है, क्योंकि लोग उसे अनदेखा करना सीख जाते हैं।

इलाज कोई टेस्टिंग की तरकीब नहीं है। यह आकार का बदलाव है: फ़ैसले को इनपुट और आउटपुट से अलग कीजिए। फ़ैसला एक pure फ़ंक्शन बन जाता है — उसे जो कुछ चाहिए वह arguments के रूप में आता है, जवाब return value के रूप में जाता है, और वह किसी और चीज़ को नहीं छूता। कीबोर्ड, स्क्रीन और घड़ी उसके चारों ओर एक पतले shell में चले जाते हैं:

python
from datetime import datetime


def drink_price(price, hour):
    """Return the price to pay for a drink.

    20% off from 17:00 up to, but not including, 19:00.
    """
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


def main():
    # The thin shell: all the input, output and clock reading lives here.
    price = float(input("Price: "))
    print(f"You pay {drink_price(price, datetime.now().hour):.2f}")


if __name__ == "__main__":
    main()

अब जाँचें दिन के किसी भी समय, जो घंटा चाहें चुन सकती हैं:

python
from drinks import drink_price

assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")
text
all checks passed

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

टेस्ट करने में मुश्किल कोड के चेतावनी संकेत, ताकि आप टेस्ट लिखने से पहले उन्हें पहचान सकें:

| संकेत | यह टेस्ट को क्यों चोट पहुँचाता है | आम इलाज | | --- | --- | --- | | लॉजिक के अंदर input() | टेस्ट इनपुट नहीं चुन सकता | इसे argument के रूप में लें | | इकलौता नतीजा print() | तुलना के लिए कुछ वापस नहीं आता | मान लौटाएँ; प्रिंट shell में करें | | अंदर datetime.now(), random | जवाब हर रन में बदलता है | समय या random मान बाहर से दें | | global variable पढ़ता या बदलता है | टेस्ट एक-दूसरे पर असर डालते हैं | उसे पास करें, नया मान लौटाएँ | | अंदर कोई तय फ़ाइल या URL खोलता है | टेस्ट को वह फ़ाइल या नेटवर्क चाहिए | डेटा, या path, बाहर से दें |

कोड को हमेशा नए आकार में नहीं ढाला जा सकता — कभी वह किसी और का होता है, या I/O ही उसका व्यवहार होता है। ऐसे मामलों के लिए pytest के पास टूल हैं: capsys प्रिंट हुआ आउटपुट पकड़ता है (अध्याय नौ), tmp_path एक अस्थायी फ़ोल्डर देता है (अध्याय नौ), और monkeypatch व mocks घड़ी, नेटवर्क और environment को बदल देते हैं (अध्याय दस और ग्यारह)। लेकिन पहले आकार बदलना आता है। जिस कोड को टेस्ट करना आसान है, उसे समझना भी आमतौर पर आसान होता है।

3. केस चुनना

आप हर इनपुट टेस्ट नहीं कर सकते; अकेला drink_price हर घंटे पर हर कीमत लेता है। काम है एक छोटा सेट चुनना, जिसमें हर केस कोई ऐसी ग़लती पकड़ सके जो बाकी चूक जाते। ज़्यादातर काम दो विचार करते हैं।

Equivalence classes। उन इनपुट को एक समूह में रखिए जिनके साथ contract एक जैसा बर्ताव करता है। समूह का कोई भी एक सदस्य बाकियों का प्रतिनिधि है: अगर drink_price(10, 12) सही है, तो drink_price(10, 11) भी लगभग पक्का सही है, क्योंकि दोनों को कोड की एक ही लाइन संभालती है। मान लीजिए contract को कसकर यह कहा गया है कि 0–23 के बाहर का घंटा, या ऋणात्मक कीमत, ValueError raise करती है। तब घंटे पाँच classes में बँटते हैं:

text
hour:   ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
        invalid   | full price    | 20% off | full price | invalid

Boundary values। ग़लतियाँ किसी class में बराबर नहीं फैलतीं। वे उसके किनारों पर जमा होती हैं, क्योंकि वहीं < और <= चुने जाते हैं, और वहीं "19:00 तक" को कोड में बदला जाता है। इसलिए हर किनारे के लिए उसके दोनों तरफ़ का मान टेस्ट कीजिए: एक class का आख़िरी और अगली का पहला। किसी सीमा L के लिए इसका मतलब है L - 1, L और L + 1 को देखना; गिनती और लंबाई के लिए 0 और 1 भी।

यह रहा कारण कि "हर class से एक सामान्य मान" काफ़ी क्यों नहीं है। नीचे happy-hour की शर्त एक आसान चूक के साथ लिखी गई है, 17 <= hour के बजाय 17 < hour:

python
def drink_price(price, hour):
    # A slip: 17 < hour instead of 17 <= hour.
    if 17 < hour < 19:
        return round(price * 0.8, 2)
    return price


# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")

# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")
text
typical cases passed
Traceback (most recent call last):
  File "/home/you/cafe/boundary.py", line 15, in <module>
    assert drink_price(10, 17) == 8.0
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

हर class से एक केस: सब पास, और जो ग्राहक ठीक पाँच बजे आते हैं वे पूरी कीमत चुकाते हैं। boundary वाला केस इसे तुरंत पकड़ लेता है।

Classes और boundaries इनपुट का आकार कवर करते हैं। उसके बाद इन सवालों से गुज़रिए, जिनमें से हर एक ने असली बग पकड़े हैं:

  • अमान्य इनपुट — जिसे contract अस्वीकार करने को कहता है: ऋणात्मक कीमत, घंटा 24।
  • खाली और शून्य — [], "", 0, 0 की कीमत। क्या "कुछ नहीं" ठीक बर्ताव करता है?
  • None — सिर्फ़ तब, जब contract उसका ज़िक्र करे। अगर नहीं करता, तो वह केस नहीं है (सवाल छह देखिए)।
  • बड़े मान — बहुत लंबी सूची, बहुत बड़ी संख्या। जिस चीज़ की भी सीमा हो, उसे सीमा पर टेस्ट कीजिए।
  • राउंडिंग — जहाँ हिसाब नतीजे से ज़्यादा दशमलव अंक पैदा करता है।

यह सब मिलाकर एक टेस्ट प्लान बनता है: हर केस के लिए एक पंक्ति, कोई भी टेस्ट कोड लिखने से पहले। आख़िरी कॉलम सबसे अहम है — जिस पंक्ति को आप सही ठहरा नहीं सकते, उसकी आपको ज़रूरत नहीं है।

| केस | इनपुट (price, hour) | अपेक्षित | क्यों | | --- | --- | --- | --- | | सबसे छोटा मान्य घंटा | (10, 0) | 10 | मान्य रेंज का किनारा | | पूरी कीमत का आख़िरी घंटा | (10, 16) | 10 | 17:00 वाले किनारे से नीचे | | छूट का पहला घंटा | (10, 17) | 8.0 | 17:00 वाले किनारे पर | | छूट का आख़िरी घंटा | (10, 18) | 8.0 | 19:00 वाले किनारे से नीचे | | फिर से पूरी कीमत का पहला घंटा | (10, 19) | 10 | 19:00 वाले किनारे पर — "शामिल नहीं" | | सबसे बड़ा मान्य घंटा | (10, 23) | 10 | मान्य रेंज का किनारा | | मुफ़्त ड्रिंक | (0, 17) | 0 | शून्य कीमत | | राउंडिंग | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992, 2 अंकों तक राउंड | | घंटा बहुत कम | (10, -1) | ValueError | रेंज के ठीक बाहर | | घंटा बहुत ज़्यादा | (10, 24) | ValueError | रेंज के ठीक बाहर | | ऋणात्मक कीमत | (-1, 12) | ValueError | अमान्य इनपुट |

और यह योजना, कसे हुए फ़ंक्शन के ख़िलाफ़ जाँचों में बदली हुई:

python
def drink_price(price, hour):
    """Return the price to pay for a drink.

    - price: a number >= 0. hour: a whole number 0-23.
    - 20% off from 17:00 up to, but not including, 19:00.
    - The result is rounded to 2 decimal places.
    - An hour outside 0-23 or a negative price raises ValueError.
    """
    if not 0 <= hour <= 23:
        raise ValueError(f"hour must be 0-23, got {hour}")
    if price < 0:
        raise ValueError(f"price must not be negative, got {price}")
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


def raises_value_error(price, hour):
    try:
        drink_price(price, hour)
    except ValueError:
        return True
    return False


assert drink_price(10, 0) == 10      # lowest valid hour
assert drink_price(10, 16) == 10     # last full-price hour before
assert drink_price(10, 17) == 8.0    # first discounted hour
assert drink_price(10, 18) == 8.0    # last discounted hour
assert drink_price(10, 19) == 10     # first full-price hour after
assert drink_price(10, 23) == 10     # highest valid hour
assert drink_price(0, 17) == 0       # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1)    # just below the valid range
assert raises_value_error(10, 24)    # just above the valid range
assert raises_value_error(-1, 12)    # negative price
print("11 checks passed")
text
11 checks passed

ग्यारह पंक्तियाँ, और हर एक किसी ऐसे कारण से है जिसे आप ज़ोर से बोलकर बता सकते हैं।

4. Oracle: कोड के बिना जवाब जानना

हर जाँच कोड के जवाब की तुलना एक अपेक्षित जवाब से करती है। अपेक्षित जवाब का स्रोत oracle कहलाता है, और उसका एक ही नियम है: वह टेस्ट हो रहा कोड नहीं होना चाहिए।

यह बात कहने लायक भी नहीं लगती, जब तक आप यह न देख लें कि इसे तोड़ना कितना आसान है। यहाँ member_discount में एक बग है — 10% के बजाय 1% — और दो जाँचें हैं:

python
def member_discount(total, is_member):
    if total < 0:
        raise ValueError(f"total must not be negative, got {total}")
    if is_member and total >= 1000:
        return round(total * 0.01, 2)   # bug: 1%, not 10%
    return 0


# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)

# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")
text
self-check passed, expected was 20.0
Traceback (most recent call last):
  File "/home/you/shop/oracle.py", line 15, in <module>
    assert member_discount(2000, True) == 200
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

पहली जाँच फ़ंक्शन की तुलना ख़ुद उसी से करती है। यह सही फ़ंक्शन के लिए पास होती है, इस टूटे हुए के लिए भी, और किसी के भी लिखे किसी भी दूसरे वर्ज़न के लिए भी — यह फ़ेल हो ही नहीं सकती, इसलिए यह आपको कुछ नहीं बताती।

इतने खुले रूप में इसे कोई नहीं लिखता। यही ग़लती आमतौर पर दो भेसों में से किसी एक में आती है:

  • आउटपुट कॉपी करना। आप फ़ंक्शन चलाते हैं, वह 20.0 प्रिंट करता है, और आप 20.0 जाँच में चिपका देते हैं। अगर कोड पहले से ग़लत था, तो अब जाँच बग की रखवाली कर रही है।
  • फ़ॉर्मूला दोहराना। आप अपेक्षित मान 2000 * 0.01 लिखते हैं — वही हिसाब जो कोड करता है। कोड के लॉजिक की नकल कोड की ग़लतियों में भी हिस्सेदार होती है। (नीचे "जब यह काम न करे" में इसे चलता हुआ दिखाया गया है।)

अच्छे oracle, मोटे तौर पर सबसे आम से सबसे कम आम तक:

  • Contract से हाथ से निकालना। ऐसे इनपुट चुनिए जो इसे आसान बनाएँ: 1873.41 के बजाय 2000, ताकि "इसका 10%" आप मन में निकाल सकें।
  • Specification के उदाहरण। अगर ज़रूरत कहती है "1500 के ऑर्डर पर 150 छूट", तो वह पंक्ति एक टेस्ट है।
  • जाने-माने तथ्य। पानी 100 °C और 212 °F पर उबलता है; खाली सूची की लंबाई 0 है।
  • कोई दूसरा, भरोसेमंद तरीका। एक सरल, धीमा हिसाब, या standard library का कोई फ़ंक्शन जो वही काम दूसरे तरीके से करता है:
python
import statistics


def average(scores):
    if not scores:
        raise ValueError("average() of an empty list")
    return sum(scores) / len(scores)


samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
    assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")
text
agrees with statistics.mean on 4 samples

statistics.mean दूसरे लोगों ने, दूसरे तरीके से लिखा है, इसलिए उससे सहमत होने का कुछ मतलब है। (दशमलव नतीजों की == से तुलना धोखा दे सकती है — पायथन में 0.1 + 0.2 == 0.3 का नतीजा False है। यहाँ के मान सुरक्षित हैं; floats की ठीक से तुलना करना अध्याय पाँच में है।)

अगर इनमें से किसी भी तरीके से आप अपेक्षित जवाब नहीं निकाल पाते, तो यह टेस्टिंग की समस्या नहीं है। इसका मतलब है कि आपको अभी पता ही नहीं कि कोड को क्या करना चाहिए — contract पर वापस जाइए।

5. क्या-क्या तैयार होना चाहिए

इस अध्याय के लिए सिर्फ़ Python 3। देखिए आपके पास कौन-सा है:

text
$ python3 --version
Python 3.12.3

यह कोर्स Python 3.12 इस्तेमाल करता है, और 3.10 या उससे ऊपर का कोई भी वर्ज़न इसका लगभग सब कुछ चला देगा। Windows पर कमांड आमतौर पर py --version होती है।

अगले अध्याय से, टेस्ट चलने से पहले तीन और चीज़ें तैयार होनी चाहिए, और अध्याय एक इनमें से हर एक को सेट करता है:

  • प्रोजेक्ट के लिए एक virtual environment, ताकि टेस्टिंग टूल उसी पायथन में इंस्टॉल हो जो आपका कोड चलाता है।
  • उसमें pytest इंस्टॉल हो।
  • टेस्ट के लिए एक जगह। टेस्ट फ़ाइलें कोड के बगल में, या tests/ फ़ोल्डर में रहती हैं, और उनके नाम test_ से शुरू होते हैं। अध्याय एक पहले तरीके से शुरू करता है; अध्याय तीन दूसरे पर जाता है और बताता है क्यों।

आगे के अध्याय अपनी ज़रूरतें जोड़ते हैं — एक कॉन्फ़िगरेशन फ़ाइल, एक plugin, डिस्क पर सैंपल डेटा — और हर एक उन्हें अपने "टेस्ट लिखने से पहले" में बताता है।

6. क्या टेस्ट न करें, और कब रुकें

जो टेस्ट आपके कोड की कोई ग़लती पकड़ ही नहीं सकता, वह लिखने में समय लेता है, चलने में समय लेता है, सँभालने में समय लेता है, और बदले में कुछ नहीं देता। इन्हें छोड़ दीजिए:

  • ख़ुद पायथन। यह नहीं कि sorted सॉर्ट करता है या round राउंड करता है। यह टेस्ट कीजिए कि आपका फ़ंक्शन सही चीज़ को सही क्रम में सॉर्ट करता है।
  • दूसरों की लाइब्रेरी। यह नहीं कि requests रिक्वेस्ट भेजता है या pandas CSV पढ़ता है। यह टेस्ट कीजिए कि आपका कोड नतीजे के साथ क्या करता है — और अगर किसी लाइब्रेरी के व्यवहार के बारे में पक्का होना हो, तो आपकी धारणा को दर्ज करने वाली एक छोटी जाँच काफ़ी है।
  • इतना सरल कोड कि ग़लत हो ही न सके। एक फ़ंक्शन जो कोई constant लौटाता है, या एक class जो सिर्फ़ अपने arguments सहेजती है। अगर आप ग़लती की कल्पना ही नहीं कर सकते, तो टेस्ट उसे पकड़ नहीं सकता।
  • वह व्यवहार जिसका किसी ने वादा नहीं किया। अगर contract drink_price को string पास करने के बारे में कुछ नहीं कहता, तो टेस्ट बस उसी को जमा देगा जो वह आज संयोग से करता है। या तो इसे contract में जोड़िए — फिर टेस्ट कीजिए — या छोड़ दीजिए।
  • "क्या" के बजाय "कैसे"। यह नहीं कि फ़ंक्शन ने अंदर कौन-सा helper बुलाया, या किस क्रम में अपने कदम उठाए। यह टेस्ट कीजिए कि क्या अंदर जाता है और क्या बाहर आता है; तब अंदर का हिस्सा बेझिझक दोबारा लिखा जा सकता है, और टेस्ट साबित करते हैं कि वह अब भी काम करता है।

और कितने काफ़ी हैं? कोई तय संख्या नहीं है, लेकिन एक नियम है। आपके पास काफ़ी टेस्ट तब हैं जब:

  • हर equivalence class का कम से कम एक केस है,
  • हर boundary के दोनों तरफ़ एक-एक केस है,
  • contract जिस हर एरर का वादा करता है, उसका एक केस है,
  • और आपने जो भी बग कभी ठीक किया है, उसका अपना एक केस है, ताकि वह चुपचाप लौट न आए।

फिर रुक जाइए। हर नए केस के लिए, जिसे जोड़ने का मन हो, पूछिए: यह कौन-सी ऐसी ग़लती पकड़ेगा जो बाकी नहीं पकड़ते? अगर आप कोई नाम नहीं बता सकते, तो यह दोहराव है। drink_price(10, 12) के बगल में drink_price(10, 11) कुछ नया नहीं पकड़ता।


पूर्ण उदाहरण

अब पूरी प्रक्रिया, शुरू से अंत तक, एक असली जैसे फ़ंक्शन पर।

ज़रूरत, जैसी आई थी: "लोकल पार्सल की शिपिंग 60 है और नेशनल की 120। भारी पार्सल ज़्यादा महँगे हैं — लोकल में हर अतिरिक्त किलो के 20, नेशनल में 30। 30 kg से ऊपर का कुछ भी हम नहीं लेते।"

सवाल, और उन्हें मिले जवाब:

| सवाल | जवाब | | --- | --- | | बेस कीमत किसे कवर करती है? | पहले किलोग्राम को — ठीक 1 kg अब भी बेस कीमत है। | | 1.2 kg का चार्ज 1 kg जितना है या 2 जितना? | हर शुरू हुआ किलोग्राम गिना जाता है: 1.2 kg का चार्ज 2 kg जितना है। | | क्या ठीक 30 kg स्वीकार है? | हाँ। 30 की अनुमति है; उससे ऊपर कुछ भी अस्वीकार। | | 0 kg, या ऋणात्मक वज़न का क्या? | अस्वीकार — यह डेटा एंट्री की ग़लती है। | | ज़ोन कैसे लिखे जाते हैं? | ठीक "local" या "national"। बाकी सब अस्वीकार, "Local" भी। | | क्या वापस आता है? | लागत, एक पूर्ण संख्या के रूप में। कुछ प्रिंट या सेव नहीं होता। |

Contract और टेस्ट करने लायक फ़ंक्शन, shipping.py। यह सब कुछ arguments के रूप में लेता है और एक संख्या लौटाता है; अलग करने के लिए कोई shell नहीं है, क्योंकि इसमें कोई इनपुट या आउटपुट है ही नहीं:

python
import math

BASE = {"local": 60, "national": 120}       # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30


def shipping_cost(weight_kg, zone):
    """Return the shipping cost of one parcel, as a whole number.

    - weight_kg: a number. At or below 0, or above 30: ValueError.
    - zone: "local" or "national", exactly. Anything else: ValueError.
    - The base price covers the first kilogram (1 kg included).
    - Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
    - No side effects: reads nothing, prints nothing, changes nothing.
    """
    if zone not in BASE:
        raise ValueError(f"unknown zone: {zone!r}")
    if not 0 < weight_kg <= MAX_WEIGHT_KG:
        raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
    extra_kg = math.ceil(weight_kg) - 1
    return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]

math.ceil अगली पूर्ण संख्या तक ऊपर राउंड करता है — math.ceil(1.2) है 2 — जो ठीक-ठीक "हर शुरू हुआ किलोग्राम गिना जाता है" है।

केस टेबल। वज़न की classes हैं: अमान्य (0 और नीचे), बेस किलोग्राम (0 से ऊपर, 1 तक), अतिरिक्त किलोग्राम (1 से ऊपर, 30 तक), अमान्य (30 से ऊपर)। ज़ोन हैं "local", "national", और बाकी सब कुछ। हर अपेक्षित मान contract से हाथ से निकाला गया है, कोड से नहीं:

| केस | इनपुट | अपेक्षित | क्यों | | --- | --- | --- | --- | | हल्का पार्सल | (0.5, "local") | 60 | बेस किलोग्राम के अंदर | | ठीक 1 kg | (1, "local") | 60 | बेस का ऊपरी किनारा: 1 kg शामिल है | | 1 kg से थोड़ा ऊपर | (1.2, "local") | 80 | शुरू हुआ किलोग्राम गिना जाता है: 60 + 1 × 20 | | पूरे किलो, लोकल | (3, "local") | 100 | 60 + 2 × 20 | | पूरे किलो, नेशनल | (3, "national") | 180 | 120 + 2 × 30 — दूसरा ज़ोन | | सीमा पर | (30, "national") | 990 | 120 + 29 × 30; 30 स्वीकार है | | शून्य | (0, "local") | ValueError | निचला किनारा: 0 अस्वीकार है | | ऋणात्मक | (-2, "local") | ValueError | अमान्य इनपुट | | सीमा से ऊपर | (30.5, "local") | ValueError | 30 से ठीक ऊपर | | ग़लत ज़ोन | (2, "Local") | ValueError | ठीक "local" नहीं |

टेबल में जान-बूझकर क्या नहीं है: None या "2" का वज़न। Contract कहता है कि वज़न एक संख्या है, इसलिए ये उसके बाहर हैं — 0 से तुलना होने पर पायथन अपनी तरफ़ से TypeError raise करता है, और इससे आगे हम कोई वादा नहीं करते। math.ceil भी नहीं: वह पायथन का है, हमारा नहीं। और (3, "local") के बगल में (2, "local") भी नहीं: वही class, कोड की वही लाइन, पकड़ने को कुछ नया नहीं।

जाँचें, check_shipping.py — टेबल, पंक्ति-दर-पंक्ति:

python
from shipping import shipping_cost


def raises_value_error(weight_kg, zone):
    """True if shipping_cost raises ValueError for these arguments."""
    try:
        shipping_cost(weight_kg, zone)
    except ValueError:
        return True
    return False


# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990

# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")

print("all 10 checks passed")
text
$ python check_shipping.py
all 10 checks passed

अब इसे तोड़िए। कुछ महीने बाद कोई shipping.py को साफ़-सुथरा करता है, import math हटा देता है और math.ceil(weight_kg) की जगह int(weight_kg) लिख देता है। पढ़ने में ठीक लगता है, और बिना एरर के चलता है। जाँचें फिर से चलाइए:

text
$ python check_shipping.py
Traceback (most recent call last):
  File "/home/you/shop/check_shipping.py", line 14, in <module>
    assert shipping_cost(0.5, "local") == 60
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

योजना ने अपना काम किया: जाँचें चलते ही बदलाव पकड़ा गया। int ऊपर राउंड करने के बजाय दशमलव काट देता है, इसलिए 0.5 kg बन गया int(0.5) - 1, यानी -1 अतिरिक्त किलोग्राम।

लेकिन रिपोर्ट को ऐसे पढ़िए जैसे आपको यह पहले से पता न हो, और देखिए कि यह कितना कम बताती है:

  • असली मान क्या था? रिपोर्ट लाइन दिखाती है, संख्या नहीं। यह जानने के लिए कि वह 40 था, आपको shipping_cost(0.5, "local") ख़ुद चलाना पड़ेगा।
  • और क्या टूटा है? स्क्रिप्ट पहली विफलता पर रुक गई। 1.2 kg वाला केस भी ग़लत है — अब वह 80 के बजाय 60 देता है — लेकिन वह जाँच कभी चली ही नहीं।
  • कौन-से वादे निभे? कुछ नहीं बताता कि एरर वाले केस अब भी पास होते हैं।
  • और एरर वाली जाँचों को सिर्फ़ "यह raise होना चाहिए" कहने के लिए try/except वाला helper फ़ंक्शन चाहिए था।

सोचना ही कठिन हिस्सा था, और वह हो चुका है: contract, टेबल, oracle। कमी है एक बेहतर runner की — जो जाँचें ख़ुद ढूँढे, विफलता के बाद भी हर एक चलाए, असली मान अपेक्षित मान के बगल में दिखाए, और एरर एक ही लाइन में जाँचे। वह pytest है, और उसे इंस्टॉल करना अगला अध्याय है।


जब यह काम न करे

ये पायथन की एरर के बजाय योजना की ग़लतियाँ हैं, इसलिए इनके लक्षण शांत होते हैं: आमतौर पर एक जाँच जो तब पास होती है जब उसे नहीं होना चाहिए।

अपेक्षित मान कोड का ही फ़ॉर्मूला दोहराता है जाँच वही हिसाब करके लिखी गई है जो फ़ंक्शन करता है। यहाँ फ़ंक्शन में ऊपर वाला int बग है, और जाँच में भी:

python
def shipping_cost(weight_kg, zone):
    # Local zone only, to keep the example short. Bug: int() instead of math.ceil().
    return 60 + (int(weight_kg) - 1) * 20


# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")

# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80
text
passed
Traceback (most recent call last):
  File "/home/you/shop/rederive.py", line 11, in <module>
    assert shipping_cost(1.2, "local") == 80
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError

"implementation को टेस्ट करना" भी ऐसा ही दिखता है: जाँच यह बताने के बजाय कि कोड क्या वादा करता है, यह दोहराती है कि वह कैसे काम करता है। अपेक्षित मान contract से निकली एक सादी संख्या के रूप में लिखिए।

अपेक्षित मान आउटपुट से कॉपी किया गया आपने फ़ंक्शन चलाया, 20.0 देखा, और उसे जाँच में चिपका दिया। जाँच पास होती है — और अगर 20.0 ग़लत था, तो अब वह बग की रक्षा करती है। कोड चलाने से पहले हर अपेक्षित मान ख़ुद निकालिए; अगर आपकी संख्या और कोड की संख्या मेल न खाएँ, तो कुछ भी लिखने से पहले पता कीजिए कि कौन-सी ग़लत है।

योजना में कोई boundary केस नहीं है हर जाँच पास होती है, और बग किनारे पर बैठा है: पाँच बजे, ठीक 1 kg, उम्र 12। आमतौर पर इसका कारण ऐसी टेबल होती है जिसमें सिर्फ़ "सामान्य" पंक्तियाँ हों। Contract में हर <, <=, "तक", "से" और "कम से कम" के लिए, दोनों तरफ़ एक-एक केस होना चाहिए।

फ़ंक्शन को कोई जाँच बुला नहीं सकती EOFError: EOF when reading a line का मतलब है कि फ़ंक्शन कीबोर्ड का इंतज़ार करता है; returned: None का मतलब है कि उसने अपना जवाब लौटाने के बजाय प्रिंट कर दिया; सुबह पास और शाम को फ़ेल होने वाली जाँच का मतलब है कि वह घड़ी पढ़ता है। तीनों डिज़ाइन की समस्याएँ हैं, और सेक्शन दो वाले pure-core-और-पतले-shell के बदलाव से ठीक होती हैं।

SyntaxWarning: assertion is always true, perhaps remove parentheses? assert और उसके मैसेज के चारों ओर कोष्ठक लगाने से दोनों मिलकर एक tuple बन जाते हैं, और ख़ाली न हो ऐसा tuple हमेशा सच होता है:

python
def drink_price(price, hour):
    if 17 <= hour < 19:
        return round(price * 0.8, 2)
    return price


assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")
text
/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
  assert (drink_price(10, 17) == 999, "happy hour price")
passed?!

999 साफ़ तौर पर ग़लत है, और जाँच पास हो गई। assert drink_price(10, 17) == 999, "happy hour price" लिखिए — बिना कोष्ठक के — और यह वैसे ही फ़ेल होगी जैसे इसे होना चाहिए। पायथन यहाँ आपको चेतावनी देता है; उसे स्क्रॉल करके आगे बढ़ने के बजाय पढ़िए।

दशमलव नतीजों की == से तुलना करने वाली जाँच सही जवाब पर भी फ़ेल होती है पायथन में 0.1 + 0.2 == 0.3 का नतीजा False है, क्योंकि 0.1 + 0.2 असल में 0.30000000000000004 है। योजना का सबक यह है कि contract में बताइए कि नतीजे कैसे राउंड होते हैं, जैसा member_discount और drink_price करते हैं। floats की तुलना के लिए pytest का टूल अध्याय पाँच में है।