DEV Community

Cover image for Testing Models with Pytest in Django: A practical approach | Testing Django Applications
AnjalBam
AnjalBam

Posted on

Testing Models with Pytest in Django: A practical approach | Testing Django Applications

Let's get started with the developers' nightmare, Software Testing. The term "testing" in software development refers to the process of verifying and validating if the software is bug-free and meets the requirements specifications as guided by its design and development, and it performs as intended. It involves executing a program with the intent of finding errors, evaluating the results of the program, and identifying any discrepancies between the actual results and the expected results.

There are various types of testing in software engineering:

  • Unit testing: Testing individual units of code, such as functions or classes.

  • Integration testing: Testing how different units of code work together.

  • System testing: Testing the entire software system as a whole.

  • Acceptance testing: Testing the software from the end user's perspective.

  • Performance testing: Measuring the performance of the software under load.

  • Security testing: Testing the software for security vulnerabilities.

For the sake of this blog, we will be focusing on only one aspect, Unit Testing, and even only one module in Django Framework, Django Models. We will focus on why and how we test models in Django.

Prerequisites

Before getting ahead, I will be assuming that you are familiar with the following:

I'll assume you have a basic understanding of how applications are built in Django. If you're new to testing and pytest, please refer to this awesome blog for the basics of testing with pytest in Django.

Why test Models?

While testing models might not seem necessary, we should test them so that all the fields are correctly implemented throughout the Model, this will help to identify and rectify errors early in the development process, improve the design as you will have thought thorough, and make code changes easier in the later phase in your application, which will ultimately build your confidence in your application.

In this comprehensive guide, we will cover testing every corner of a Django model. We will be:

  • Testing models as a whole

  • Testing individual fields, to ensure all constraints are applied correctly

  • Testing different methods in the application

Let's get started right away.

Create a new Django application.

Create a virtual environment

First, let's create our project folder, and then a virtual environment.

mkdir testing-models # create project folder
cd testing-models # Change directory
virtualenv venv # Create virtual environment
source ./venv/bin/activate # Activate the virtual environment
Enter fullscreen mode Exit fullscreen mode

Create a new Django project.

Install Django.

# Install django
pip install django
Enter fullscreen mode Exit fullscreen mode

Create a new project with the following command:

django-admin startproject core .
Enter fullscreen mode Exit fullscreen mode

We are naming our main project configuration folder with core, where all the settings and other server configurations will be.

If we type python manage.py runserver and head over to http://localhost:8000/ in the browser, we will see the Django landing page.

Great, now let's create our first application,

python manage.py startapp myapp
Enter fullscreen mode Exit fullscreen mode

Let's create another app called helpers where we will store our utility classes for testing.

python manage.py startapp helpers
Enter fullscreen mode Exit fullscreen mode

Now, let's register the application in our installed apps.

INSTALLED_APPS = [
    # ... rest apps
    "myapp",
    "helpers",
]
Enter fullscreen mode Exit fullscreen mode

Time for our model.

For our model, the myapp will contain a UserProfile model which will be a One To One relationship with User from django.contrib.auth.models .

# in myapp/models.py
from django.contrib.auth.models import User
from django.db import models


class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    bio = models.TextField(null=True, blank=True)
    profile_picture = models.URLField(null=True, blank=True)
    settings = models.JSONField(default=dict)

    def change_theme_preference(self, theme):
        self.settings['theme'] = theme
        self.save()

    def get_theme_preference(self):
        return self.settings.get('theme', 'auto')
Enter fullscreen mode Exit fullscreen mode

So, the code above in the model is kinda straightforward... We have a model which has four fields:

  • user: which is a One-To-One Relation with the User, such that, an instance of user can have only one instance of UserProfile

  • bio: A store to hold bio information about the user

  • profile_picture: for simplicity, we are using an URLField to hold user profile picture link.

  • settings: A JSONField to hold user preferences, that may be dynamic along the way.

It also has two methods, change_theme_preference to change user theme preference for our application, and get_theme_preference to get the user theme preference.

With our model ready, we are ready for migrations.

python manage.py makemgirations

python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

After successful migration, verify if your application still works, and then we can move on with writing tests.

Let's Get the Testing Started

First, let's install testing dependencies for pytest

pip install pytest pytest-django model-bakery
Enter fullscreen mode Exit fullscreen mode
  • pytest: Our Pytest framework

  • pytest-django: Django plugin for pytest

  • model-bakery: It is a utility framework that will help us create objects automatically without having to care about data to fill up in the database.

To configure our testing environment for pytest, create a pytest.ini file in the project root and paste the following content:

[pytest]
DJANGO_SETTINGS_MODULE = core.settings
python_files = tests.py test_*.py *_tests.py
Enter fullscreen mode Exit fullscreen mode

It will tell us where to look for the Django settings and where to find our tests.

While writing tests, we will try to follow best practices and make our code as reusable as possible. So we will be making small utility classes that can be reused across the application for similar tasks. And, as previously mentioned, we will put these classes inside of the helpers application in our Django project.

Let's prepare for our first test:

from typing import Any

import pytest
from django.db import models
from model_bakery import baker


class BaseModelTest:
    model: models.Model = None

    instance_kwargs: dict[str, Any] = {}

    @pytest.fixture
    def instance(self) -> models.Model:
        return baker.make(self.model, **self.instance_kwargs)

    def test_issubclass_model(self) -> None:
        assert issubclass(self.model, models.Model)
Enter fullscreen mode Exit fullscreen mode

The BaseModelTest in the above snippet contains the following attributes:

  • model: It will be our class reference, which is a model

  • instance_kwargs: A dictionary that will contain key-value to be put in the database columns.

  • instance: A method, but a Pytest fixture, that will be accessible throughout the test class.

  • test_issubclass_model: A test that will make sure that our model classes will be inherited from models.Model. Further, it can be modified to make sure the inheritance of class from some other custom base model as well.

Since, BaseModelTest is ready, we can write the first test for our model.

# In myapp/tests.py
from helpers.tests import BaseModelTest
from myapp.models import UserProfile


class TestModelUserProfile(BaseTestUserProfile, BaseModelTest):
    model = UserProfile
Enter fullscreen mode Exit fullscreen mode

This is it for our very first test. Now, if we run:

pytest myapp
Enter fullscreen mode Exit fullscreen mode

Woah... We must see our first test passing.

Testing further

# In myapp/tests.py
class TestModelUserProfile(BaseTestUserProfile, BaseModelTest):
    model = UserProfile

    def test_has_all_attributes(self, instance):
        assert hasattr(instance, 'user')
        assert hasattr(instance, 'bio')
        assert hasattr(instance, 'profile_picture')
        assert hasattr(instance, 'settings')

        assert hasattr(instance, "change_theme_preference")
        assert hasattr(instance, "get_theme_preference")
Enter fullscreen mode Exit fullscreen mode

Here we added a method test_has_all_attributes which is a test, that verifies if the instance has all the attributes.

Now if we run the test as before, we will have to add a django_db mark to the function as follows:

# In myapp/tests.py
import pytest

# Rest of the code

class TestModelUserProfile(BaseTestUserProfile, BaseModelTest):
    model = UserProfile

    @pytest.mark.django_db
    def test_has_all_attributes(self, instance):
        assert hasattr(instance, 'user')
        assert hasattr(instance, 'bio')
        assert hasattr(instance, 'profile_picture')
        assert hasattr(instance, 'settings')

        assert hasattr(instance, "change_theme_preference")
        assert hasattr(instance, "get_theme_preference")
Enter fullscreen mode Exit fullscreen mode

This mark will allow our method to access the database. The pytest-django provides several marks for database access like transactional_db, django_db_reset_sequences, etc.

Now, if we run the test with pytest . we must see two tests passing.

Test our methods now

class TestModelUserProfile(BaseTestUserProfile, BaseModelTest):
    # Rest of the code here...

    @pytest.mark.django_db
    def test_change_theme_preference(self, instance):
        instance.change_theme_preference("dark")
        assert instance.settings["theme"] == "dark"
Enter fullscreen mode Exit fullscreen mode

This will test our change_theme_preference method. and actually validates that the passed value is stored in the database instance.

Time for another method now.

class TestModelUserProfile(BaseTestUserProfile, BaseModelTest):
    # Rest of the code here...

    @pytest.mark.django_db
    def test_get_theme_preference_should_get_auto_if_not_set(self, instance):
        assert instance.get_theme_preference() == "auto"

    @pytest.mark.django_db
    def test_get_theme_preference_set_in_db(self, instance):
        # Set the theme
        instance.settings["theme"] = "dark"
        instance.save()

        # Assert the value
        assert instance.get_theme_preference() == "dark"
Enter fullscreen mode Exit fullscreen mode

This method had two cases where it should return "auto" if there was no value set in the database, or return whatever value is set in the database. And these two tests will validate that thing for us.

Phew... That's a lot!

This will test our model if it has all the attributes and if all the methods are working as intended. But there's something still left. If you remember, we discussed that testing our models means verifying if all the constraints are also applied correctly in the database. We'll handle that in the next section.

Here, we have an issue... Not an issue though, but we have to repeat the @pytest.mark.django_db every time we write the test. To solve, this, we create a file conftest.py at the project root, which will hold the following:

@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
    pass
Enter fullscreen mode Exit fullscreen mode

This will allow us to write tests without us having to worry about using that mark earlier.

Try to run the tests without the mark, they should run as expected and we are ready to move on.

Let's Test our Fields

While testing our fields, we must consider the part that those fields actually are tested by Django itself such that if provided the correct configurations in those, they will work as they are intended. For example, if we provide db_index=True to a field, it will create a database index for sure. We do not need to verify if the database index is created but rather we verify if the db_index=True is passed to the field or not.

Just like that... Let's start testing the Django model fields now.

Create a Utility Base Field Testing Class

As before, for the model, we will create a base field testing class for fields, we will create all tests in this class that are common to all the fields, such that we do not have to rewrite the same tests for every single one of them.

# In helpers/tests.py
# imports...
DATETIME_FIELDS = (models.DateTimeField, models.DateField, models.TimeField)

# Rest of the code...
class BaseModelFieldTest:
    model: models.Model = None
    field_name: str = None
    field_type: models.Field = None

    null: bool = False
    blank: bool = False
    default: Any = models.fields.NOT_PROVIDED
    unique: bool = False
    db_index: bool = False
    auto_now: bool = False
    auto_now_add: bool = False

    @property
    def field(self):
        return self.model._meta.get_field(self.field_name)

    def test_field_type(self):
        assert isinstance(self.field, self.field_type)

    def test_is_null(self):
        assert self.field.null == self.null

    def test_is_unique(self):
        assert self.field.unique == self.unique

    def test_is_indexed(self):
        assert self.field.db_index == self.db_index

    def test_is_blank(self):
        assert self.field.blank == self.blank

    def test_default_value(self):
        assert self.field.default == self.default

    def test_auto_now(self):
        if self.field.__class__ not in DATETIME_FIELDS:
            pytest.skip(f"{self.model.__name__}->{self.field_name} is not a date/time model type.")

        assert self.field.auto_now == self.auto_now

    def test_auto_now_add(self):
        if self.field.__class__ not in DATETIME_FIELDS:
            pytest.skip(f"{self.model.__name__}->{self.field_name} is not a date/time model type.")

        assert self.field.auto_now_add == self.auto_now_add
Enter fullscreen mode Exit fullscreen mode

The above base class contains most of the common attributes shared by the fields in Django and verifies if the provided field will have the correct configurations. The attributes that are passed to some fields like models.CharField(unique=True, db_index=True) will be verified with this class.

Note that we have skipped the tests test_auto_now and test_auto_now_add, for fields other than the Datetime fields, as they are specific to those fields only.

And also how the values in those attributes are set to default values found in Django

In a minute, we will see how powerful this will be and how easy it will be for us to write tests for the fields.

Tests for our fields

To Test Our Field we create the class as follows.

# In myapp/tests.py
from django.db import models
from helpers.tests import BaseModelTest, BaseModelFieldTest

# Rest of the code...


class TestFieldUserProfileBio(BaseModelFieldTest):
    field_name = "bio"
    field_type = models.TextField
    model = UserProfile

    null = True
    blank = True
Enter fullscreen mode Exit fullscreen mode

Now, if we run the tests, we must see 11 tests passed and 2 skipped.

JUST LIKE THAT!! We tested a field, by just passing the configurations we passed in the field above.

Now, Time to test other fields...

# Other imports
from django.contrib.auth.models import User

# Other Code

class TestFieldUserProfileProfilePicture(BaseModelFieldTest):
    field_name = "profile_picture"
    field_type = models.URLField
    model = UserProfile

    null = True
    blank = True


class TestFieldUserProfileSettings(BaseModelFieldTest):
    field_name = "settings"
    field_type = models.JSONField
    model = UserProfile

    default = dict


class TestFieldUserProfileUser(BaseModelFieldTest):
    field_name = "user"
    field_type = models.OneToOneField
    model = UserProfile

    db_index = True
    unique = True

    def test_has_correct_related(self):
        assert self.field.related_model == User
Enter fullscreen mode Exit fullscreen mode

The test for fields profile_picture and settings are pretty straightforward as we set the configurations we pass to the field in the model. In the user field, we have passed the configurations but we added a test that verifies the correct related field in the model as shown in the test above. We can also abstract it into another base class for RelatedFields as follows which can be used for Foreign Keys and Other relations as well.

# IN helpers/tests.py
# ...other code 

class BaseTestFieldRelated(BaseModelFieldTest):
    related_model = None

    def test_has_correct_related_model(self):
        assert self.field.related_model == self.related_model
Enter fullscreen mode Exit fullscreen mode

And use it as,

# In myapp/tests.py
# ...other code

class TestFieldUserProfileUser(BaseTestFieldRelated):
    field_name = "user"
    field_type = models.OneToOneField
    model = UserProfile

    db_index = True
    unique = True
    related_model = User
Enter fullscreen mode Exit fullscreen mode

Now, If we run the tests, it shall pass all the tests.

What next?

We can notice that some of the attributes like model = UserProfile are repeated in every class. We can make an abstract class like follows:

class BaseTestUserProfile:
    model = UserProfile
Enter fullscreen mode Exit fullscreen mode

And inherit this class in every test class we create.

Another thing we can do when we are writing tests for whole application, The most common fields like CharField with max_length=255 become very much repeated, we can create another base class for CharField only like:

class BaseModelCharFieldTest(BaseModelFieldTest):
    field_type = models.CharField
    max_length = 255

    def test_has_max_length(self):
        assert self.field.max_length == self.max_length
Enter fullscreen mode Exit fullscreen mode

This will reduce a lot of redundant code as well.

Conclusion

In this article, we completely tested a Django model, along with its fields and methods as well. We created a reusable framework to test models, that can be reused in every model.

With this, we can conclude how and why a model should be tested. You can find the full source code on GitHub. I hope it helped and I will be posting about unit testing different parts of Django soon. Stay connected! Stay Safe!

Connect with me on LinkedIn.

This blog was originally posted on my blog

Top comments (0)