टेस्ट ड्रिवन डेवलपमेंट

टेस्ट ड्रिवन डेवलपमेंट का एक अवलोकन।


टेस्ट-ड्रिवन डेवलपमेंट (TDD) प्रोग्रामिंग की एक ऐसी शैली है जिसमें प्रोग्राम के डिज़ाइन को कोड में लागू करने का रास्ता दिखाने के लिए टेस्ट लिखे जाते हैं।

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

रिफैक्टरिंग

रिफैक्टरिंग का मतलब है कोड को फिर से लिखकर उसका डिज़ाइन बेहतर बनाना। यह सिर्फ बग ठीक करने के लिए कोड दोबारा लिखना नहीं है। टेस्ट पास कराने के लिए कोड बदलने को कभी-कभी "रिफैक्टरिंग" कह दिया जाता है। हालाँकि टेस्ट पास कराने के तरीके के तौर पर कोड बदलने में डिज़ाइन सुधारना शायद शामिल हो सकता है, लेकिन सिर्फ गलतियाँ ठीक करना ज़रूरी नहीं कि कोड के डिज़ाइन को बेहतर बनाए, और इसलिए ज़रूरी नहीं कि वह रिफैक्टरिंग हो।

नीचे बिना रिफैक्टरिंग किए सिर्फ गलतियाँ ठीक करने का उदाहरण दिया गया है:

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

नीचे पहले रिफैक्टरिंग और फिर गलतियाँ ठीक करने का उदाहरण दिया गया है:


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

Exercism पर TDD और Python

Exercism का Python ट्रैक अपने अभ्यासों में TDD का तरीका अपनाता है। यूनिट टेस्ट पहले से लिखे होते हैं। किसी हल के पास होने के लिए क्या चाहिए, यह बारीकी से समझने के लिए छात्र ये टेस्ट देख सकते हैं। छात्र को हल का एक स्टब भी दिया जा सकता है।

वेब एडिटर में Exercism पर फेल हुए टेस्ट को ठीक करना

जब Python के किसी हल के लिए एक या अधिक टेस्ट फेल होते हैं, तो उससे जुड़े टास्क की पृष्ठभूमि हरी नहीं रहेगी। सबसे पहले फेल हुए टास्क का हिस्सा खुला हुआ दिखेगा और उसका हेडर कुछ ऐसा दिखेगा

Task 1 Extract coordinates -

माइनस चिह्न पर क्लिक करने से टास्क बंद हो जाएगा, जिससे हम दूसरे टास्क देख सकते हैं। लेकिन फिलहाल हम इसी टास्क पर रुकते हैं।

उसके नीचे एक खुला हुआ टेस्ट एरिया दिखेगा, जो कुछ ऐसा दिखेगा

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

यहाँ Tisbury Treasure अभ्यास को दर्शाता है और get_coordinate उस फंक्शन या मेथड को, जो फेल हुआ।

Test 1 आम तौर पर एक तरह का टेम्पलेट होता है, जिसमें टेस्ट तैयार करने के लिए एक कोड सेक्शन होता है। इसमें यह जानकारी नहीं होती कि कौन-से खास टेस्ट फेल हुए। इसके नीचे की ओर लिखा होगा कि

One or more variations of this test failed. Details can be found under each [variant#].

⌄ पर क्लिक करने से टेस्ट बंद हो जाएगा।

उसके नीचे एक बंद टेस्ट दिखेगा, जो कुछ ऐसा दिखता है:

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

यह कैसा दिखेगा, यह दाईं ओर के पैनल की चौड़ाई पर निर्भर करता है। > पर क्लिक करने से टेस्ट खुल जाएगा। इनपुट डेटा और अपेक्षित परिणाम का डेटा शायद एक कोड सेक्शन में दिखाया जाएगा। यह डेटा इस टास्क के सभी टेस्ट के लिए हो सकता है। सबसे नीचे, Test Failure सेक्शन में, यह खास वजह लिखी होती है कि यह टेस्ट क्यों फेल हुआ। यह कुछ ऐसा दिख सकता है:

AssertionError: ['2A'] != '2A'

इस खास मामले में यह बताता है कि ['2A'] की लौटाई गई वैल्यू अपेक्षित वैल्यू '2A' के बराबर नहीं थी।

अगर हम get_coordinate का कोड देखें, तो पता चलता है कि यह इस तरह लिखा गया है

def get_coordinate(record):
    return [record[1]]

अगर हम ऐरे के ब्रैकेट हटा दें (जैसे return record[1]) और टेस्ट फिर से चलाएँ, तो Task 1 के टेस्ट पास हो जाएँगे।

अगर एक या अधिक टास्क अब भी फेल हो रहे हैं, तो ऊपर दी गई प्रक्रिया हर टास्क के साथ दोहराई जाती है, जब तक सभी टेस्ट पास न हो जाएँ।

कभी-कभी अपेक्षित डेटा और लौटाया गया डेटा इतना बड़ा होता है कि वह पूरा Test Failure सेक्शन में नहीं समाता। यह कुछ ऐसा दिख सकता है:

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

फिर भी उतना डेटा हो सकता है कि समस्या समझ आ जाए। ऊपर के मामले में, दो लाइन फीड लौटाए गए हैं (जैसे \n\n(\'Brass Spyglass) जबकि अपेक्षित सिर्फ एक है (जैसे \n(\'Brass Spyglass)।

सभी टेस्ट पास होने के बाद

बधाई हो! सभी टेस्ट पास हो गए हैं। अब आगे क्या? हल को तुरंत पब्लिश किया जा सकता है। या अब जब कोड काम कर रहा है, तो अगर आप किसी भी वजह से उसकी रिफैक्टरिंग करना चाहें, तो आप कोड बदलकर हल फिर से जमा कर सकते हैं। अगर आपको लगता है कि कोड बेहतर हो सकता है, लेकिन आपको पता नहीं कि कैसे, तो आप हल के लिए मेंटरिंग का अनुरोध कर सकते हैं। अगर कोई मेंटर उपलब्ध हो, तो वे हल के दूसरे तरीकों पर सुझाव देने के लिए आपसे संपर्क कर सकते हैं। हल पब्लिश करते समय आप टिप्पणियों की अनुमति दे सकते हैं, और दूसरे छात्र मौका लेकर टिप्पणियाँ लिख सकते हैं या सवाल पूछ सकते हैं।

परफॉर्मेंस के लिए ऑप्टिमाइज़ेशन

"समय से पहले किया गया ऑप्टिमाइज़ेशन सारी बुराइयों की जड़ है" (यह कहावत Tony Hoare और Donald Knuth दोनों से जोड़ी जाती है), लेकिन एक समय ऐसा भी आता है जब हल काम करता हो, फिर भी हम उसकी परफॉर्मेंस बेहतर करना चाहते हैं। ऐसा तब हो सकता है जब हल कुछ टेस्ट पास करता है, लेकिन कुछ में तय समय के अंदर पूरा नहीं हो पाता। यह जानना काम का हो सकता है कि कोड का कोई हिस्सा ठीक कितना समय ले रहा है। timeit मॉड्यूल की मदद से कोड चलने में लगने वाला समय बहुत छोटी अवधियों तक नापा जा सकता है। timeit फंक्शन अधिकतम पाँच आर्गुमेंट ले सकता है: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None)। stmt पैरामीटर उस असली कोड को तय करता है जिसे चलाकर समय नापा जाना है। number पैरामीटर तय करता है कि stmt वाला कोड कितनी बार चलेगा। setup पैरामीटर उस कोड को तय करता है जो stmt कोड चलाने की तैयारी के लिए सिर्फ एक बार चलता है। setup कोड को चलने में लगा समय कुल समय में शामिल होता है। stmt वाला कोड जितनी अधिक बार चलाया जाएगा, हर बार के हिसाब से setup का समय उतना ही कम गिना जाएगा। timer पैरामीटर की मदद से डिफ़ॉल्ट के बजाय कोई दूसरा Timer दिया जा सकता है। timer पैरामीटर का डिफ़ॉल्ट आर्गुमेंट perf_counter है, जो ज़्यादातर मामलों के लिए काफी होना चाहिए। number पैरामीटर का डिफ़ॉल्ट आर्गुमेंट 1_000_000 है। globals पैरामीटर वह नेमस्पेस तय करता है जिसमें कोड चलाया जाएगा। globals पैरामीटर का डिफ़ॉल्ट आर्गुमेंट None है।

नीचे timeit का उपयोग करके यह देखने का उदाहरण है कि किसी वाक्य में अंग्रेज़ी के सभी स्वर हैं या नहीं, यह तय करने में कितना समय लगता है:


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

कोड को दस लाख बार चलाने में हर कॉल पर औसतन 4.965089999896008e-07 सेकंड लगा (हर कॉल पर लगभग 497 नैनोसेकंड)।

नीचे दिया गया उदाहरण यह देखने के लिए है कि casefold कॉल को लिस्ट कॉम्प्रिहेंशन से बाहर निकालने से कोई समय बचता है या नहीं:


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

कोड को दस लाख बार चलाने में हर कॉल पर औसतन 4.923898000270128e-07 सेकंड लगा (हर कॉल पर लगभग 492 नैनोसेकंड)। तो, casefold को लिस्ट कॉम्प्रिहेंशन से बाहर निकालने से हर कॉल में लगभग 5 नैनोसेकंड बचे, यानी दस लाख कॉल में कुल मिलाकर करीब 5 मिलीसेकंड।

cProfile की मदद से कोड का प्रोफाइल भी बनाया जा सकता है; हालाँकि यह उतना बारीक नहीं है, क्योंकि यह सिर्फ मिलीसेकंड तक के समय तक ही जा पाता है।