31

I'm trying to write unit tests for a django app that does a lot of datetime operations. I have installed mock to monkey patch django's timezone.now for my tests.

While I am able to successfully mock timezone.now when it is called normally (actually calling timezone.now() in my code, I am not able to mock it for models that are created with a DateTimeField with default=timezone.now.


I have a User model that contains the following:

from django.utils import timezone
...
timestamp = models.DateTimeField(default=timezone.now)
modified = models.DateTimeField(default=timezone.now)
...
def save(self, *args, **kwargs):
    if kwargs.pop('modified', True):
        self.modified = timezone.now()
    super(User, self).save(*args, **kwargs)

My unit test looks like this:

from django.utils import timezone

def test_created(self):
    dt = datetime(2010, 1, 1, tzinfo=timezone.utc)
    with patch.object(timezone, 'now', return_value=dt):
        user = User.objects.create(username='test')
        self.assertEquals(user.modified, dt)
        self.assertEquals(user.timestamp, dt)

assertEquals(user.modified, dt) passes, but assertEquals(user.timestamp, dt) does not.

How can I mock timezone.now so that even default=timezone.now in my models will create the mock time?


Edit

I know that I could just change my unit test to pass a timestamp of my choice (probably generated by the mocked timezone.now)... Curious if there is a way that avoids that though.

nhahtdh
  • 55,989
  • 15
  • 126
  • 162
dgel
  • 16,352
  • 8
  • 58
  • 75
  • could you provide a full relevant listing for `User` model? It is very important to know at what point the binding for default value is taking place. – Oleksiy Sep 21 '13 at 01:12

4 Answers4

17

Here's a method you can use that doesn't require altering your non-test code. Just patch the default attributes of the fields you want to affect. For example--

field = User._meta.get_field('timestamp')
mock_now = lambda: datetime(2010, 1, 1)
with patch.object(field, 'default', new=mock_now):
    # Your code here

You can write helper functions to make this less verbose. For example, the following code--

@contextmanager
def patch_field(cls, field_name, dt):
    field = cls._meta.get_field(field_name)
    mock_now = lambda: dt
    with patch.object(field, 'default', new=mock_now):
        yield

would let you write--

with patch_field(User, 'timestamp', dt):
    # Your code here

Similarly, you can write helper context managers to patch multiple fields at once.

cjerdonek
  • 5,814
  • 2
  • 32
  • 26
  • The problem with this approach is that the test doesn't fail if the `default` argument isn't provided to the field, so it passes when it ought to fail – chrisbunney Feb 27 '19 at 12:45
  • I'm not sure I follow. Do you have an example? Is there any reason you can't add additional assertions to your test to make sure a failure happens in the situation you're concerned about? – cjerdonek Feb 28 '19 at 04:17
  • The example would be to remove the `default=timezone.now` from the declaration for the timezone field and check whether the test passes or not. My experience was that the test passed when I expected it to fail due to there being no default. – chrisbunney Feb 28 '19 at 12:12
  • We want to test that the field is set to the current date when no value is provided. This requires a callable that returns the current date to be passed to the `default` argument, but by patching the `default` argument, we actually overwrite whatever was there, so we're not testing the code as it would be in production. By mocking default, we actually test whether django uses the default argument correctly! So, that makes the patching all rather redundant. In the end, I opted for checking default was set to `timezone.now`: `self.assertEqual(event_datetime_field.default, timezone.now)` – chrisbunney Feb 28 '19 at 12:24
  • In that case, yes, you wouldn't want to use this approach. The purpose of this approach is to ensure that test objects created in your tests have known (e.g. hard-coded) datetime values instead of values that vary depending on the current real datetime. So it's not meant for testing the field itself but rather as a supportive technique to ensure that tests using these objects are deterministic / reproducible. – cjerdonek Mar 01 '19 at 10:04
  • Thank you!!! I just had to mock `get_default` instead of `default`. – pheki Nov 24 '22 at 01:22
10

I just ran into this issue myself. The problem is that models are loaded before mock has patched the timezone module, so at the time the expression default=timezone.now is evaluated, it sets the default kwarg to the real timezone.now function.

The solution is the following:

class MyModel(models.Model):
    timestamp = models.DateTimeField(default=lambda: timezone.now())
Jordan Carlson
  • 393
  • 1
  • 7
  • 1
    Hi, @Jordan, after changing this default value, and run the python manage.py makemigrations command, I got error like: "raise ValueError("Cannot serialize function: lambda")", my python is version 3.5.3, django is 1.10.5 – Menglong Li Apr 16 '17 at 09:33
  • 12
    Do not do that! django cannot serialize a lambda, it will end up with an exception during makemigrations. Instead, create a wrapper function in a utils package `def now(): return timezone.now()` and use it everywhere – frlinw Jul 26 '18 at 21:30
6

There is another easy way to do the above thing.

import myapp.models.timezone
from unittest.mock import patch

@patch('django.utils.timezone.now')
def test_created(self, mock_timezone):
    dt = datetime(2010, 1, 1, tzinfo=timezone.utc)
    mock_timezone.return_value = dt
    user = User.objects.create(username='test')

    self.assertEquals(user.modified, dt)
    self.assertEquals(user.timestamp, dt)

This is the best way to mock timezone.now.

Tahir Fazal
  • 313
  • 2
  • 6
  • 5
    This doesn't seem to work for me with Django 2.1.5 and python 3.6.7 – chrisbunney Feb 27 '19 at 12:47
  • @chrisbu Note that fields with `auto_add_now` will not be overridden, as `auto_add_now` is a database-level instruction and not a python instruction. Django developers decided not to change this odd behavior. Reference: https://code.djangoproject.com/ticket/16583 – Barney Szabolcs Dec 28 '20 at 22:02
3

Looks like you are patching timezone in the wrong place.

Assuming your User model lives in myapp\models.py and you want to test save() in that file. The problem is that when you from django.utils import timezone at the top, it imports it from django.utils. In your test you are patching timezone locally, and it has no effect on your test, since module myapp\models.py already has a reference to the real timezone and it looks like our patching had no effect.

Try patching timezone from myapp\models.py, something like:

import myapp.models.timezone

def test_created(self):
    with patch('myapp.models.timezone') as mock_timezone:
        dt = datetime(2010, 1, 1, tzinfo=timezone.utc)
        mock_timezone.now.return_value = dt

        assert myapp.models.timezone.now() == dt

        user = User.objects.create(username='test')
        self.assertEquals(user.modified, dt)
        self.assertEquals(user.timestamp, dt)
Oleksiy
  • 6,337
  • 5
  • 41
  • 58
  • Good catch. Unfortunately importing `timezone` from my models doesn't seem to make any difference. – dgel Sep 20 '13 at 20:24
  • give it a try with example: you have to patch on a module level, not just object. – Oleksiy Sep 20 '13 at 20:43
  • 1
    looks like the default value is set way before the test (and patching) runs. There is probably some serious django magic that happens before test is even running. – Oleksiy Sep 21 '13 at 02:06
  • @Oleksiy The magic is the following: `auto_add_now` is a database-level instruction and not a python instruction. Django developers decided not to change this odd behavior. Reference: code.djangoproject.com/ticket/16583 – Barney Szabolcs Dec 28 '20 at 22:06