在Django单元测试中修补datetime的最佳方法

4 投票
1 回答
2453 浏览
提问于 2025-04-18 00:51

我在用一个自制的 datetime.datetime 模拟工具来替换代码中的日期时间功能(具体见底部),但其他人似乎对它的工作原理有些困惑,常常遇到意想不到的问题。我们来看一个测试例子:

@patch("datetime.datetime", FakeDatetime)
def my_test(self):
  FakeDatetime.now_value = datetime(2014, 04, 02, 13, 0, 0)

  u = User.objects.get(x=y)
  u.last_login = datetime(2014, 04, 01, 14, 0, 0)
  u.save()

  u2 = User.objects.get(x=y)
  # Checks if datetime.datetime.now() - u2.last_login < 24 hours
  self.assertTrue(u2.logged_in_in_last_24_hours())

现在,如果你看看 Django 的 DatetimeField 是如何把日期转换成 SQL 的:

def to_python(self, value):
  if value is None:
    return value
  if isinstance(value, datetime.datetime):
    return value
  if isinstance(value, datetime.date):
    value = datetime.datetime(value.year, value.month, value.day)

源代码

当你在测试中调用 u.save() 时,这部分代码就会被执行。此时在 Django 代码中,value 的值(u.last_login)是 datetime.datetime 类型,因为我们在测试中使用了一个没有被替换的 datetime 版本(因为我们的导入是在模块级别,而替换是在方法级别)。

现在在 Django 代码中,datetime.datetime 被替换了,因此:

isinstance(value, datetime.datetime)

相当于:

isinstance(datetime.datetime(2014, 04, 01, 14, 0, 0), FakeDatetime)

这结果是 False,但:

isinstance(datetime.datetime(2014, 04, 01, 14, 0, 0), datetime.date)

结果是 True,因此 datetime.datetime 对象被转换成了 datetime.date,当你从 SQL 中获取 u2.last_login 的值时,实际上是 datetime(2014, 04, 01, 0, 0, 0),而不是 datetime(2014, 04, 01, 14, 0, 0)

因此测试失败了。

解决这个问题的方法是把:

u.date_joined = datetime(2014, 04, 01, 14, 0, 0)

替换成:

u.date_joined = FakeDatetime(2014, 04, 01, 14, 0, 0)

但这似乎容易出错,并且常常让使用或编写测试的人感到困惑。

尤其是在你需要真实的 now 值时,你必须要么使用 datetime_to_fakedatetime(datetime.datetime.now()),要么调用 FakeDatetime.now(),但要确保之前的测试已经取消了 FakeDatetime.now_value 的设置。

我在寻找一种更直观的方法,同时又避免在特定的子模块中单独替换 datetime.datetime 对象(因为可能有很多这样的模块),而是希望在整个代码中进行替换。

自制模拟工具的代码:

from datetime import datetime

class FakeDatetime(datetime):
  now_value = None

  def __init__(self, *args, **kwargs):
    return super(FakeDatetime, self).__init__()

  @classmethod
  def now(cls):
    if cls.now_value:
      result = cls.now_value
    else:
      result = datetime.now()
    return datetime_to_fakedatetime(result)

  @classmethod
  def utcnow(cls):
    if cls.now_value:
      result = cls.now_value
    else:
      result = datetime.utcnow()
    return datetime_to_fakedatetime(result)

  # http://stackoverflow.com/questions/20288439/how-to-mock-the-operator-in-python-specifically-datetime-date-datetime-ti
  def __add__(self, other):
    return datetime_to_fakedatetime(super(FakeDatetime, self).__add__(other))

  def __sub__(self, other):
    return datetime_to_fakedatetime(super(FakeDatetime, self).__sub__(other))

  def __radd__(self, other):
    return datetime_to_fakedatetime(super(FakeDatetime, self).__radd__(other))

  def __rsub__(self, other):
    return datetime_to_fakedatetime(super(FakeDatetime, self).__rsub__(other))


def datetime_to_fakedatetime(dt):
  # Because (datetime - datetime) produces a timedelta, so check if the result is of the correct type.
  if isinstance(dt, datetime):
    return FakeDatetime(
      dt.year,
      dt.month,
      dt.day,
      dt.hour,
      dt.minute,
      dt.second,
      dt.microsecond,
      dt.tzinfo
    )
  return dt

谢谢!

1 个回答

3

这里有一个链接,指向一个叫做 freezegun 的工具,它可以和Django一起使用。

撰写回答