使用基于日期/时间的对象进行 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()
的显式引用替换为生成它们的Factory。 Factory 必须由应用程序(或单元测试)配置模块。这种配置(被某些人称为“依赖注入”)允许您将正常运行时的 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 单元测试的主要内容,如果未能解决你的问题,请参考以下文章
dapi 基于Django的轻量级测试平台六 怎样使用压测功能