2025, Sep 27 09:17

Как правильно тестировать условные декораторы в Python

Показываем, как тестировать условные декораторы в Python: не патч декоратора, а проверки обёрнутой функции через мок. Избегайте ложных зелёных тестов.

Тестирование декораторов в 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

Очевидный, но вводящий в заблуждение юнит‑тест попытался бы пропатчить декоратор и проверить количество его вызовов. Однако это число отражает момент декорирования, а не то, выполнялась ли обёрнутая функция при вызове.

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, такая проверка всё равно пройдёт, потому что декоратор применяется ровно один раз — в момент определения. Она ничего не говорит о том, какой вызываемый объект реально сработал при выполнении функции.

Почему такая проверка вводит в заблуждение

Патча декоратор и читая счётчик его вызовов, вы наблюдаете только фазу декорирования. Функция, которую возвращает декоратор, и выполняется при вызове декорированной функции. Если ваше условие иногда направляет выполнение через родительскую обёртку, нужно отслеживать именно возвращённую обёрнутую функцию, чтобы подтвердить поведение во время выполнения.

Правильный подход к тестированию

Решение — замокать обёрнутую функцию, возвращаемую родительским декоратором, и проверять вызовы на этом объекте. Когда условие 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")

Такой подход фиксирует два ключевых факта. Во‑первых, патч самого декоратора лишь доказывает, что декорирование произошло, — и это всегда один раз. Во‑вторых, патч и отслеживание конкретного обёрнутого вызываемого объекта доказывает, что именно исполнилось во время вызова, — ровно то, чем управляет условный поток.

Почему это важно

Тестирование декораторов часто смешивает два момента времени: декорирование и вызов. В сложных кодовых базах — особенно там, где есть каскадные декораторы или feature‑флаги, — проверка «не того момента» приводит к зелёным тестам, которые не подтверждают реальное поведение. Выравнивание тестов с выполнением в рантайме помогает ловить регрессии в контракте, который навязывает декоратор, а не просто факт его применения.

Выводы

Когда декоратор на этапе вызова решает, направить ли выполнение в родительскую обёртку или вернуться к исходной функции, проверяйте маршрут выполнения через утверждения о вызовах на обёрнутой функции, которую возвращает родительский декоратор. Держите проверки сфокусированными на том, что реально запускается, а не только на том, что прикрепляется при определении. Этот небольшой сдвиг делает тесты устойчивыми и честными относительно поведения, которое для вас важно.

Статья основана на вопросе с StackOverflow от Rohit Pathak и ответе от Favor.