Home > database >  Monkey patch datetime.now() in an entire Django app with unittest.mock
Monkey patch datetime.now() in an entire Django app with unittest.mock

Time:09-20

I have a Django project with this file structure:

.
├── 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

In my views.py, I have a call to datetime.now():

from datetime import datetime


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

And in child_app/my_calendar.py, I have another call to datetime.now():

from datetime import datetime


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

This is a simple example, but in real life this Django app has a lot of child apps with many calls to datetime.now(), and I'm trying to write a test to mock all of them. Looking at this answer, I can use this to mock datetime.now() in a module:

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")

This test passes, and I can do the same for the other module:

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")

This test passes as well. Now I have a lot more modules and it's a pain, and ultimately infeasible, to write a separate decorator for each and every one of them. How can I monkey patch every datetime.now() in an entire Django app (here app) in a DRY manner? I can't do this:

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")

Because:

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

Now I can't change the actual code and I can only change the tests, but even if I add from datetime import datetime to mock_django/app/__init__.py, the tests will fail.

I tried to use 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")

But this gives another error:

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

I can achieve the results I want using time_machine:

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


@time_machine.travel(datetime(2022, 9, 10, 14))
class TestApp(TestCase):
    def test_datetime_now(self):
        self.assertEqual(time_now(), "2022-09-10 14")
        self.assertEqual(another_method(), "2022-09-10 14")

But I'm wondering if I can do exactly this with unittest.mock.

CodePudding user response:

You can use pkgutil.walk_packages to find the submodules and then recursively patch them:

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

Usage:

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")
  • Related