Using Pytest Fixtures in Django

As your projects expand, there are many code boilerplate, maintainability, and duplication issues with the test framework that Python and Django provide. It’s also not a particularly pythonic method of writing tests. Pytest makes writing tests more effortless and more beautiful.

It allows you to define tests as functions, eliminating a lot of boilerplate code and making your code more legible and maintainable. Pytest also has test discovery capabilities and the ability to define and use fixtures.

Why should you use Pytest Fixtures?

When writing tests, it’s relatively typical for the test to require objects, which numerous tests may require. The development of these things could be a time-consuming operation. It will be tough to include that intricate process in each test case, and we will need to update our logic everywhere if the model changes. In most cases, it will result in code duplication and maintainability difficulties.

To avoid this, we may use the pytest fixture, which allows us to declare the fixture in one place and then inject it into any of the tests in a much simpler manner. In a nutshell, if we need to understand fixtures, they are the locations where we prepare everything for our test. They’re everything the test requires to function correctly.

In this article, we’ll look at how we can utilize fixtures to construct Django models that are more legible and maintainable. Not to be confused with Django fixtures, these are the fixtures offered by pytest.

Installation and Configuration

This article will put up a simple inventory application with the pytest test suite.

Making a Django App

Let’s start by building a simple inventory application and adding a few models to run tests later. To make a Django app, navigate to the folder you wish to work in, open the console, and type the commands below:

tuts@Codeunderscored:~$ django-admin startproject inventory_app
tuts@Codeunderscored:~$ cd inventory-app
tuts@Codeunderscored:~$ python manage.py startapp INVProduct

After you’ve finished creating the app, go to settings.py and add it to the INSTALLED_APPS list.

# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'INVProduct'
]

Now, under the INVProduct app’s models.py, let’s make some basic models.

from django.db import models


class INVRetail(models.Model):
  name = models.CharField(max_length=128)
  
class INVCategory(models.Model):
  name = models.CharField(max_length=128, unique=True)
  
class INVProduct(models.Model):
  name = models.CharField(max_length=50)
  description = models.TextField(default="", blank=True)
  sku = models.CharField(max_length=50, unique=True) # unique model number
  mrp = models.DecimalField(max_digits=10, decimal_places=2)
  weight = models.DecimalField(max_digits=10, decimal_places=2)
  retails = models.ManyToManyField(Retail,related_name="inv_products",verbose_name="Inventory retail stores that carry the product",)
  category = models.ForeignKey(Category,related_name="inv_products",on_delete=models.CASCADE,blank=True,null=True,)
  date_created = models.DateTimeField(auto_now_add=True)
  date_modified = models.DateTimeField(auto_now=True)

Each inventory product will have its category and will be offered in various retail outlets. Let’s execute the migration file and see what happens:

tuts@Codeunderscored:~$ python manage.py makemigrations
tuts@Codeunderscored:~$ python manage.py migrate

Now that the models and database are complete, we can begin developing test cases. First, we’ll set up the pytest in our Django app.

We’ll utilize the pytest-django plugin to test our Django applications using pytest. This plugin provides a set of handy tools for testing Django apps and projects. Let’s start with the plugin’s installation and configuration.

pytest installation

pip is used to install Pytest:

tuts@Codeunderscored:~$ pip install pytest-django

When you install pytest-django, it will also install the most recent version of pytest. We must inform pytest-django where our settings.py file is stored once installed. The simplest method is to include this information in a pytest configuration file. Add the following content to a file called pytest.ini in your project directory:

[pytest]

DJANGO_SETTINGS_MODULE=inventory_app.settings

You can describe how our tests should run using various configurations in the file. For example, we can add the following line to configure how test files are discovered across projects:

[pytest]
DJANGO_SETTINGS_MODULE=inventory_app.settings
python_files = tests.py test_*.py *_tests.py

Adding a Test Suite to a Django Application

Django and pytest will automatically find and run test cases in files containing the word ‘test’ in the name. Create a new module called tests in the INVProduct app folder. Then create a file named test_models.py in which we’ll write all of the app’s model test cases.

tuts@Codeunderscored:~$ cd INVProduct
tuts@Codeunderscored:~$ mkdir tests
tuts@Codeunderscored:~$ cd tests && touch test_models.py

Executing the Test Suite

The pytest command is used to run tests directly:

tuts@Codeunderscored:~$ pytest
tuts@Codeunderscored:~$ pytest tests # test a directory
tuts@Codeunderscored:~$ pytest test.py # test file

For the time being, we’ve set up pytest and Django and are ready to write our first test.

How to write tests with Pytest

Pytest is a tool for creating tests. We’ll write a few test cases here to put the models we wrote in the models.py file to the test. First, let’s develop a primary test case to demonstrate how to create a category.

from product.models import Category

def test_create_category():
  inv_cat = INVCategory.objects.create(name="Laptop")
  assert inv_cat.name == "Laptop"

Now try running the following command from your command prompt:

tuts@Codeunderscored:~$ pytest

The experiments were unsuccessful. If you look at the error, you’ll notice that it has to do with the database. According to the documentation for pytest-django, when it comes to database access, pytest-django takes a cautious approach. If your tests try to access the database by default, they will fail. It will only be permitted if you specifically request database access.

It pushes you to keep database-required tests to a bare minimum, making it very clear which code makes use of the database. The latter means that our test cases must have direct database access. To do so, utilize pytest marks to inform pytest-django that your test requires database access.

from INVProduct.models import INVCategory

@pytest.mark.django_db
def test_create_category():
  inv_cat = INVCategory.objects.create(name="Laptop")
  assert inv_cat.name == "Laptop"

Alternatively, we can use the db helper fixture provided by the pytest-django package to access the database in the test cases. The Django database will be built up using this fixture. It’s only required for fixtures who want to access the database.

from INVProduct.models import INVCategory

def test_create_category(db):
  inv_cat = INVCategory.objects.create(name="Laptop")
  assert inv_cat.name == "Laptop"

We’ll adopt the db fixture technique in the future because it encourages code reuse through the usage of fixtures. Then, repeat the test as follows:

tuts@Codeunderscored:~$ pytest

The operation is executed correctly, and your test resulted in the test passing. Great! Using pytest, we have successfully written our first test case.

Creating Django Model Fixtures

Let’s build a test case to see if the to-check category updates now that you’re comfortable with Django and pytest.

from INVProduct.models import INVCategory

def test_filter_category(db):
  INVCategory.objects.create(name="Laptop")
  assert INVCategory.objects.filter(name="Laptop").exists()
  
def test_update_category(db):
  inv_cat = INVCategory.objects.create(name="Laptop")
  inv_cat.name = "HP"
  inv_cat.save()
  category_from_db = INVCategory.objects.get(name="HP")
  assert category_from_db.name == "HP"

When you look at both test cases, you’ll notice that neither of them tests INVCategory creation logic, and the INVCategory object is also created twice, once for each test case. We may have many test cases that require the INVCategory instance as the project grows in size. If each test creates its INVCategory, any modifications to the INVCategory model could cause problems.

Fixtures come to the rescue in this situation. It encourages you to reuse code in your test cases. You can establish a test fixture to reuse an object across multiple test cases:

import pytest
from INVProduct.models import INVCategory

@pytest.fixture
def inv_category(db) -> INVCategory:
  return INVCategory.objects.create(name="Laptop")

def test_filter_inv_category(category):
  assert INVCategory.objects.filter(name="Laptop").exists()
  
def test_update_inv_category(category):
  inv_cat.name = "HP"
  inv_cat.save()
  category_from_db = INVCategory.objects.get(name="HP")
  assert category_from_db.name == "HP"

We’ve developed a simple function named inv_category and marked it as a fixture with the @pytest.fixture decorator. It may now be inserted into test cases the same way as the fixture db was.

We no longer need to go to each test case and update the INVCategory to generate logic. If a new demand comes in, every INVCategory should have a description and a small icon to represent the INVCategory. We need to change the fixture at one location and implement it in each test scenario.

import pytest
from INVProduct.models import INVCategory

@pytest.fixture
def inv_category(db) -> INVCategory:
  return INVCategory.objects.create(name="Laptop", description="Type of Laptop", icon="laptop.png")


You may eliminate code duplication and make tests more maintainable by using fixtures.

Appropriately sized Fixtures

It’s best to have a single fixture function used with a variety of input values. The last-mentioned is done using parameterized pytest fixtures. Let’s write the product’s fixture, keeping in mind that we’ll need to generate an SKU product number with six characters and only alphanumeric characters.

import pytest
from INVProduct.models import INVCategory, INVProduct

@pytest.fixture
def inv_product(db):
  return Product.objects.create(name="HP Laptop", sku="HP9871")


def test_inv_product_sku(inv_product):
  assert all(letter.isalnum() for letter in inv_product.sku)
  assert len(inv_product.sku) == 6

We now want to test the case against numerous sku scenarios to ensure validity for all input types. Three different inv_product fixture instances can be created by flagging the fixture. The unique request object provides access to each parameter to the fixture function:

import pytest
from INVProduct.models import INVProduct

@pytest.fixture(params=("HP9871", "123456", "ABCDEF"))
def inv_product(db,request):
	return INVProduct.objects.create(name="HP Laptop",sku=request.param)


def test_inv_product_sku(inv_product):
  assert all(letter.isalnum() for letter in inv_product.sku)
  assert len(inv_product.sku) == 6

Fixture functions can be parameterized, in which case they will be called several times, each time conducting a set of dependent tests or tests that are dependent on this fixture. Test functions don’t usually need to be aware that they’re re-run. Fixture parametrization aids in the creation of comprehensive functional tests for components with many configuration options. Run the following command in the terminal:

tuts@Codeunderscored:~$ pytest

Our test inv_product sku function is called three times.

How are Fixtures injected into other fixtures?

Typically, we encounter a scenario requiring an object for a case reliant on another object. Let’s try making a couple of products in the “Laptop” category.

import pytest

from INVProduct.models import INVCategory, INVProduct

@pytest.fixture
def inv_product_1(db):
  inv_category = INVCategory.objects.create(name="Laptop")
  return INVProduct.objects.create(name="HP Laptop", category=inv_category)

@pytest.fixture
def inv_product_2(db):
  inv_category = INVCategory.objects.create(name="Laptop")
  return INVProduct.objects.create(name="Dell Laptop", category=inv_category)

def test_two_different_products_create(inv_product_1, inv_product_2):
  assert inv_product_1.pk != inv_product_2.pk

Attempting to test this in the terminal leads to an error:

pytest

According to the test case, we tried creating the “Laptop” category twice, which throws an IntegrityError. If you look at the code, we’ve generated the INVCategory in both inv_product_1 and inv_product_2 fixtures.

What could have been done differently?

If you look closely, you’ll notice that the database has been injected into both the product_one and product_two fixtures and that db is merely another fixture. As a result, fixtures can be injected into one another.

The incredibly versatile fixture system is one of pytest’s most robust features. It enables us to break down extensive test requirements into more simple and ordered functions, requiring simply that each one define the items on which they rely.

You can utilize this functionality to fix the IntegrityError mentioned before. Create the INVCategory fixture and incorporate it into both the product and category fixtures.

import pytest

from INVProduct.models import INVCategory, INVProduct

@pytest.fixture
def inv_category(db) -> INVCategory:
  return INVCategory.objects.create(name="Laptop")

@pytest.fixture
def inv_product_1(db, inv_category):
  return INVProduct.objects.create(name="HP Laptop", category=inv_category)

@pytest.fixture
def inv_product_2(db, inv_category):
  return INVProduct.objects.create(name="Dell Laptop", category=inv_category)

def test_two_different_products_create(inv_product_1, inv_product_2):
  assert inv_product_1.pk != inv_product_2.pk

If we try to run the test right now, it should pass.

pytest

We made code easier to maintain by reorganizing the fixtures in this manner. We can easily maintain many sophisticated model fixtures by simply injecting fixtures. Let’s imagine we need to include an example where the retail shop “QPR” sells inv_product_1 and inv_produc_2. Injecting retailer fixtures into the product fixture is a simple way to accomplish this.

import pytest

from INVProduct.models import INVCategory, INVRetail, INVProduct

@pytest.fixture
def inv_category(db) -> INVCategory:
  return INVCategory.objects.create(name="Laptop")

@pytest.fixture
def inv_retailer_qpr(db):
  return INVRetail.objects.create(name="QPR")

@pytest.fixture
def inv_product_1(db, inv_category, inv_retailer_qpr):
  inv_product = INVProduct.objects.create(name="HP Laptop", category=inv_category)
  inv_product.retails.add(inv_retailer_qpr)
  return inv_product

def test_inv_product_retailer(db, inv_retailer_qpr, inv_product_1):
  assert inv_product_1.retails.filter(name=inv_retailer_qpr.name).exists()

Fixtures for Autouse

You might wish to have a fixture or several that you know will be used in all of your tests. “Autouse” fixtures are a practical approach to all tests automatically requesting them. It can eliminate a lot of unnecessary requests while also allowing for more advanced fixture usage.

By supplying autouse=True to the fixture’s decorator, we may turn it into an autouse fixture. Here’s an example of how they are put to use:

import pytest

from INVProduct.models import INVCategory, INVProduct, INVRetail

…

@pytest.fixture
def inv_retailer_qpr(db):
  return INVRetail.objects.create(name="QPR")

@pytest.fixture
def inv_retailers(db) -> list:
  return []

@pytest.fixture(autouse=True)
def append_inv_retailers(inv_retailers, inv_retailer_qpr):
  return retailers.append(inv_retailer_qpr)

@pytest.fixture
def inv_product_1(db, inv_category, inv_retailers):
  inv_product = INVProduct.objects.create(name="HP Laptop", category=inv_category)
  inv_product.retails.set(inv_retailers)
  return product

def test_product_inv_retailer(db, inv_retailer_qpr, inv_product_1):
  assert inv_product_1.retails.filter(name=inv_retailer_qpr.name).exists()

The inv_append_retailers fixture is an autouse fixture in this example. Even if the test did not request it, the test_product_inv_retailer is affected because it happens automatically. That isn’t to say they can’t be asked; it just means it’s not essential.

Fixtures as Factories

So far, we’ve just generated objects with a few arguments. On the other hand, practical models are a little more complicated and may require more inputs. If we keep track of the sku, mrp, and weight of a product and the name and category.

If we opt to give the product fixture every input, the logic inside the product fixture will become a little more sophisticated.

import random
import string
import pytest

from INVProduct.models import INVCategory, INVProduct, INVRetail

@pytest.fixture
def inv_category(db) -> INVCategory:
  return INVCategory.objects.create(name="Laptop")

@pytest.fixture
def inv_retailer_qpr(db):
  return INVRetail.objects.create(name="QPR")

@pytest.fixture
def inv_product_1(db, inv_category, inv_retailer_qpr):
  inv_sku = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
  inv_product = INVProduct.objects.create(
  sku=inv_sku,
  name="HP Laptop",
  description="A Laptop worth your investment.",
  mrp="650.00",
  is_available=True,
  category=inv_category,
  )
  inv_product.retails.set([inv_retailer_qpr])
  return inv_product

@pytest.fixture
def inv_product_1(db, inv_category, inv_retailer):
  inv_sku = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
  inv_product = INVProduct.objects.create(
  sku=inv_sku,
  name="Dell Laptop",
  description="A Laptop with generational difference.",
  mrp="250.00",
  is_available=True,
  category=inv_category,
  )
  inv_product.retails.add([inv_retailer])
  return inv_product

Product generation involves a relatively complex logic of managing merchants and generating unique SKU. And when we add more needs, the product generation logic will expand. Some extra reasoning is needed if we consider discounts and coupon code complexity for every retailer. There could be a lot of different versions of the product instance to test against, and you’ve already discovered how tough it is to maintain such a complex code.

When the same class instance is required for multiple tests, the “factory as fixture” pattern can be helpful. Instead of returning an instance, the fixture will return a function, and you can get the distance you intended to test by executing that function.

import random
import string
import pytest

from INVProduct.models import INVCategory, INVProduct, INVRetail

@pytest.fixture
def inv_category(db) -> INVCategory:
    return INVCategory.objects.create(name="Laptop")

@pytest.fixture
def inv_retailer_abc(db):
    return INVRetail.objects.create(name="QPR")

@pytest.fixture
def inv_product_factory(db, inv_category, inv_retailer_qpr):
    def create_inv_product(
        name, description="The Laptop", mrp=None, is_available=True, retailers=None
    ):
        if retailers is None:
            retailers = []
        inv_sku = "".join(random.choices(string.ascii_uppercase +
										str@pytest.fixture
def inv_product_1(inv_product_factory):
return inv_product_factory(name="HP Laptop", mrp="650.25")

@pytest.fixture
def inv_product_2(inv_product_factory):
return inv_product_factory(name="Dell Laptop", mrp="251")

def test_inv_product_retailer(db, inv_retailer_qpr, inv_product_1):
assert inv_product_1.retails.filter(name=inv_retailer_qpr.name).exists()

def test_inv_product_2(inv_product_1):
assert inv_product_1.name == "IBM Laptop"
assert inv_product_1.is_availableing.digits, k=6))
        inv_product = INVProduct.objects.create(
            sku=inv_sku,
            name=name,
            description=description,
            mrp=mrp,
            is_available=is_available,
            category=inv_category,
        )
        inv_product.retails.add(inv_retailer_qpr)
        if retailers:
            inv_product.retails.set(retailers)
        return product

    return create_inv_product

@pytest.fixture
def inv_product_1(inv_product_factory):
    return inv_product_factory(name="Hp Laptop", mrp="650.25")

@pytest.fixture
def inv_product_2(inv_product_factory):
    return inv_product_factory(name="Dell Laptop", mrp="251")

def test_product_inv_retailer(db, inv_retailer_qpr, inv_product_qpr):
    assert inv_product_qpr.retails.filter(name=inv_retailer_qpr.name).exists()

def test_product_inv_1(inv_product_1):
    assert inv_product_1.name == "Hp Laptop"
    assert inv_product_1.is_available

It isn’t too dissimilar to what you’ve already accomplished, so let’s break it down:

  • The inv_retailer_qpr fixture and inv_category stay the same.
  • A new inv_product_factory fixture is added, and the inv_category and inv_retailer_qpr fixtures are inserted.
  • The fixture inv_product_factory provides a wrapper and returns the create_inv_product inner function.
  • inv_product_factory should be injected into another fixture and used to build a product instance.
  • In python, the factory fixture operates similarly to decorators.

Using Scopes to Share Fixtures

Fixtures that require network or database access are time-consuming to create and rely on connectivity. Whenever we request any fixture within our tests, it is utilized to run the method, build an instance, and send it to the test, as seen in the previous example. So, if we’ve written ‘n’ tests and each one requires the same fixture, that fixture instance is produced n times throughout the execution.

The latter is primarily because fixtures are produced when a test first requests them and then removed based on their scope:

  • In the default scope, usually, the fixture is destroyed at the tests’ close.
  • Class: the fixture is destroyed during the takedown of the last test in the class.
  • Module: The fixture is destroyed during the takedown of the module’s final test.
  • The fixture is destroyed during the takedown of the last test in the package.
  • The fixture is destroyed after the testing session.

We may use scope=” module” in the above example to ensure that the inv_category, inv_retailer_qpr, inv_product_1, and inv_product_1 instances are only called once per test module.

As a result, many test functions in a test module will share the same inv_category, inv_retailer_qpr, inv_product_1, and inv_product_2 fixture instance, saving time.

@pytest.fixture(scope="module")
def inv_category(db) -> INVCategory:
  return INVCategory.objects.create(name="Laptop")

@pytest.fixture(scope="module")
def inv_retailer_qpr(db):
  return INVRetail.objects.create(name="QPR")

@pytest.fixture(scope="module")
def inv_product_1(inv_product_factory):
  return product_factory(name="HP Laptop", mrp="650.25")

@pytest.fixture(scope="module")
def inv_product_2(inv_product_factory):
  return product_factory(name="Dell Laptop", mrp="251")

We can give the fixtures more scope, and you can do it for all of them. However, if we try to test this in the terminal, we get the following error:

pytest

This problem is because the db fixture has the function scope for a reason, so the transaction rollbacks at the end of each test ensure that the database is in the same state as when the test began. Using the Django db blocker fixture, however, you can have session/module scoped access to the database in the fixture:

import random
import string
import pytest

from INVProduct.models import INVCategory, INVProduct, INVRetail

@pytest.fixture(scope="module")
def inv_category(inv_django_db_blocker):
    with inv_django_db_blocker.unblock():
        return INVCategory.objects.create(name="Laptop")

@pytest.fixture(scope="module")
def inv_retailer_qpr(inv_django_db_blocker):
    with inv_django_db_blocker.unblock():
        return INVRetail.objects.create(name="QPR")

@pytest.fixture(scope="module")
def inv_product_factory(inv_django_db_blocker, inv_category, inv_retailer_qpr):
    def create_inv_product(
        name, description="The Laptop", mrp=None, is_available=True, retailers=None
    ):
        if retailers is None:
            retailers = []
        inv_sku = "".join(random.choices(
								 string.ascii_uppercase + string.digits, k=6)
								)
        with inv_django_db_blocker.unblock():
            inv_product = INVProduct.objects.create(
                sku=inv_sku,
                name=name,
                description=description,
                mrp=mrp,
                is_available=is_available,
                category=inv_category,
            )
            inv_product.retails.add(inv_retailer_qpr)
            if retailers:
                inv_product.retails.set(retailers)
            return inv_product

    return create_inv_product

@pytest.fixture(scope="module")
def inv_product_1(inv_product_factory):
    return inv_product_factory(name="HP Laptop", mrp="650.25")

@pytest.fixture(scope="module")
def inv_product_2(inv_product_factory):
    return inv_product_factory(name="Dell Laptop", mrp="251")

def test_product_inv_retailer(db, inv_retailer_qpr, inv_product_qpr):
    assert product_one.retails.filter(name=retailer_abc.name).exists()

def test_product_inv_1(inv_product_1):
    assert inv_product_1.name == "Laptop HP"
    assert inv_product_2.is_available

If we go to the terminal and run the tests, they will succeed.

$ pytest

Conclusion

We’ve successfully learned about the different features provided by pytest fixtures and how we may profit from code reusability and maintainability in our tests. Fixtures make managing dependencies and organizing your test data much more manageable.

It was an article explaining how to utilize fixtures with Django models and the numerous benefits. If you unlock the database in session scope and then change it in other fixtures or tests, you’re on your own. More information on fixtures is found in the official documents.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *