Search code examples
pythondjangodjango-testingpytest-djangopytest-mock

Testing a django `post_save` signal that includes function calls that occur after db transaction is committed


When django tests are running, database transactions are not committed. How do I test an event triggered by object creation but that happens after the db transaction has been committed?

I have a Campaign model and the below post_save signal. Using Django TestCase it is difficult to assert that the functions within transaction.on_commit are called in the case when a new Campaign object is created. When the signal runs in the test context, it always thinks that an existing campaign object is being edited, not that one has been newly created. Therefore I cannot test the else branch of the if statement.

How could I test the case when Campaign.objects.filter(pk=instance.pk).exists() is False?

Signal:

@receiver(post_save, sender=Campaign, dispatch_uid="apps.writing.signals.create_handwriting")
def create_handwriting(sender, instance, **kwargs):
    """Whenever a campaign is created or updated, trigger the handwriting cloud function to (re)generate the
    handwriting image.
    """

    if Campaign.objects.filter(pk=instance.pk).exists():
        transaction.on_commit(
            lambda: log_campaign_progress(pk=instance.pk, status="t2h-edited", stage="campaign")
        )
        transaction.on_commit(lambda: delete_campaign_pages(campaign_pk=instance.pk))
    else:
        transaction.on_commit(
            lambda: log_campaign_progress(pk=instance.pk, status="t2h-created", stage="campaign")
        )

    transaction.on_commit(lambda: enqueue_handwriting_generation(campaign_pk=instance.pk))

Test:

class TestSignals(TestCase):
    def setUp(self):
        self.factory = RequestFactory()

    @mock.patch("lettergun.apps.writing.signals.log_campaign_progress")
    @mock.patch("lettergun.apps.writing.signals.enqueue_handwriting_generation")
    @mock.patch("lettergun.apps.writing.signals.delete_campaign_pages")
    def test_create_handwriting_edit_existing_campaign(
        self, delete_campaign_pages, enqueue_handwriting_generation, log_campaign_progress
    ):
        # disconnected in the factory so we need to reconnect it here
        signals.post_save.connect(
            sender=Campaign,
            dispatch_uid="apps.writing.signals.create_handwriting",
            receiver=create_handwriting,
        )
        enqueue_handwriting_generation.return_value = True
        log_campaign_progress.return_value = True

        with self.captureOnCommitCallbacks(execute=True) as callbacks:
            user = G(User)
            campaign = G(Campaign, user=user)

        assert Campaign.objects.get(pk=campaign.pk)
        assert Campaign.objects.filter(pk=campaign.pk).exists()

        enqueue_handwriting_generation.assert_called_with(campaign_pk=campaign.pk)
        log_campaign_progress.assert_called_with(pk=campaign.pk, stage="campaign", status="t2h-edited")
        delete_campaign_pages.assert_called_with(campaign_pk=campaign.pk)

Solution

  • django.test.TestCase does not support transactions (there is a performance penalty with committing a database transaction and cleaning after test). As per https://docs.djangoproject.com/en/4.0/topics/testing/tools/#django.test.TestCase

    • Wraps the tests within two nested atomic() blocks: one for the whole class and one for each test. Therefore, if you want to test some specific database transaction behavior, use TransactionTestCase.

    You should use TransactionTestCase https://docs.djangoproject.com/en/4.0/topics/testing/tools/#django.test.TransactionTestCase