Testing Models with Pytest in Django: A practical approach | Testing Django Applications
Tags: testing, django, python, webdev
Published: 2023-11-16 10:52:20

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:

* [**Python**](https://www.python.org/)
    
* [Django](https://docs.djangoproject.com/)
    
* [Pytest](https://docs.pytest.org/)
    

> 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](https://djangostars.com/blog/django-pytest-testing/) 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.

```bash
mkdir testing-models # create project folder
cd testing-models # Change directory
virtualenv venv # Create virtual environment
source ./venv/bin/activate # Activate the virtual environment
```

#### Create a new Django project.

Install Django.

```bash
# Install django
pip install django
```

Create a new project with the following command:

```bash
django-admin startproject core .
```

> 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/](http://localhost:8000/) in the browser, we will see the Django landing page.

Great, now let's create our first application,

```bash
python manage.py startapp myapp
```

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

```bash
python manage.py startapp helpers
```

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

```python
INSTALLED_APPS = [
    # ... rest apps
    "myapp",
    "helpers",
]
```

#### 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` .

```python
# 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')
```

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.

```bash
python manage.py makemgirations

python manage.py migrate
```

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`

```bash
pip install pytest pytest-django model-bakery
```

* `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:

```ini
[pytest]
DJANGO_SETTINGS_MODULE = core.settings
python_files = tests.py test_*.py *_tests.py
```

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:

```python
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)
```

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.

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


class TestModelUserProfile(BaseTestUserProfile, BaseModelTest):
    model = UserProfile
```

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

```python
pytest myapp
```

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

#### Testing further

```python
# 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")
```

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:

```python
# 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")
```

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

```python
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"
```

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.

```python
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"
```

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:

```python
@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
    pass
```

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.

```python
# 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
```

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.

```python
# 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
```

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...

```python
# 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
```

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.

```python
# 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
```

And use it as,

```python
# 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
```

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:

```python
class BaseTestUserProfile:
    model = UserProfile
```

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:

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

    def test_has_max_length(self):
        assert self.field.max_length == self.max_length
```

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.](https://github.com/AnjalBam/testing-models) 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](https://www.linkedin.com/in/iamanjalbam/).
> This blog was originally posted on [my blog](https://blog.anjalbam.com.np)