Обезьяний патч datetime.now() во всем приложении Django с помощью unittest.mock

У меня есть проект Django с такой структурой файлов:

.
├── app
│  ├── __init__.py
│  ├── admin.py
│  ├── apps.py
│  ├── child_app
│  │  └── my_calendar.py
│  ├── migrations
│  ├── models.py
│  ├── tests.py
│  └── views.py
├── manage.py
└── mock_django
   ├── __init__.py
   ├── asgi.py
   ├── settings.py
   ├── urls.py
   └── wsgi.py

В моем views.py есть вызов datetime.now():

from datetime import datetime


def time_now():
    return datetime.now().strftime("%Y-%m-%d %H")

В child_app/my_calendar.py у меня есть еще один вызов datetime.now():

from datetime import datetime


def another_method():
    return datetime.now().strftime("%Y-%m-%d %H")

Это простой пример, но в реальной жизни это приложение Django имеет множество дочерних приложений с большим количеством вызовов datetime.now(), и я пытаюсь написать тест, чтобы высмеять их все. Глядя на этот ответ, я могу использовать его для высмеивания datetime.now() в модуле:

from django.test import TestCase
from unittest import mock
from datetime import datetime
from views import time_now


class TestApp(TestCase):
    @mock.patch("views.datetime", wraps=datetime)
    def test_datetime_now(self, views_datetime):
        views_datetime.now.return_value = datetime(2022, 9, 10, 14)

        self.assertEqual(time_now(), "2022-09-10 14")

Этот тест проходит, и я могу сделать то же самое для другого модуля:

from django.test import TestCase
from unittest import mock
from datetime import datetime
from views import time_now
from child_app.my_calendar import another_method


class TestApp(TestCase):
    @mock.patch("views.datetime", wraps=datetime)
    @mock.patch("child_app.my_calendar.datetime", wraps=datetime)
    def test_datetime_now(self, my_calendar_datetime, views_datetime):
        my_calendar_datetime.now.return_value = datetime(2022, 9, 10, 14)
        views_datetime.now.return_value = datetime(2022, 9, 10, 14)

        self.assertEqual(time_now(), "2022-09-10 14")
        self.assertEqual(another_method(), "2022-09-10 14")

Этот тест также проходит. Теперь у меня гораздо больше модулей, и писать отдельный декоратор для каждого из них - мучение и, в конечном счете, невыполнимая задача. Как я могу по-обезьяньи исправить каждый datetime.now() во всем приложении Django (здесь app) DRY образом? Я не могу этого сделать:

from django.test import TestCase
from unittest import mock
from datetime import datetime
from views import time_now
from child_app.my_calendar import another_method


class TestApp(TestCase):
    @mock.patch("app.datetime", wraps=datetime)
    def test_datetime_now(self, mock_datetime):
        mock_datetime.now.return_value = datetime(2022, 9, 10, 14)

        self.assertEqual(time_now(), "2022-09-10 14")
        self.assertEqual(another_method(), "2022-09-10 14")

Причина:

AttributeError: <module 'app' from 'mock_django/app/__init__.py'> does
not have the attribute 'datetime'

Теперь я не могу изменить фактический код и могу изменить только тесты, но даже если я добавлю from datetime import datetime к mock_django/app/__init__.py, тесты не пройдут.

Я пытался использовать mock.patch.object:

from django.test import TestCase
from unittest import mock
from datetime import datetime
from views import time_now
from child_app.my_calendar import another_method


class TestApp(TestCase):
    @mock.patch.object(datetime, 'now')
    def test_datetime_now(self, mock_datetime):
        mock_datetime.now.return_value = datetime(2022, 9, 10, 14)

        self.assertEqual(time_now(), "2022-09-10 14")
        self.assertEqual(another_method(), "2022-09-10 14")

Но это дает другую ошибку:

TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'

Вы можете использовать pkgutil.walk_packages для поиска подмодулей и затем рекурсивно исправлять их:

import contextlib
import importlib
import pkgutil
from unittest.mock import DEFAULT, MagicMock, patch


def patch_in_app(app, *, attribute, wraps):
    module = importlib.import_module(app)
    targets = [
        f"{name}.{attribute}"
        for _, name, _ in pkgutil.walk_packages(module.__path__, prefix=f"{app}.")
        if hasattr(importlib.import_module(name), attribute)
    ]

    def decorator(func):
        @functools.wraps(func)
        def _func(*args):
            with patch_all(*targets, new=MagicMock(wraps=wraps)) as mock:
                return func(*args, mock)
        return _func

    return decorator


@contextlib.contextmanager
def patch_all(target, *targets, new=DEFAULT):
    with patch(target, new=new) as mock:
        if targets:
            with patch_all(*targets, new=mock):
                yield mock
        else:
            yield mock

Использование:

class TestApp(TestCase):
    # @mock.patch("app.views.datetime", wraps=datetime)                  # Change these
    # @mock.patch("app.child_app.my_calendar.datetime", wraps=datetime)  #
    @patch_in_app("app", attribute="datetime", wraps=datetime)           # to this
    def test_datetime_now(self, mock_datetime):
        mock_datetime.now.return_value = datetime(2022, 9, 10, 14)

        self.assertEqual(time_now(), "2022-09-10 14")
        self.assertEqual(another_method(), "2022-09-10 14")
Вернуться на верх