Django Signals - Automated Tests
Django Signals (3 Part Series)
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:
- We use the
@pytest.mark.django_db
decorator to ensure that the test has access to the database. - We temporarily disconnect the signal to prevent it from firing when we create the user with django-baker.
- We use
baker.make()
to create a User instance withis_active=False
. - We reconnect the signal.
- We save the user, which triggers the post_save signal.
- We refresh the user from the database to get the updated state.
- 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:
- We first disconnect other signals to isolate our test.
- We connect our new email validation signal.
- We test a valid case by creating a user with a correct email. This should not raise an exception.
- We then test an invalid case using
pytest.raises()
to check if the correct exception is raised with the expected message. - 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
- Isolate Signal Tests: Always disconnect and reconnect signals in your tests to ensure that you’re testing the signal behavior in isolation.
- Use Factory Libraries: Libraries like django-baker make it easy to create model instances for testing without triggering signals.
- Test Both Positive and Negative Cases: Ensure that your signals fire when they should, and don’t fire when they shouldn’t.
- 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.
- 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.