使用基于日期/时间的对象进行 Django 单元测试

Posted

技术标签:

【中文标题】使用基于日期/时间的对象进行 Django 单元测试【英文标题】:Django unit testing with date/time-based objects 【发布时间】:2010-11-05 18:53:18 【问题描述】:

假设我有以下Event 模型:

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

我想测试Event.is_over(),方法是创建一个在未来(今天 + 1 或其他)结束的事件,并存根日期和时间,以便系统认为我们已经到达未来的日期。

就 python 而言,我希望能够存根所有系统时间对象。这包括datetime.date.today()datetime.datetime.now() 和任何其他标准日期/时间对象。

执行此操作的标准方法是什么?

【问题讨论】:

【参考方案1】:

编辑:由于我的答案是此处公认的答案,因此我将对其进行更新,以让每个人都知道同时创建了一个更好的方法,即 freezegun 库:https://pypi.python.org/pypi/freezegun。当我想影响测试时间时,我会在所有项目中使用它。看看吧。

原答案:

更换这样的内部材料总是很危险的,因为它可能会产生令人讨厌的副作用。所以你真正想要的是让猴子补丁尽可能地本地化。

我们使用 Michael Foord 优秀的 mock 库:http://www.voidspace.org.uk/python/mock/,它有一个 @patch 装饰器,用于修补某些功能,但猴子补丁只存在于测试功能的范围内,功能用完后一切都会自动恢复其范围。

唯一的问题是内部datetime 模块是用C 实现的,所以默认情况下你无法对其进行猴子补丁。我们通过制作自己的简单实现来解决这个问题,可以模拟。

整个解决方案是这样的(示例是在 Django 项目中使用的验证器函数,用于验证日期是否在未来)。请注意,我从一个项目中取出了这个,但去掉了不重要的东西,所以在复制粘贴时,事情可能实际上不起作用,但我希望你明白了:)

首先,我们在一个名为 utils/date.py 的文件中定义我们自己非常简单的 datetime.date.today 实现:

import datetime

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

然后我们在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)

最终的实现如下所示:

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.'))

希望对你有帮助

【讨论】:

我在博客上写了另一种使用模拟/补丁和日期时间的方法:voidspace.org.uk/python/weblog/arch_d7_2010_10_02.shtml#e1188 编辑时:freezegun 模块可能有一些问题。我需要根据我的时区进行测试。但是,freezegun 的 freeze_time 方法只允许您提供 tz_offset 参数,Django 将在 datetime 对象中查找 tzinfo 属性。这使我的一些测试用例失败。使用时请记住这一点。 @ErdinEray 请与项目维护人员讨论您在使用 freezegun 时遇到的问题 已经这样做了。发表评论只是为了通知一些 Google 员工。这是问题:github.com/spulec/freezegun/issues/246【参考方案2】:

您可以编写自己的 datetime 模块替换类,实现要替换的 datetime 中的方法和类。例如:

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)

让我们把它放在它自己的模块中,我们称之为datetimestub.py

然后,在测试开始时,您可以这样做:

import sys
import datetimestub

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

随后任何datetime 模块的导入都将使用datetimestub.DatetimeStub 实例,因为当模块名称用作sys.modules 字典中的键时,将不会导入该模块:@987654327 处的对象将改为使用 @。

【讨论】:

【参考方案3】:

与 Steef 的解决方案略有不同。而不是全局替换 datetime 而是您可以只替换您正在测试的模块中的 datetime 模块,例如:

import models # your module with the Event model import datetimestub models.datetime = datetimestub.DatetimeStub()

这样,在您的测试期间更改更加本地化。

【讨论】:

或者,也许是import mockdatetime as datetime 这将涉及更改您正在测试的代码,但不是吗?您真正想要做的就是在模型模块中重新绑定名称“datetime”。 归根结底,它是关于利用 Python 的动态特性来避免不必要地使您的代码复杂化。 +1 这比替换 sys.modules['datetime'] 要好得多。交换 sys.modules['datetime'] 仅适用于后续导入,并且在问题的示例代码中,日期时间的导入是发生的第二件事。设置 models.datetime 允许您在测试 setUp() 中修补对象并在 tearDown() 中恢复它。【参考方案4】:

我建议看看testfixturestest_datetime()。

【讨论】:

【参考方案5】:

如果你嘲笑 self.end_date 而不是 datetime 怎么办?然后,您仍然可以测试该函数是否正在执行您想要的操作,而无需建议所有其他疯狂的解决方法。

这不会让您像问题最初询问的那样对所有日期/时间进行存根,但这可能不是完全必要的。

今天 = datetime.date.today() 事件 1 = 事件() event1.end_date = today - datetime.timedelta(days=1) # 1 天前 事件2 = 事件() event2.end_date = today + datetime.timedelta(days=1) # 未来 1 天 self.assertTrue(event1.is_over()) self.assertFalse(event2.is_over())

【讨论】:

【参考方案6】:

这不会执行系统范围的日期时间替换,但如果您厌倦了尝试让某些东西工作,您总是可以添加一个可选参数以使其更易于测试。

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

【讨论】:

我不确定这是否适用于长时间运行的线程,就像您在 django + mod_wsgi 环境中所拥有的那样。我认为今天的默认值将在第一次加载程序时编译,然后保持不变,直到下次重新加载代码。 你可以进一步减少这个:def is_over(self, today=datetime.datetime.now(): return today > self.date_end【参考方案7】:

两个选择。

    通过提供您自己的日期时间来模拟。由于在标准库目录之前搜索本地目录,因此您可以将测试放在具有您自己的模拟版本 datetime 的目录中。这比看起来更难,因为您不知道所有秘密使用日期时间的地方。

    使用策略。将代码中对datetime.date.today()datetime.date.now() 的显式引用替换为生成它们的FactoryFactory 必须由应用程序(或单元测试)配置模块。这种配置(被某些人称为“依赖注入”)允许您将正常运行时的 Factory 替换为特殊的测试工厂。您获得了很大的灵活性,无需对生产进行特殊处理。没有“如果测试以不同的方式进行”的业务。

这是策略版本。

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

现在你可以这样做了

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

从长远来看,您或多或少必须这样做以将浏览器区域设置与服务器区域设置分开。使用默认的datetime.datetime.now() 使用服务器的区域设置,这可能会激怒处于不同时区的用户。

【讨论】:

我不是特别喜欢这个解决方案,因为它涉及到为了测试代码而使用非标准日期/时间方法使生产代码复杂化。 (a) 它们是标准的 datetime.datetime.now() 函数调用。什么是非标准? (b) 所有设计都应该允许策略(或 (b) 依赖注入),因为这就是单元测试(和架构)做得好的方式。 这可能适用于您从头开始构建的系统,但是当将多个第 3 方库拉到一起时(每个库都调用原始的 datetime.datetime.now()),它可能会成为维护问题。我想尽量减少我必须修改的第 3 方库代码的数量,因此更改原始 python 方法结果的解决方案将是理想的。 -1 这是 Python,不是 Java。我们有一流的函数,因此对任何函数的任何引用都是已经“策略模式”,因为您可以重新分配该名称以指向其他函数。这正是这里(更好的)解决方案所做的。

以上是关于使用基于日期/时间的对象进行 Django 单元测试的主要内容,如果未能解决你的问题,请参考以下文章

Django 仅基于日期过滤日期时间

如何在 Django 中使用不同的设置进行单元测试?

dapi 基于Django的轻量级测试平台六 怎样使用压测功能

如何编写单元测试以确保我的基于日期/时间的代码适用于所有时区以及有/无 DST?

如何开始用Junit进行单元测试

在 Django 单元测试中使用会话对象