djangodjango-testingfactory-boy

Return fake dates for every blog post - bypass django's auto now


I am testing whether my blog posts are in reverse chronological order. To do so, I must set random dates for each post created. I'm using faker to set the dates. I am getting back a fake date, but it's the same date for every post. Is auto now still the issue here, or am I not using Faker correctly?

Factory:

fake = Faker()
mocked = fake.date_time()

class BlogPageFactory(wagtail_factories.PageFactory):
    class Meta:
        model = models.BlogPage

    with patch('django.utils.timezone.now', mocked):
        date = mocked
    # date = datetime.date.today()
    author = factory.SubFactory(UserFactory)
    slug = factory.sequence(lambda n: f"post{n}")
    snippet = factory.sequence(lambda n: f"Article {n} snippet...")
    body = "Test post..."
    featured_image = factory.SubFactory(wagtail_factories.ImageFactory)
    featured_article = False

Models:

class BlogPage(Page):
    date = models.DateField("Post date")
    snippet = models.CharField(
        max_length=250, help_text="Excerpt used in article list preview card."
    )
    body = RichTextField(blank=True)
    tags = ClusterTaggableManager(through=BlogPageTag, blank=True)
    featured_image = models.ForeignKey("wagtailimages.Image", on_delete=models.CASCADE)
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
    featured_article = models.BooleanField(default=False)

    content_panels = Page.content_panels + [
        MultiFieldPanel(
            [
                FieldPanel("date"),
                FieldPanel("tags"),
            ],
            heading="Blog Information",
        ),
        FieldPanel("snippet"),
        FieldPanel("featured_image"),
        FieldPanel("body"),
        FieldPanel("author"),
        InlinePanel("page_comments", label="Comments"),
    ]

    search_fields = Page.search_fields + [index.SearchField("body")]

    parent_page_types = ["CategoryIndexPage"]
    subpage_types = []

    def serve(self, request, *args, **kwargs):
        """
        Method override to handle POST conditions for blog comments, ``BlogComment``.
        """
        from .forms import CommentForm

        if request.method == "POST":
            form = CommentForm(request.POST)
            if form.is_valid():
                new_comment = form.save(commit=False)
                new_comment.user = request.user
                new_comment.page = self
                new_comment.save()
                messages.success(
                    request,
                    "Your message was successfully "
                    "submitted and is awaiting moderation. "
                    "Thank you for contributing!",
                )
                return redirect(self.get_url())
        else:
            form = CommentForm

        return render(request, "blog/blog_page.html", {"page": self, "form": form})

Solution

  • There are two issues with your current code, leading to the problem you meet:

    1. You're generating a fixed mocked date at module import;
    2. You're not actually overriding any auto_now with your patch(..., mocked)

    Getting a truly random mocked date

    The issue comes from the way your code is written; you compute the mocked date at module import time — it is thus always the same for all future calls.

    In order to get a dynamic value, you need to use one of factory_boy's dedicated declarations — they are evaluated each time the factory is asked to generate an instance.

    I recommend taking a look at another of my answers for a more in-depth explanation.

    Overriding the date in the model

    Here, your model field declaration is a "standard" Django field, with no magic; there is no need to patch django.utils.timezone.now(), since passing date=mocked to BlogPage.objects.create(...) will work. By the way, this is exactly the call performed by factory_boy under the hood.

    If you had a model with a auto_now_add, Django does not allow overriding the value — as seen in their docs.

    This would leave you with two possible options:

    1. Replace auto_now_add=True with default=timezone.now: when the default is a callable, Django will execute the callable for each new instance of the model, unless the caller provided a value — as you would be doing with the factory;

    2. If that isn't possible, you could instead override the _create() method:

      class BlogPageFactory(factory.django.DjangoModelFactory):
        ...
      
        date = factory.Faker("date_time")
      
        @classmethod
        def _create(cls, model_class, *args, **kwargs):
          # Extract the "date" kwarg, as an explicit value will be ignore
          # by auto_now_add
          date = kwargs.pop("date")
      
          # Force `timezone.now()` to always return the expected date
          # for the duration of the instance creation
          with patch("django.utils.timezone.now", lambda: date):
      
            # Let factory_boy actually create the instance
            return super()._create(model_class, *args, **kwargs)