2025, Sep 27 09:31

Python में सशर्त डेकोरेटर की सही यूनिट टेस्टिंग

Python में सशर्त डेकोरेटर की यूनिट टेस्टिंग सीखें: डेकोरेशन बनाम रनटाइम कॉल का फर्क, सही mocking/patching और व्यवहार-आधारित आसर्शन के तरीके। उदाहरण सहित

Python में शर्तों के आधार पर लगने वाले डेकोरेटर का परीक्षण दिखने से ज्यादा पेचीदा हो सकता है। आप डेकोरेटर को इंस्ट्रूमेंट करके कॉल गिन सकते हैं, लेकिन बाद में पता चलता है कि आपकी जाँच वास्तविक निष्पादन पथ को पकड़ ही नहीं रही। असली समस्या सीधी है: डेकोरेटर परिभाषा के समय चलते हैं, जबकि वे जो फ़ंक्शन लौटाते हैं, वह कॉल के समय चलता है। अगर आपको किसी शर्त के तहत व्यवहार साबित करना है, तो सही परत का निरीक्षण करना होगा।

सेटअप

एक नेस्टेड डेकोरेटर पर विचार करें, जहाँ बाहरी रैपर केवल तब लगाया जाता है जब कोई प्रेडिकेट True निकलता है। नीचे की लॉजिक एक पेरेंट रैपर से जोड़ती है और शर्त के आधार पर निष्पादन को मोड़ती है।

# decoratorFactory.py
def bootstrap_guard(func):
    print("parent decorator is initialized")
    primed = False
    def call_through(*args, **kwargs):
        nonlocal primed
        if primed:
            return func(*args, **kwargs)
        else:
            primed = True
            res = func(*args, **kwargs)
            return res
    return call_through
def select_wrap(predicate):
    def apply_guard(fn):
        guarded_fn = bootstrap_guard(fn)
        def envelope(*args, **kwargs):
            if predicate():
                print(f"{predicate()}")
                return guarded_fn(*args, **kwargs)
            else:
                print(f"{predicate()}")
                return fn(*args, **kwargs)
        return envelope
    return apply_guard

एक सीधा-सादा लेकिन भ्रामक यूनिट-टेस्ट डेकोरेटर को पैच करके उसकी कॉल-गिनती पर दावा करेगा। मगर वह संख्या सजावट (decoration) के समय को दर्शाती है, यह नहीं कि कॉल होने पर लिपटा हुआ फ़ंक्शन चला था या नहीं।

import unittest
from unittest import mock
import util
from decoratorFactory import select_wrap
class TestGateDecorator(unittest.TestCase):
    @mock.patch("decoratorFactory.bootstrap_guard")
    def test_select_wrap_decorates_once(self, mock_guard):
        mock_guard.return_value = lambda: "Hello"
        with mock.patch("util.flag_ready", return_value=True):
            @select_wrap(predicate=util.flag_ready)
            def inner():
                return "Hello"
            self.assertEqual(inner(), "Hello")
            self.assertEqual(mock_guard.call_count, 1)

भले ही आप शर्त को False कर दें, यह आसर्शन फिर भी पास हो जाएगा, क्योंकि डेकोरेटर परिभाषा के समय ठीक एक बार ही लगाया जाता है। यह इस बारे में कुछ नहीं बताता कि फ़ंक्शन चलते समय वास्तव में कौन-सा callable चला।

यह आसर्शन भ्रामक क्यों है

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

जाँचने का सही तरीका

समाधान यह है कि पेरेंट डेकोरेटर जो लिपटा हुआ फ़ंक्शन लौटाता है, उसे मॉक करें और उसी ऑब्जेक्ट पर कॉल की पुष्टि करें। शर्त True होने पर सशर्त डेकोरेटर को पेरेंट का रैपर कॉल करना चाहिए; और False होने पर उसे उसे दरकिनार करके मूल फ़ंक्शन को सीधा कॉल करना चाहिए। नीचे के टेस्ट ठीक यही दिखाते हैं।

import unittest
from unittest import mock
import util
from decoratorFactory import select_wrap
class TestGateDecorator(unittest.TestCase):
    def test_select_wrap_executes_parent_when_true(self):
        wrapped_spy = mock.MagicMock(return_value="Hello from parent")
        with mock.patch("decoratorFactory.bootstrap_guard") as patched_guard:
            patched_guard.return_value = wrapped_spy
            with mock.patch("util.flag_ready", return_value=True):
                @select_wrap(predicate=util.flag_ready)
                def inner():
                    return "Hello from original"
                result = inner()
                wrapped_spy.assert_called_once()
                self.assertEqual(result, "Hello from parent")
    def test_select_wrap_bypasses_parent_when_false(self):
        wrapped_spy = mock.MagicMock(return_value="Hello from parent")
        with mock.patch("decoratorFactory.bootstrap_guard") as patched_guard:
            patched_guard.return_value = wrapped_spy
            with mock.patch("util.flag_ready", return_value=False):
                @select_wrap(predicate=util.flag_ready)
                def inner():
                    return "Hello from original"
                result = inner()
                wrapped_spy.assert_not_called()
                self.assertEqual(result, "Hello from original")

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

यह क्यों मायने रखता है

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

मुख्य बातें

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

यह लेख StackOverflow के एक प्रश्न (लेखक: Rohit Pathak) और Favor के उत्तर पर आधारित है।