Untangled Development

Handling auto_now and auto_now_add with factory_boy

Luckett Ledger

Image credit: Gerald Murphy, CC BY-SA 4.0, via Wikimedia Commons

DateField, auto_now and auto_now_add

Let’s say we have an Order entity and we want to keep track of:

  1. when this Order was created
  2. 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:

auto_now_add

Automatically set the field to now when the object is first created. Useful for creation of timestamps. [..]

auto_now

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 Orders for today. I.e. not the last 24 hours. But for the current calendar day.

So we have to filter those Orders 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:

  1. this works, and
  2. 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 Orders. 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_boys 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 for auto_now_add, because model.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 avoids auto_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

Footnotes


  1. 2022 Django Developer Survey 

  2. 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. 

  3. The Given-When-Then style, article by Martin Fowler. 

Comments !