With the test framework that Python and Django provide, there is a lot of code boilerplate, maintainability, and duplication issues rise as your projects grow. It’s also not a very pythonic way of writing tests.
Pytest provides a simple and more elegant way to write tests.
It provides the ability to write tests as functions, which means a lot of boilerplate code has been removed, making your code more readable and easy to maintain. Pytest also provides functionality in terms of test discovery—and defining and using fixtures.
Why Pytest Fixtures?
When writing tests, it's very common that the test will need objects, and those objects may be needed by multiple tests. There might be a complicated process for the creation of these objects. It will be difficult to add that complex process in each of the test cases, and on any model changes, we will need to update our logic in all places. This will create issues of code duplication and its maintainability.
To avoid all of this, we can use the fixture provided by the pytest, where we will define the fixture in one place, and then we can inject that fixture in any of the tests in a much simpler way.
Briefly, if we have to understand fixtures, in the literal sense, they are where we prepare everything for our test. They’re everything that the test needs to do its thing.
We are going to explore how effectively we can make use of fixtures with Django models that are more readable and easy to maintain. These are the fixtures provided by the pytest and not to be confused with Django fixtures.
Installation and Setup
For this blog, we will set up a basic e-commerce application and set up the test suite for pytest.
Creating Django App
Before we begin testing, let's create a basic e-commerce application and add a few models on which we can perform tests later.
To create a Django app, go to the folder you want to work in, open the terminal, and run the below commands:
Once the app is created, go to the settings.py and add the newly created product app to the INSTALLED_APPS.
Now, let's create basic models in the models.py of the product app.
Here, each product will have a category and will be available at many retail stores. Now, let's run the migration file and migrate the changes:
The models and database is now ready, and we can move on to writing test cases for these models.
Let's set up the pytest in our Django app first.
For testing our Django applications with pytest, we will use the plugin pytest-django, which provides a set of useful tools for testing Django apps and projects. Let’s start with installing and configuration of the plugin.
Pytest can be installed with pip:
Installing pytest-django will also automatically install the latest version of pytest. Once installed, we need to tell pytest-django where our settings.py file is located.
The easiest way to do this is to create a pytest configuration file with this information.
Create a file called pytest.ini in your project directory and add this content:
You can provide various configurations in the file that will define how our tests should run.
e.g. To configure how test files should be detected across project, we can add this line:
Adding Test Suite to the Django App
Django and pytest automatically detect and run your test cases in files whose name starts with 'test'.
In the product app folder, create a new module named tests. Then add a file called test_models.py in which we will write all the model test cases for this app.
Running your Test Suite
Tests are invoked directly with the pytest command:
For now, we are configured and ready for writing the first test with pytest and Django.
Writing Tests with Pytest
Here, we will write a few test cases to test the models we have written in the models.py file. To start with, let's create a simple test case to test the category creation.
Now, try to execute this test from your command line:
The tests failed. If you look at the error, it has to do something with the database. The pytest-django doc says:
pytest-django takes a conservative approach to enabling database access. By default your tests will fail if they try to access the database. Only if you explicitly request database access will this be allowed. This encourages you to keep database-needing tests to a minimum which makes it very clear what code uses the database.
This means we need to explicitly provide database access to our test cases. For this, we need to use [pytest marks](<https://docs.pytest.org/en/stable/mark.html#mark>) to tell pytest-django your test needs database access.
Alternatively, there is one more way we can access the database in the test cases, i.e., using the db helper fixture provided by the pytest-django. This fixture will ensure the Django database is set up. It’s only required for fixtures that want to use the database themselves.
Going forward, we will use the db fixture approach as it promotes code reusability using fixtures.
Run the test again:
The command completed successfully and your test passed. Great! We have successfully written our first test case using pytest.
Creating Fixtures for Django Models
Now that you’re familiar with Django and pytest, let's add a test case to check if the to-check category updates.
If you look at both the test cases, one thing you can observe is that both the test cases do not test Category creation logic, and the Category instance is also getting created twice, once per test case. Once the project becomes large, we might have many test cases that will need the Category instance. If every test is creating its own category, then you might face trouble if any changes to the Category model happen.
This is where fixtures come to the rescue. It promotes code reusability in your test cases. To reuse an object in many test cases, you can create a test fixture:
Here, we have created a simple function called category and decorated it with @pytest.fixture to mark it as a fixture. It can now be injected into the test cases just like we injected the fixture db.
Now, if a new requirement comes in that every category should have a description and a small icon to represent the category, we don't need to now go to each test case and update the category to create logic. We just need to update the fixture, i.e., only one place. And it will take effect in every test case.
Using fixtures, you can avoid code duplication and make tests more maintainable.
It is recommended to have a single fixture function that can be executed across different input values. This can be achieved via parameterized pytest fixtures.
Let's write the fixture for the product and consider we will need to create a SKU product number that has 6 characters and contains only alphanumeric characters.
We now want to test the case against multiple sku cases and make sure for all types of inputs the test is validated. We can flag the fixture to create three different product_one fixture instances. The fixture function gets access to each parameter through the special request object:
Fixture functions can be parametrized in which case they will be called multiple times, each time executing the set of dependent tests, i.e., the tests that depend on this fixture.
Test functions usually do not need to be aware of their re-running. Fixture parametrization helps to write exhaustive functional tests for components that can be configured in multiple ways.
Open the terminal and run the test:
We can see that our test_product_sku function ran thrice.
Injecting Fixtures into Other fixtures.
We will often come across a case wherein, we will need an object for a case that will be dependent on some other object. Let's try to create a few products under the category "Books".
If we try to test this in the terminal, we will encounter an error:
The test case throws an IntegrityError, saying we tried to create the "Books" category twice. And if you look at the code, we have created the category in both product_one and product_two fixtures. What could we have done better?
If you look carefully, we have injected db in both the product_one and product_two fixtures, and db is just another fixture. So that means fixtures can be injected into other fixtures.
One of pytest’s greatest strengths is its extremely flexible fixture system. It allows us to boil down complex requirements for tests into more simple and organized functions, where we only need to have each one describe the things they are dependent on.
You can use this feature to address the IntegrityError above. Create the category fixture and inject it into both the product fixtures.
If we try to run the test now, it should run successfully.
By restructuring the fixtures this way, we have made code easier to maintain. By simply injecting fixtures, we can maintain a lot of complex model fixtures in a much simpler way.
Let's say we need to add an example where product one and product two will be sold by retail shop "ABC". This can be easily achieved by injecting retailer fixtures into the product fixture.
Sometimes, you may want to have a fixture (or even several) that you know all your tests will depend on. “Autouse” fixtures are a convenient way of making all tests automatically request them. This can cut out a lot of redundant requests, and can even provide more advanced fixture usage.
We can make a fixture an autouse fixture by passing in autouse=True to the fixture’s decorator. Here’s a simple example of how they can be used:
In this example, the append_retailers fixture is an autouse fixture. Because it happens automatically, test_product_retailer is affected by it, even though the test did not request it. That doesn’t mean they can’t be requested though; just that it isn’t necessary.
Factories as Fixtures
So far, we have created objects with a small number of arguments. However, practically models are a bit more complex and may require more inputs. Let's say we will need to store the sku, mrp, and weight information along with name and category.
If we decide to provide every input to the product fixture, then the logic inside the product fixtures will get a little complicated.
Product creation has a somewhat complex logic of managing retailers and generating unique SKU. And the product creation logic will grow as we keep adding requirements. There may be some extra logic needed if we consider discounts and coupon code complexity for every retailer. There may also be a lot of versions of the product instance we may want to test against, and you have already learned how difficult it is to maintain such a complex code.
The “factory as fixture” pattern can help in these cases where the same class instance is needed for different tests. Instead of returning an instance directly, the fixture will return a function, and upon calling which one, you can get the distance that you wanted to test.
This is not far from what you’ve already done, so let’s break it down:
- The category and retailer_abc fixture remains the same.
- A new product_factory fixture is added, and it is injected with the category and retailer_abc fixture.
- The fixture product_factory creates a wrapper and returns an inner function called create_product.
- Inject product_factory into another fixture and use it to create a product instance
The factory fixture works similar to how decorators work in python.
Sharing Fixtures Using Scopes
Fixtures requiring network or db access depend on connectivity and are usually time-expensive to create. In the previous example, every time we request any fixture within our tests, it is used to run the method, generate an instance and pass them to the test. So if we have written ‘n’ tests, and every test calls for the same fixture then that fixture instance will be created n times during the entire execution.
This is mainly happening because fixtures are created when first requested by a test, and are destroyed based on their scope:
- Function: the default scope, the fixture is destroyed at the end of the test.
- Class: the fixture is destroyed during the teardown of the last test in the class.
- Module: the fixture is destroyed during teardown of the last test in the module.
- Package: the fixture is destroyed during teardown of the last test in the package.
- Session: the fixture is destroyed at the end of the test session.
In the previous example, we can add scope="module" so that the category, retailer_abc, product_one, and product_two instances will only be invoked once per test module.
Multiple test functions in a test module will thus each receive the same category, retailer_abc, product_one, and product_two fixture instance, thus saving time.
This is how we can add scope to the fixtures, and you can do it for all the fixtures.
But, If we try to test this in the terminal, we will encounter an error:
The reason for this error is that the db fixture has the function scope for a reason, so the transaction rollbacks on the end of each test ensure the database is left in the same state it has when the test starts. Nevertheless, you can have the session/module scoped access to the database in the fixture by using the django_db_blocker fixture:
Now, if we go to the terminal and run the tests, it will run successfully.
Warning: Beware that when unlocking the database in session scope, you're on your own if you alter the database in other fixtures or tests.
We have successfully learned various features pytest fixtures provide and how we can benefit from the code reusability perspective and have maintainable code in your tests. Dependency management and arranging your test data becomes easy with the help of fixtures.
This was a blog about how you can use fixtures and the various features it provides along with the Django models. You can check more on fixtures by referring to the official documentation.