Handling auto_now and auto_now_add with factory_boy

DateField, auto_now and auto_now_add
Let’s say we have an Order
entity and we want to keep track of:
- when this
Order
was created - when this
Order
was last updated
We could override Django’s model class’s save
function for this. For example:
class Order(models.Model):
...
def save(self, *args, **kwargs):
if not self.pk:
self.created_at = timezone.now()
self.updated_at = timezone.now()
super().save(*args, **kwargs)
But considering the breath of this use case, Django conveniently provides auto_now_add
and auto_now
for this:
Automatically set the field to now when the object is first created. Useful for creation of timestamps. [..]
Automatically set the field to now every time the object is saved. Useful for “last-modified” timestamps. [..]
For both fields the docs state:
Note that the current date is always used; it’s not just a default value that you can override.
And in the case of auto_now_add
:
So even if you set a value for this field when creating the object, it will be ignored.
This is important. We’ll come to it later.
Given these fields let’s use them to achieve what we need for our Order
model class. Without overriding save
function:
from django.db import models
class Order(models.Model):
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
Yay!
Our query
We want to retrieve the list of Order
s for today. I.e. not the last 24 hours. But for the current calendar day.
So we have to filter those Order
s whose created_at
is greater-than-or-equal-to today’s “floored”, i.e. today at 00:00
hours. Without using packages like arrow, flooring today’s date:
In [1]: from datetime import datetime, time
...: from django.utils import timezone
In [2]: datetime.combine(timezone.now(), time.min)
Out[2]: datetime.datetime(2023, 9, 10, 0, 0)
But that’s not timezone-aware. Let’s fix that:
In [3]: datetime.combine(timezone.now(), time.min, tzinfo=timezone.get_current_timezone())
Out[3]: datetime.datetime(2023, 9, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
Since we foresee using this in multiple places, we define it as a Queryset function:
class OrderQueryset(models.QuerySet):
def created_today(self):
today_floor = datetime.combine(
timezone.now(), time.min, tzinfo=timezone.get_current_timezone()
)
return self.filter(created_at__gte=today_floor)
and create a manager from that queryset:
class Order:
...
objects = OrderQueryset.as_manager()
Let’s make sure that:
- this works, and
- we don’t break it in the futre.
I.e. let’s write a test for it.
Enter factory_boy
Using factories to create test data is a good idea. The “why“‘s are outside the scope of this post.
To build the test data for this test I will use factory_boy. In to the 2022 Django Developer Survey 1. In which 12% of respondents listed factory_boy
as one of the “top 5 Python packages [they] rely on”.
The factory for our Order
model is straightfoward:
import factory
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = "orders.Order"
The unit test
Since our test needs to mock the current time, I use time_machine
to “travel” to the date/time I want my test to “run at” 2.
Using basic principles from boundary-value analysis we use our OrderFactory
to create three Order
s. Each with a different created_at
to allow us to test our created_today
queryset function:
import arrow
import time_machine
from django.test import TestCase
from orders.models import Order
from orders.tests.factories import OrderFactory
class OrderQuerysetTest(TestCase):
@time_machine.travel("2023-09-10 14:30")
def test_created_today(self):
# Given three orders created on today's date's boundaries
date1 = arrow.get("2023-09-09 23:59") # yesterday
date2 = arrow.get("2023-09-10 00:00") # today 00:00
date3 = arrow.get("2023-09-10 00:01") # today 00:01
_ = OrderFactory(created_at=date1)
order2 = OrderFactory(created_at=date2)
order3 = OrderFactory(created_at=date3)
# When created_today is called
queryset = Order.objects.all().created_today()
# Then the expected orders are returned
self.assertQuerysetEqual(
queryset,
queryset.filter(id__in=[order2.id, order3.id]),
)
Note the usage of “given/when/then” to structure the test 3. Let’s run it!
$ ./manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_created_today (orders.tests.test_models.OrderQuerysetTest.test_created_today)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/path/to/.pyenv/versions/demo_env/lib/python3.11/site-packages/time_machine/__init__.py", line 332, in wrapper
return wrapped(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/path/to/dev/factoryboy_autonow/project/orders/tests/test_models.py", line 23, in test_created_today
self.assertQuerysetEqual(
File "/path/to/.pyenv/versions/demo_env/lib/python3.11/site-packages/django/test/testcases.py", line 1330, in assertQuerysetEqual
return self.assertQuerySetEqual(*args, **kw)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/path/to/.pyenv/versions/demo_env/lib/python3.11/site-packages/django/test/testcases.py", line 1346, in assertQuerySetEqual
return self.assertEqual(list(items), values, msg=msg)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Lists differ: [<Order: 1>, <Order: 2>, <Order: 3>] != [<Order: 2>, <Order: 3>]
First differing element 0:
<Order: 1>
<Order: 2>
First list contains 1 additional elements.
First extra element 2:
<Order: 3>
- [<Order: 1>, <Order: 2>, <Order: 3>]
? ^ ------------
+ [<Order: 2>, <Order: 3>]
? ^
----------------------------------------------------------------------
Ran 1 test in 0.003s
FAILED (failures=1)
Destroying test database for alias 'default'...
Why has it failed? After putting a breakpoint this query makes the issue obvious:
ipdb> Order.objects.all().values("created_at")
<OrderQueryset [{'created_at': datetime.datetime(2023, 9, 10, 14, 30, tzinfo=datetime.timezone.utc)}, {'created_at': datetime.datetime(2023, 9, 10, 14, 30, 0, 184, tzinfo=datetime.timezone.utc)}, {'created_at': datetime.datetime(2023, 9, 10, 14, 30, 0, 286, tzinfo=datetime.timezone.utc)}]>
Didn’t get it? Let’s present it better:
ipdb> [obj.created_at.strftime("%Y-%m-%d %H:%M:%S") for obj in Order.objects.all()]
['2023-09-10 14:30:00', '2023-09-10 14:30:00', '2023-09-10 14:30:00']
The created_at
is being set to the “current time” as mocked by time-machine. Our OrderFactory
is ignoring the created_at
passed into it. Why?
Django’s docs earlier told us:
So even if you set a value for this field when creating the object, it will be ignored.
Indeed factory_boy uses the model’s save
function. And when a field is configured to have auto_now_add
set to True
, it will default to “now”. And “now” is set by time-machine
to be 2023-09-10 14:30:00
as shown above.
The code to replicate this issue can be found in the “initial commit” of this Github repository.
Alternative 1
The first approach to think about addressing this is to:
- create object
- change field
- call
.save()
which, using factory_boy
‘s create_batch
looks like this:
order1, order2, order3 = OrderFactory.create_batch(3)
order1.created_at = date1
order1.save()
order2.created_at = date2
order2.save()
order3.created_at = date3
order3.save()
The github repo commit with this change is here.
This approach works:
$ ./manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.002s
OK
Destroying test database for alias 'default'...
But:
- puts more code and logic in test
- the calls to
save
are repetitive - easy to forget to do it in our next unit test
- this approach does not work for
auto_now
but only forauto_now_add
, becausemodel.save()
will still fetch the current datetime from the mocked datetime
Concllusion: Meh.
Alternative 2
An alternative is the suppress autotime approach.
Using the suppress_autotime
function as written in that answer, the code would now look:
with suppress_autotime(Order, "created_at"):
order1 = OrderFactory(created_at=date1)
order2 = OrderFactory(created_at=date2)
order3 = OrderFactory(created_at=date3)
Complete github repo commit with this change. Following the locality of behaviour principle, I just pasted this function in the test module itself.
This approach works for both auto_now_add
and auto_now
.
What I do not like:
- the context manager results in more things-to-think-about within our unit test code
- an additional import whenever you need
suppress_autotime
function
Alternative 3
Override _create
in factory.django.DjangoModelFactory
. This approach is inspired by three separate resources as described in the References section below.
The code involves restoring the unit test code to the state in which we did not do any changes to handle auto_now_add
:
_ = OrderFactory(created_at=date1)
order2 = OrderFactory(created_at=date2)
order3 = OrderFactory(created_at=date3)
And instead enhance our OrderFactory
class to handle re-write
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = "orders.Order"
@classmethod
def _create(cls, model_class, *args, **kwargs):
# override auto_now_add for "created_at" column
created_at = kwargs.pop("created_at", None)
obj = super()._create(model_class, *args, **kwargs)
if created_at is not None:
model_class.objects.filter(id=obj.id).update(created_at=created_at)
return obj
Complete github repo commit with this change.
How does this work?
- This approach overrides
create
classmethod to hook into the instance creation. - If the field with
auto_now_add
is being set, it sets it as a variable for this model. - Once the instance is created, it using the queryset manager
update
method to set the datetime. Which avoidsauto_now_add
for setting the date.
Downsides:
- This approach is
auto_now_add
-specific. I.e. it works on create only. - It involves two database writes. I.e. one to create the object, and the second to update it. This, depending on your setup, might be a negligible cost.
On the plus side, our test’s code is oblivious of auto_now_add
. We do not have to think about auto_now_add
when writing the test!
Conclusion
In case your test data setup requires you to manually set fields with auto_now
you have to use the “suppress autotime” approach. This works for fields having either auto_now
or auto_now_add
set to True
.
If your test data needs setting only of a field with auto_now_add
you do not need the context manager. And overriding the factory’s _create
function will result in less things to think about while writing tests!
Got a better way to deal with this? Please use the comments box below!
References
- Related question on stackoverflow
- Answer suggesting suppress autotime, inspires approach 2
- Answer suggesting update via queryset manager, inspires approach 3 in part
- Related github non-issue
- and subsequent DjangoModelFactory._create comment, inspires approach 3 in part
Footnotes
-
time-machine is not the only library for mocking time out there. Adam Johnson, the author of
time-machine
, compares it with alternatives in his post introducing the package. ↩ -
The Given-When-Then style, article by Martin Fowler. ↩
Comments !