Django unit testing with date/time-based objects

≡放荡痞女 提交于 2019-12-18 10:33:49

问题


Suppose I have the following Event model:

from django.db import models
import datetime

class Event(models.Model):
    date_start = models.DateField()
    date_end = models.DateField()

    def is_over(self):
        return datetime.date.today() > self.date_end

I want to test Event.is_over() by creating an Event that ends in the future (today + 1 or something), and stubbing the date and time so the system thinks we've reached that future date.

I'd like to be able to stub ALL system time objects as far as python is concerned. This includes datetime.date.today(), datetime.datetime.now(), and any other standard date/time objects.

What's the standard way to do this?


回答1:


EDIT: Since my answer is the accepted answer here I'm updating it to let everyone know a better way has been created in the meantime, the freezegun library: https://pypi.python.org/pypi/freezegun. I use this in all my projects when I want to influence time in tests. Have a look at it.

Original answer:

Replacing internal stuff like this is always dangerous because it can have nasty side effects. So what you indeed want, is to have the monkey patching be as local as possible.

We use Michael Foord's excellent mock library: http://www.voidspace.org.uk/python/mock/ that has a @patch decorator which patches certain functionality, but the monkey patch only lives in the scope of the testing function, and everything is automatically restored after the function runs out of its scope.

The only problem is that the internal datetime module is implemented in C, so by default you won't be able to monkey patch it. We fixed this by making our own simple implementation which can be mocked.

The total solution is something like this (the example is a validator function used within a Django project to validate that a date is in the future). Mind you I took this from a project but took out the non-important stuff, so things may not actually work when copy-pasting this, but you get the idea, I hope :)

First we define our own very simple implementation of datetime.date.today in a file called utils/date.py:

import datetime

def today():
    return datetime.date.today()

Then we create the unittest for this validator in tests.py:

import datetime
import mock
from unittest2 import TestCase

from django.core.exceptions import ValidationError

from .. import validators

class ValidationTests(TestCase):
    @mock.patch('utils.date.today')
    def test_validate_future_date(self, today_mock):
        # Pin python's today to returning the same date
        # always so we can actually keep on unit testing in the future :)
        today_mock.return_value = datetime.date(2010, 1, 1)

        # A future date should work
        validators.validate_future_date(datetime.date(2010, 1, 2))

        # The mocked today's date should fail
        with self.assertRaises(ValidationError) as e:
            validators.validate_future_date(datetime.date(2010, 1, 1))
        self.assertEquals([u'Date should be in the future.'], e.exception.messages)

        # Date in the past should also fail
        with self.assertRaises(ValidationError) as e:
            validators.validate_future_date(datetime.date(2009, 12, 31))
        self.assertEquals([u'Date should be in the future.'], e.exception.messages)

The final implementation looks like this:

from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError

from utils import date

def validate_future_date(value):
    if value <= date.today():
        raise ValidationError(_('Date should be in the future.'))

Hope this helps




回答2:


You could write your own datetime module replacement class, implementing the methods and classes from datetime that you want to replace. For example:

import datetime as datetime_orig

class DatetimeStub(object):
    """A datetimestub object to replace methods and classes from 
    the datetime module. 

    Usage:
        import sys
        sys.modules['datetime'] = DatetimeStub()
    """
    class datetime(datetime_orig.datetime):

        @classmethod
        def now(cls):
            """Override the datetime.now() method to return a
            datetime one year in the future
            """
            result = datetime_orig.datetime.now()
            return result.replace(year=result.year + 1)

    def __getattr__(self, attr):
        """Get the default implementation for the classes and methods
        from datetime that are not replaced
        """
        return getattr(datetime_orig, attr)

Let's put this in its own module we'll call datetimestub.py

Then, at the start of your test, you can do this:

import sys
import datetimestub

sys.modules['datetime'] = datetimestub.DatetimeStub()

Any subsequent import of the datetime module will then use the datetimestub.DatetimeStub instance, because when a module's name is used as a key in the sys.modules dictionary, the module will not be imported: the object at sys.modules[module_name] will be used instead.




回答3:


Slight variation to Steef's solution. Rather than replacing datetime globally instead you could just replace the datetime module in just the module you are testing, e.g.:


import models # your module with the Event model
import datetimestub

models.datetime = datetimestub.DatetimeStub()

That way the change is much more localised during your test.




回答4:


I'd suggest taking a look at testfixtures test_datetime().




回答5:


What if you mocked the self.end_date instead of the datetime? Then you could still test that the function is doing what you want without all the other crazy workarounds suggested.

This wouldn't let you stub all date/times like your question initially asks, but that might not be completely necessary.

today = datetime.date.today()

event1 = Event()
event1.end_date = today - datetime.timedelta(days=1) # 1 day ago
event2 = Event()
event2.end_date = today + datetime.timedelta(days=1) # 1 day in future

self.assertTrue(event1.is_over())
self.assertFalse(event2.is_over())



回答6:


This doesn't perform system-wide datetime replacement, but if you get fed up with trying to get something to work you could always add an optional parameter to make it easier for testing.

def is_over(self, today=datetime.datetime.now()):
    return today > self.date_end



回答7:


Two choices.

  1. Mock out datetime by providing your own. Since the local directory is searched before the standard library directories, you can put your tests in a directory with your own mock version of datetime. This is harder than it appears, because you don't know all the places datetime is secretly used.

  2. Use Strategy. Replace explicit references to datetime.date.today() and datetime.date.now() in your code with a Factory that generates these. The Factory must be configured with the module by the application (or the unittest). This configuration (called "Dependency Injection" by some) allows you to replace the normal run-time Factory with a special test factory. You gain a lot of flexibility with no special case handling of production. No "if testing do this differently" business.

Here's the Strategy version.

class DateTimeFactory( object ):
    """Today and now, based on server's defined locale.

    A subclass may apply different rules for determining "today".  
    For example, the broswer's time-zone could be used instead of the
    server's timezone.
    """
    def getToday( self ):
        return datetime.date.today()
    def getNow( self ):
        return datetime.datetime.now()

class Event( models.Model ):
    dateFactory= DateTimeFactory() # Definitions of "now" and "today".
    ... etc. ...

    def is_over( self ):
        return dateFactory.getToday() > self.date_end 


class DateTimeMock( object ):
    def __init__( self, year, month, day, hour=0, minute=0, second=0, date=None ):
        if date:
            self.today= date
            self.now= datetime.datetime.combine(date,datetime.time(hour,minute,second))
        else:
            self.today= datetime.date(year, month, day )
            self.now= datetime.datetime( year, month, day, hour, minute, second )
    def getToday( self ):
        return self.today
    def getNow( self ):
        return self.now

Now you can do this

class SomeTest( unittest.TestCase ):
    def setUp( self ):
        tomorrow = datetime.date.today() + datetime.timedelta(1)
        self.dateFactoryTomorrow= DateTimeMock( date=tomorrow )
        yesterday = datetime.date.today() + datetime.timedelta(1)
        self.dateFactoryYesterday=  DateTimeMock( date=yesterday )
    def testThis( self ):
        x= Event( ... )
        x.dateFactory= self.dateFactoryTomorrow
        self.assertFalse( x.is_over() )
        x.dateFactory= self.dateFactoryYesterday
        self.asserTrue( x.is_over() )

In the long run, you more-or-less must do this to account for browser locale separate from server locale. Using default datetime.datetime.now() uses the server's locale, which may piss off users who are in a different time zone.



来源:https://stackoverflow.com/questions/1042900/django-unit-testing-with-date-time-based-objects

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!