Django signals are a powerful feature that allow decoupled applications to get notified when certain actions occur. However, testing signals can be tricky. In this article, we’ll focus on how to write tests for Django signals, particularly the post_save signal, using pytest, pytest-django, and model-bakery.

Prerequisites

Before we begin, make sure you have the following packages installed:

django
pytest
pytest-django
model-bakery

You can install these using pip:

$ pip install django pytest pytest-django model-bakery

Setting Up the Test Environment

First, let’s set up a simple Django model and a signal handler:

# project/users/models.py
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver


class User(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()
    is_active = models.BooleanField(default=False)


@receiver(post_save, sender=User)
def activate_user(sender, instance, created, **kwargs):
    if created and not instance.is_active:
        instance.is_active = True
        instance.save()

Writing Tests for post_save Signal

Now, let’s write tests for our post_save signal using pytest and pytest-django:

# tests/integration/users/test_signals.py
import pytest
from django.db.models.signals import post_save
from model_bakery import baker

from project.users.models import User


@pytest.mark.django_db
def test_user_activation_signal():
    # Disconnect the signal temporarily
    post_save.disconnect(receiver=activate_user, sender=User)

    # Create a user using django-baker
    user = baker.make(User, is_active=False)
    assert not user.is_active

    # Reconnect the signal
    post_save.connect(receiver=activate_user, sender=User)

    # Save the user to trigger the signal
    user.save()

    # Refresh the user from the database
    user.refresh_from_db()

    # Check if the user is now active
    assert user.is_active

Let’s break down this test:

  1. We use the @pytest.mark.django_db decorator to ensure that the test has access to the database.
  2. We temporarily disconnect the signal to prevent it from firing when we create the user with django-baker.
  3. We use baker.make() to create a User instance with is_active=False.
  4. We reconnect the signal.
  5. We save the user, which triggers the post_save signal.
  6. We refresh the user from the database to get the updated state.
  7. Finally, we assert that the user is now active.

Testing Signal Side Effects

Sometimes, signals might have side effects beyond just modifying the instance. Let’s modify our signal to demonstrate this:

# project/users/models.py
class UserLog(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    action = models.CharField(max_length=100)


@receiver(post_save, sender=User)
def log_user_activation(sender, instance, created, **kwargs):
    if created and instance.is_active:
        UserLog.objects.create(user=instance, action="User activated")

Now, let’s test this new behavior:

# tests/integration/users/test_signals.py
@pytest.mark.django_db
def test_user_activation_logging():
    post_save.disconnect(receiver=log_user_activation, sender=User)

    user = baker.make(User, is_active=False)
    assert UserLog.objects.count() == 0

    post_save.connect(receiver=log_user_activation, sender=User)

    user.is_active = True
    user.save()

    assert UserLog.objects.count() == 1
    log = UserLog.objects.first()
    assert log.user == user
    assert log.action == "User activated"

Testing Exceptions in Signals

Sometimes, signals might raise exceptions, and it’s important to test these scenarios. Let’s modify our signal to demonstrate this:

# project/users/models.py
from django.core.exceptions import ValidationError


@receiver(post_save, sender=User)
def validate_user_email(sender, instance, created, **kwargs):
    if created and not instance.email.endswith('@example.com'):
        raise ValidationError("User email must end with @example.com")

Now, let’s write a test for this exception:

# tests/integration/users/test_signals.py
import pytest
from django.core.exceptions import ValidationError
from django.db.models.signals import post_save
from model_bakery import baker

from project.users.models import User, validate_user_email


@pytest.mark.django_db
def test_user_email_validation_signal():
    # Disconnect other signals to isolate this test
    post_save.disconnect(receiver=activate_user, sender=User)
    post_save.disconnect(receiver=log_user_activation, sender=User)

    # Connect our email validation signal
    post_save.connect(receiver=validate_user_email, sender=User)

    # Test valid email
    user_valid = baker.make(User, email='[email protected]')
    assert user_valid.pk is not None

    # Test invalid email
    with pytest.raises(ValidationError) as excinfo:
        baker.make(User, email='[email protected]')
    
    assert "User email must end with @example.com" in str(excinfo.value)

    # Clean up: disconnect the signal
    post_save.disconnect(receiver=validate_user_email, sender=User)

Explanation:

  1. We first disconnect other signals to isolate our test.
  2. We connect our new email validation signal.
  3. We test a valid case by creating a user with a correct email. This should not raise an exception.
  4. We then test an invalid case using pytest.raises() to check if the correct exception is raised with the expected message.
  5. Finally, we disconnect the signal to clean up after our test.

Detailed Side Effect Testing

Let’s expand on our previous side effect test to cover more scenarios:

# project/users/models.py
class UserLog(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    action = models.CharField(max_length=100)
    timestamp = models.DateTimeField(auto_now_add=True)


@receiver(post_save, sender=User)
def log_user_changes(sender, instance, created, **kwargs):
    if created:
        UserLog.objects.create(user=instance, action="User created")
    else:
        UserLog.objects.create(user=instance, action="User updated")
# tests/integration/users/test_signals.py
from django.utils import timezone
import pytest
from django.db.models.signals import post_save
from model_bakery import baker

from project.users.models import User, UserLog, log_user_changes


@pytest.mark.django_db
def test_user_logging_signal():
    # Disconnect other signals
    post_save.disconnect(receiver=activate_user, sender=User)
    post_save.disconnect(receiver=validate_user_email, sender=User)

    # Connect our logging signal
    post_save.connect(receiver=log_user_changes, sender=User)

    # Test user creation logging
    user = baker.make(User)
    assert UserLog.objects.count() == 1

    log = UserLog.objects.first()
    assert log.user == user
    assert log.action == "User created"
    creation_time = log.timestamp

    # Wait a moment to ensure different timestamp to manipulate time
    # However that's a bad idea, use freezegun or time-machine library
    # - https://github.com/spulec/freezegun
    # - https://github.com/adamchainz/time-machine
    import time
    time.sleep(0.1)

    # Test user update logging
    user.name = "Updated Name"
    user.save()

    assert UserLog.objects.count() == 2

    log = UserLog.objects.latest("timestamp")
    assert log.user == user
    assert log.action == "User updated"
    assert log.timestamp > creation_time

    # Clean up: disconnect the signal
    post_save.disconnect(receiver=log_user_changes, sender=User)

Best Practices for Testing Signals

  1. Isolate Signal Tests: Always disconnect and reconnect signals in your tests to ensure that you’re testing the signal behavior in isolation.
  2. Use Factory Libraries: Libraries like django-baker make it easy to create model instances for testing without triggering signals.
  3. Test Both Positive and Negative Cases: Ensure that your signals fire when they should, and don’t fire when they shouldn’t.
  4. Refresh from Database: Always refresh your model instances from the database after triggering signals to ensure you’re working with the most up-to-date data.
  5. Test Side Effects: If your signals create, update, or delete other objects, make sure to test these side effects.

Conclusion

Testing Django signals, especially post_save signals, can be challenging, but with the right tools and techniques, it becomes manageable. By using pytest, pytest-django, and model-bakery, you can write comprehensive tests that ensure your signals are working correctly. Remember to isolate your signal tests, use factories for creating test data, and always test both the direct effects and any side effects of your signals.