Press enter or click to view image in full size
Has this ever happened to you?
“Oh, I’m trying to test some code in one of my Django models but the post_save signal is performing some unwanted behavior”
Context managers might be able to help! I’ve been developing with Django for 2 years, and as my knowledge of the different features grew so did some pain points in implementing the myriad aspects the framework has to offer. While the Django signals API offers great flexibility in creating your models, it can be tricky to test model behavior in isolation.
To start, we should ask the question “What do signals offer us?” Signals allow an engineer to setup callback functions when a particular event occurs. One example is the Django ORM post_save signal, which is triggered after a Django model is saved. The full offerings of the signals API can be found here: https://docs.djangoproject.com/en/3.2/topics/signals/
The basic process of incorporating signals into your Django project consists of two simple steps: write a callback function that receives a particular message and performs some logic with that event; register the callback function with the Django signal you want to have execute the function on event. For example, consider the case where you might want to track what device a user in your system logged in through. Your models.py would look something like:
class User(AbstractUser):
# User in our system
passclass DeviceLogin(BaseModel):
# Different devices a user could potentially login from
DESKTOP = 'DSK'
PHONE = 'PHN'
TABLET = 'TBL' LOGIN_NAME_CHOICES = [
(DESKTOP , 'Desktop'),
(PHONE , 'Phone'),
(TABLET , 'Tablet'),
] type = models.CharField(
max_length=3,
choices=LOGIN_NAME_CHOICES,
db_index=True,
unique=True
) total_logins = models.IntField()class UserDeviceLogin(BaseModel):
# Map of user to device login count
user = models.ForeignKey(User, on_delete=models.CASCADE)
login = models.ForeignKey(DeviceLogin, on_delete=models.CASCADE)
login_count = models.IntField() class Meta:
unique_together = ('user', 'login ')
When a user is created you would want to create initial records in the UserDeviceLogin table to start with, and then increment the value of the corresponding record each time a user logins. You could create the initial record with Django signals like so:
from django.db.models.signals import post_save
from accounts.models import User, DeviceLogin, UserDeviceLogin
def user_post_save(sender, instance, created, *args, **kwargs):
# Signal to process User.post_save signal
if created:
for login_type in DeviceLogin.objects.all().iterator():
UserDeviceLogin.objects.create(
user=instance,
login=login_type,
login_count=0
)
post_save.connect(
user_post_save,
sender=User,
dispatch_uid='user_post_save_create_user-device-login'
)
To explain: the user_post_save method is setup to fit the signature of a Django signal callback. It accepts a sender, an instance, a boolean value denoting whether or not the record was created, and arbitrary arguments and keyword arguments we don’t do anything with. It assumes (rightly so!) that the instance being passed to it is one of User , and for each record in the DeviceLogin table creates a corresponding UserDeviceLogin record with the count of login occurrences set to 0. This makes it easy to setup mappings of predetermined foreign types to users when a user is created in the database.
Then in your apps.py all you would need to do is register the signals.py
module once the app is ready:
# You might also need to include this in your accounts/__init__.py:
# default_app_config = 'accounts.apps.AccountsConfig'from django.apps import AppConfigclass AccountsConfig(AppConfig):
name = 'accounts'def ready(self):
# Register signals
import accounts.signals
All well and good, right?
“But hold on! There’s a unique_together constraint on the UserDeviceLogin model. We should write a test for this!”
Good job you! Yes, we should be testing the unique_together behavior lest we introduce some snake in the grass that breaks our database integrity down the line. Our test for this would look something like this:
class TestUserDevivceLogin(TestCase):
def test_uniqueness_on_user_device_login(self):
user = User.objects.create(username='test_user')
login_type = DeviceLogin.objects.get(name=DeviceLogin.PHONE)
UserDeviceLogin.objects.create(user=user, login=login_type) with self.assertRaises(IntegrityError):
UserDeviceLogin.objects.create(
user=user,
login=login_type
)
This test case first creates a UserDeviceLogin mapping for a particular user, then tries to create another UserDeviceLogin mapping for the same user/type pair. We expect the test should raise an IntegrityError if we try to perform this action, as the unique_together constraint should prevent this from happening.
I encourage you to figure out why this fails on your own before proceeding.
So it turns out this test case does throw the exception we want, just not where we want it! After we create our test user, the post_save signal is fired for the created User . This would already create a UserDeviceLogin for every DeviceLogin record in the database. So when we go to create the first login device mapping we have already triggered an IntegrityError as EVERY UserDeviceLogin record possible has been created for our test user.
So it seems like we want to temporarily disable the User post save signal for this test case. Unfortunately, there does not seem to be any facility for “turning off” Django signals with a decorator or context manager. Until now!
The gist of it is here: https://gist.github.com/nickdibari/dde5a222983fa3e0aa46145646d9b986
Essentially this provides an interface to disable a particular signal/callback/sender pairing for a context. So if we amended our previous test to look like this:
from django.db.models.signals import post_savefrom accounts.signals import user_post_saveclass TestUserDeviceLogin(TestCase):
def test_uniqueness_on_user_device_login(self):
dispatch_uid = 'user_post_save_create_user-device-login'
with SignalDisconnect(
post_save,
user_post_save,
User,
dispatch_uid
):
# Signal is disabled inside here!
user = User.objects.create(username='test_user') login_type = DeviceLogin.objects.get(name=DeviceLogin.PHONE)
UserDeviceLogin.objects.create(user=user, login=login_type)with self.assertRaises(IntegrityError):
UserDeviceLogin.objects.create(
user=user,
login=login_type
)
Now our test passes! By disabling the post_save signal when we create the user, there aren’t any UserDeviceLogin records for the user, allowing us to create a particular mapping and test our database integrity.
I hope this is useful, I spent a good amount of time banging my head against the wall trying to figure out why my test was unexpectedly failing before I realized the issue was with the signal. Happy developing!