DEV Community

Serhat Teker
Serhat Teker

Posted on • Originally published at tech.serhatteker.com on

Testing created/updated/auto_now fields in Django

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}"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When you run the test you will see same successful result.

All done!

Discussion (2)

Collapse
alexbender profile image
Alex Bender

Hey, looks like markup of the page drifted

Collapse
serhatteker profile image
Serhat Teker Author

Thank you for the info. I fixed it.