@MasterCopipaster

Почему не получается замокать декоратор у функции?

Всем привет я пытаюсь написать юнит тест.
У меня есть декоратор который проверяет является ли аргумент переданный в функцию действительно аргументом ожидаемого типа. @check_types_method

Его использования выглядит вот так:
class Calculator:

    @check_types_method
    def call(self, a: MegaNumber, b: int) -> int:
        return a.value + b

Если переменная a будет не MegaNumber то будет брошено исключение.
Это круто работает но при тестах это создает проблемы.
Например мне надо подменить MegaNumber mock объектом и если я это сделаю то в тесте получаю ошибку.

TypeError: The argument 'a' must be of type <class 'mega_number.MegaNumber'>, received: MagicMock


Для теста нужно замокать декоратор что бы он не чего не делал. И пропускал Mock объекты. Однако у меня не как не получается сделать это и в чем причина я не пойму.
Моя лучшая попытка выглядит так:

import unittest
from unittest.mock import MagicMock, patch
from calculator import Calculator


@patch('calculator.check_types_method', lambda x: x)
class TestCalculator(unittest.TestCase):

    def test_call(self):
        mock_mega_number = MagicMock()
        mock_mega_number.value = 10
        calculator = Calculator()
        result = calculator.call(mock_mega_number, 5)

        self.assertEqual(result, 15, "The call method should return the sum of MegaNumber's "
                                     "value and the integer provided.")


но декоратор загружается раньше чем применяется patch - и я не как не могу победить это. Подскажите как быть?

код для воспроизведения:

check_types_method.py

from inspect import signature
from functools import wraps
from typing import get_origin, get_args, Union


def check_types_method(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        sig = signature(func)
        bound = sig.bind(*args, **kwargs)
        bound.apply_defaults()

        for name, value in bound.arguments.items():
            expected_type = sig.parameters[name].annotation
            if expected_type is not sig.empty:
                if not is_of_generic_type(value, expected_type):
                    raise TypeError(
                        f"The argument '{name}' must be of type {expected_type}, received: {type(value).__name__}")
        return func(*args, **kwargs)

    def is_of_generic_type(obj, generic_type):
        origin_type = get_origin(generic_type)
        arg_types = get_args(generic_type)
        if origin_type is Union:
            return any(is_of_generic_type(obj, arg) for arg in arg_types)
        if origin_type is None:
            # Non-generic types
            return isinstance(obj, generic_type)
        if not isinstance(obj, origin_type):
            return False
        if origin_type in (list, set):
            element_type = arg_types[0]
            return all(is_of_generic_type(item, element_type) for item in obj)
        elif origin_type is dict:
            key_type, value_type = arg_types
            return all(is_of_generic_type(k, key_type) and is_of_generic_type(v, value_type) for k, v in obj.items())
        elif origin_type is tuple:
            if len(arg_types) == 2 and arg_types[1] is Ellipsis:
                return all(isinstance(item, arg_types[0]) for item in obj)
            else:
                return len(obj) == len(arg_types) and all(
                    is_of_generic_type(item, t) for item, t in zip(obj, arg_types))
        return False

    return wrapper


mega_number.py

class MegaNumber:

    def __init__(self):
        self.value = 10


calculator.py

from check_types_method import check_types_method
from mega_number import MegaNumber


class Calculator:

    @check_types_method
    def call(self, a: MegaNumber, b: int) -> int:
        return a.value + b


test_calculator.py

import unittest
from unittest.mock import MagicMock, patch
from calculator import Calculator


@patch('calculator.check_types_method', lambda x: x)
class TestCalculator(unittest.TestCase):

    def test_call(self):
        mock_mega_number = MagicMock()
        mock_mega_number.value = 10
        calculator = Calculator()
        result = calculator.call(mock_mega_number, 5)

        self.assertEqual(result, 15, "The call method should return the sum of MegaNumber's "
                                     "value and the integer provided.")
  • Вопрос задан
  • 729 просмотров
Пригласить эксперта
Ответы на вопрос 2
Vindicar
@Vindicar
RTFM!
Мне кажется, ты просто используешь неверный инструмент, пытаясь совместить несовместимое.
Вариант А
Ты проверяешь, что у тебя объект нужного типа. Тогда твой декоратор в принципе работает, но вообще-то это задача для статического анализатора кода типа mypy или встроенного в pycharm, а не проверки в рантайме. И тогда нужно забыть про моки.

Вариант Б
Ты проверяешь, что у тебя объект имеет нужные поля и методы (duck typing).
Тогда тебе нужен typing.Protocol в комбинации с typing.runtime_checkable, с помощью которого ты сможешь описать, что должен иметь объект. Затем этот протокол можно будет подсунуть в isinstance() для проверки, и мок, по идее, её пройдёт. Но опять-таки, задача скорее для статического анализатора кода, чем для рантайм-проверки. Если у тебя в принципе неведомо что может быть передано в метод - это простыми тестами не решается.
Ответ написан
@MasterCopipaster Автор вопроса
А ларчик то просто открывался
import unittest
from unittest.mock import MagicMock, patch
from calculator import Calculator
from mega_number import MegaNumber


class TestCalculator(unittest.TestCase):

    def test_call(self):
        mock_mega_number = MagicMock(MegaNumber)
        mock_mega_number.value = 10
        calculator = Calculator()
        result = calculator.call(mock_mega_number, 5)

        self.assertEqual(result, 15, "The call method should return the sum of MegaNumber's "
                                     "value and the integer provided.")
Ответ написан
Комментировать
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы