Intro
Django's auto_now_add
and auto_now
model field arguments provide an easy way to create dates when an entity is created and/or last updated.
To give you more precise overview let's assume that I have a Post Model
like below:
# src/main/models.py
from django.utils.translation import ugettext_lazy as _
from src.core.models.abstract import TimeStampedModel
class Post(TimeStampedModel):
title = models.CharField()
author = models.ForeignKey("author")
body = models.TextField()
is_published = models.BooleanField(default=False)
class Meta:
verbose_name = _("Post")
verbose_name_plural = _("Posts")
def __repr__(self):
return f"<Post {self.author}:{self.title}>"
def __str__(self):
return f"{self.author}:{self.title}"
And this is my TimeStampedModel
:
# src/core/models/abstract.py
from django.db import models
class TimeStampedModel(models.Model):
"""
An abstract base class model that provides self-updating
`created_at` and `updated_at` fields.
"""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
ordering = ["-updated_at"]
get_latest_by = "-updated_at"
Above I used TimeStampedModel
as an Abstract Model
and I always recommend to move your common fields into Abstract Model
aligned with clean architecture and DRY method.
In a more common way you could write the model like below;
# src/main/models.py
from django.db import models
from django.utils.translation import ugettext_lazy as _
class Post(models.Model):
title = models.CharField()
author = models.ForeignKey("author")
body = models.TextField()
is_published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Post")
verbose_name_plural = _("Posts")
def __repr__(self):
return f"<Post {self.author}:{self.title}>"
def __str__(self):
return f"{self.author}:{self.title}"
Problem
Unfortunately, auto_now
and auto_now_add
make writing unit tests which depend on creation or modification times difficult, since there is no simple way to set these fields to a specific time for testing.
For an example, assume you have a business rule; you're giving 7 days to authors to be able publish a blog post after creation. Maybe you want them to re-read their posts to eliminate typo or logic errors — yes, it makes no sense, I'm just making some company policy.
# src/main/models.py
import datetime
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from src.core.models.abstract import TimeStampedModel
class Post(TimeStampedModel):
title = models.CharField()
author = models.ForeignKey("author")
body = models.TextField()
is_published = models.BooleanField(default=False)
class Meta:
verbose_name = _("Post")
verbose_name_plural = _("Posts")
def __repr__(self):
return f"<Post {self.author}:{self.title}>"
def __str__(self):
return f"{self.author}:{self.title}"
def publish(self):
"""Publish a post which created >=7 days before"""
if timezone.now() - self.created_at <= datetime.timedelta(days=7):
self.is_published = True
self.save()
As you can see we're making is_published
attr True
if a post created 7 days before or more.
In order to test this behavior, let's write some unittest;
# src/tests/test_models.py
import pytest
from src.main.models import Post
from src.users.models import User
from src.users.tests.factories import UserFactory
@pytest.fixture
def user() -> User:
return UserFactory()
class TestPostModel:
def test_is_published_with_now(self, user):
post = Post.objects.create(
title="some-title",
body="some-body",
author=user,
)
post.publish()
assert post.is_published is True
I'm using FactoryBoy
library to create an User
instance, however you can use default methods like User.objects.create(...)
.
Above test will fail since the created_at
field of the created Post
model instance will always be equal to the time you run the tests. So there is no way to make is_published
True
in a test.
Solution
Solution comes from Python's unittest.mock
library: Mock;
# src/tests/test_models.py
import datetime
import pytest
from unittest import mock
from django.utils import timezone
from src.main.models import Post
from src.users.models import User
from src.users.tests.factories import UserFactory
@pytest.fixture
def user() -> User:
return UserFactory()
class TestPostModel:
time_test_now = timezone.now() - datetime.timedelta(days=60)
@mock.patch("django.utils.timezone.now")
def test_is_published_with_now(self, mock_now, user):
mock_now.return_value = self.time_test_now
post = Post.objects.create(
title="some-title",
body="some-body",
author=user,
)
post.publish()
assert post.is_published is True
We are patching the method with mock.patch
decorator to return a specific time when the factory creates the object for testing. So with mock
in this method current now will be 60 days before actual now.
When you run the test you'll see the test will pass.
Instead of decorator you can also use context manager
—I don't usually use this method since it creates hard-to-read, nested methods when you mock/patch multiple stuff:
# src/tests/test_models.py
import pytest
from unittest import mock
from django.utils import dateparse, timezone
from src.main.models import Post
from src.users.models import User
from src.users.tests.factories import UserFactory
@pytest.fixture
def user() -> User:
return UserFactory()
class TestPostModel:
def test_is_published_with_now(self, user):
with mock.patch("django.utils.timezone.now") as mock_now:
mock_now.return_value = dateparse.parse_datetime("2020-01-01T04:30:00Z")
post = Post.objects.create(
title="some-title",
body="some-body",
author=user,
)
post.publish()
assert post.is_published is True
When you run the test you will see same successful result.
All done!
Top comments (2)
Hey, looks like markup of the page drifted
Thank you for the info. I fixed it.