Blog

Ideas and insights from our team

How I test my DRF serializers


How I test my DRF serializers

In this blog post, I will show the whats and whys on testing Django REST Framework serializers. First, some context. Here is the model setup we are going to use for this example:

from django.db import models

class Bike(models.Model):
    COLOR_OPTIONS = (('yellow', 'Yellow'), ('red', 'Red'), ('black', 'Black'))

    color = models.CharField(max_length=255, null=True, blank=True,
                             choices=COLOR_OPTIONS)
    size = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True)

And this is the serializer I'm are going to test:

from rest_framework import serializers
from bikes.models import Bike

class BikeSerializer(serializers.ModelSerializer):
    COLOR_OPTIONS = ('yellow', 'black')

    color = serializers.ChoiceField(choices=COLOR_OPTIONS)
    size = serializers.FloatField(min_value=30.0, max_value=60.0)

    class Meta:
        model = Bike
        fields = ['color', 'size']

I'm going to use the common unittest framework, so here is the setup I normally go with:

def setUp(self):
        self.bike_attributes = {
            'color': 'yellow',
            'size': Decimal('52.12')
        }

        self.serializer_data = {
            'color': 'black',
            'size': 51.23
        }

        self.bike = Bike.objects.create(**self.bike_attributes)
        self.serializer = BikeSerializer(instance=self.bike)

First, notice I have a default set of attributes (self.bike_attributes) that I'll use to initialize a Bike object. The self.serializer_data is also a set of attributes but this time to be used as default data parameters to the serializer when we need them. I always set those as valid values [more on this latter]. The last bit is the self.serializer which is a simple instance of the serializer initialized with the self.bike object. The reason why I'm defining those in the setup and setting them as attributes to the test class is because they will be repeatedly used in the tests so we can skip setting them up every time (and some other reasons I'll be talking about along the post).

Let's get started.

The first test is actually one of the most important. It verifies if the serializer has the exact attributes it is expected to.

    def test_contains_expected_fields(self):
        data = self.serializer.data

        self.assertEqual(set(data.keys()), set(['color', 'size']))

I'm using sets to make sure that the output from the serializer has the exact keys I expect it to. Using a set to make this verification is actually very important because it will guarantee that the addition or removal of any field to the serializer will be noticed by the tests. Verifying the presence of the field using a series of assertIns would pick the removal of a field but not additions.

Update
As highlighted by Aki in the comments, self.assertItemsEqual(data.keys(), ['color', 'size']) can also be used and is more readable than self.assertEqual(set(data.keys()), set(['color', 'size'])). If you are using Python 3 [and you should be], assertItemsEqual is now called assertCountEqual.

Now moving on to check if the serializer produces the expected data to each field. The color field is pretty standard:

    def test_color_field_content(self):
        data = self.serializer.data

        self.assertEqual(data['color'], self.bike_attributes['color'])

Notice I'm using self.bike_attributes['color'] to make the assert. Because those default attributes are set in the setUp and not inside the test it's a good idea to also assert using them, this will allow changes in the global test set up that will not interfere with the tests without compromising the quality of the suit.

Next to the size attribute. This one is a little more tricky because the model is using a DecimalField while the corresponding attribute in the serializer uses a FloatField.

    def test_size_field_content(self):
        data = self.serializer.data

        self.assertEqual(data['size'], float(self.bike_attributes['size']))

CAUTION
Be careful when comparing Decimals and floats:
3.14 == float(Decimal('3.14')) -> True
Decimal(3.14) == Decimal('3.14') -> False

size attribute has both lower and upper bounds so it's very important to test edge cases:

    def test_size_lower_bound(self):
        self.serializer_data['size'] = 29.9

        serializer = BikeSerializer(data=self.serializer_data)

        self.assertFalse(serializer.is_valid())
        self.assertEqual(set(serializer.errors), set(['size']))

Remember I said self.serializer_data should be valid values? That's the point where I'll take advantage of this. Because I know the default data in self.serializer_data is valid I can change only the value of the size to an invalid value. This will guarantee the test will be picking up on the exact behavior it was meant to. The self.assertEqual(set(serializer.errors), set(['size'])) assert also reinforces this.

Upper bound size test goes the same way:

    def test_size_upper_bound(self):
        self.serializer_data['size'] = 60.1

        serializer = BikeSerializer(data=self.serializer_data)

        self.assertFalse(serializer.is_valid())
        self.assertEqual(set(serializer.errors), set(['size']))

Because the size data type changes from model to serializer, it needs to be carefully tested. The conversion from model DecimalField to float was previously tested but inputting a float to the serializer and converting it to a correct Decimal value in the model is not yet covered.

    def test_float_data_correctly_saves_as_decimal(self):
        self.serializer_data['size'] = 31.789

        serializer = BikeSerializer(data=self.serializer_data)
        serializer.is_valid()

        new_bike = serializer.save()
        new_bike.refresh_from_db()

        self.assertEqual(new_bike.size, Decimal('31.79'))

Special attention to new_bike.refresh_from_db(), if you don't do this bike.size will be a float and not a Decimal. Notice I se the self.serializer_data['size'] to 31.789. By doing this I'm also verifying that it is correctly rounded (Decimal('31.79')).

Lastly, because the choices options are different between model and serializer, it's good to check if the serializer is picking on invalid values:

    def test_color_must_be_in_choices(self):
        self.bike_attributes['color'] = 'red'

        serializer = BikeSerializer(instance=self.bike, data=self.bike_attributes)

        self.assertFalse(serializer.is_valid())
        self.assertEqual(set(serializer.errors.keys()), set(['color']))

That's it for now. Tests vary a lot according to the application context, but I hope the cases exposed in this post can give you some insights or ideas that you can adapt to your project. As usual, feedbacks are very welcome, leave them in the comments. I'll be happy to make updates if you can spot things I'm missing.

Looking for more?
cdrf.co lets you explore DRF views and serializers
Python API clients with Tapioca

About Filipe Ximenes

Bike enthusiast, software developer and former director at Python Brasil Association. Likes open source and how people interact in open source communities.

Comments