Pytest is a Python testing framework that can be used to write various types of tests, including unit tests, integration tests, end-to-end tests, and functional tests. While Django comes with its built-in testing framework, its limitations may leave developers yearning for more. In this light, enter Pytest, a powerhouse in the Python testing community that seamlessly integrates with Django, providing flexibility and efficiency. In the realm of Django development, Pytest stands out as a go-to solution for writing diverse tests, from unit tests to end-to-end scenarios. Its extensive plugin ecosystem, particularly the pytest-Django plugin, proves invaluable, offering a robust alternative to Django's native testing capabilities. As we embark on this journey into Pytest for Django, we'll be exploring its features, advantages, and practical applications. Our context will be a fictional blog post app for showcasing Pytest's prowess in enhancing testing, though it can still be used in any sort of Django project.
Problems with test cases for django
While it is possible to get through writing tests for a Django project using only the default TestCase class, Django test cases offer a few issues that can make development less streamlined. Note that some of these issues can be solved with a bit of hacking and introducing other third-party libraries, but it is usually tedious. Though sometimes basic tests are just what you need, then it is okay to just use TestCase. These problems include:
Lack of flexibility in writing tests
Tests that are written using the default Django TestCase class often involve the use of assertions that are specific to Django. This restricts flexibility, as developers might find it hard to employ custom assertions or even native Python assertion statements.
from django.test import TestCase
from django.urls import reverse
class TestHomePageView(TestCase):
def test_homepage_response_code(self):
response = self.client.get(reverse('homepage-url'))
self.assertEqual(response.status_code, 200)Complexity in the setup/teardown process
In testing, setup is preparing for tests, and teardown is cleaning up after tests. Complexity in setup/teardown happens when tests get complicated, especially in the Django default TestCase. In TestCase, setUp() prepares, and tearDown() cleans up, which can be tricky in intricate test scenarios. Consider a scenario involving interdependent models or complex data relationships, where setting up the test environment and cleaning it afterward can be challenging.
Let's imagine a scenario involving multiple models with interdependencies, such as a blog app with Post, Author, and Comment models.
from django.test import TestCase
from blog.models import Post, Author, Comment
class BlogTestCase(TestCase):
def setUp(self):
self.author = Author.objects.create(name='Test Author')
self.post = Post.objects.create(
title='Test Post',
content='This is a test post.',
author=self.author)
self.comment = Comment.objects.create(
post=self.post,
author=self.author,
text='Test comment')
def test_post_and_comment_relationship(self):
post = Post.objects.get(title='Test Post')
comment = Comment.objects.get(post=post)
self.assertIsNotNone(comment)
self.assertEqual(comment.text, 'Test comment')
def tearDown(self):
self.comment.delete()
self.post.delete()
self.author.delete()It is not neccessary to save an instance of the model with like with the self.post.save() expression, though it is okay if you use it.
Difficult in parameterizing testing
Parameterized testing involves running the same test logic with multiple sets of input data. In the default Django TestCase structure, parameterized testing can be challenging to implement without duplicating test code. Let's explore this with an example.
from django.test import TestCase
from blog.models import Calculator
class CalculatorTestCase(TestCase):
def test_addition(self):
# Test addition with different sets of numbers
result_1 = Calculator.add(2, 3)
self.assertEqual(result_1, 5)
result_2 = Calculator.add(-1, 5)
self.assertEqual(result_2, 4)
result_3 = Calculator.add(0.5, 2)
self.assertEqual(result_3, 2.5)Introducing pytest
Pytest can address the problems mentioned above that come with using the default TestCase class. Here is how:
Simplified setup and teardown
Using Pytest fixtures simplifies the setup process, especially in cases involving intricate relationships between models, by providing a cleaner and more organized approach to handling dependencies and setup.
@pytest.fixture
def sample_post():
post = Post.objects.create(title="Sample post", content="This is a simple thing for me")
post.save()
yield post
post.delete()This simple function takes care of all the setup and teardown for all the tests. This is how this the TestCase class would handle setup and teardown.
class TestBlogModel(TestCase):
def setUp(self):
self.post = Post.objects.create(title="Sample title", content="Sample page content")
self.post.save()
def test_post_model_str(self):
...
def tearDown(self):
self.post.delete()Also, fixtures automatically handle teardown, ensuring setup only occurs when needed for specific tests and cleaning up afterward.
Custom Assertions
Pytest provides mechanisms for using simple assertions with no additional methods that are more pythonic and easier to read. Here is an example of how you would go about it.
import pytest
from blog.models import Author, Post, Comment
@pytest.fixture
def author():
author = Author.objects.create(name='Test Author')
yield author
author.delete()
@pytest.fixture
def post(author):
post = Post.objects.create(title='Test Post', content='This is a test post.', author=author)
yield post
post.delete()
@pytest.fixture
def comment(post, author):
comment = Comment.objects.create(post=post, author=author, text='Test comment')
yield comment
comment.delete()
def test_post_and_comment_relationship(post, comment):
retrieved_post = Post.objects.get(title='Test Post')
retrieved_comment = Comment.objects.get(post=retrieved_post)
assert retrieved_comment is not None
assert retrieved_comment.text == 'Test comment'Easier parameterization of tests
Using pytest features instead of the default Django TestCase helps with parameterization. Parameterization simplifies passing parameters to tests, allowing developers to run the same test with different inputs or scenarios easily using a fixture decorator. Here is the same example using Pytest.
import pytest
from blog.models import Post
@pytest.mark.parametrize("title, content, author_name, expected_author", [
("Title1", "Content1", "Author1", "Author1"),
("Title2", "Content2", "Author2", "Author2"),
("Title3", "Content3", "Author3", "Author3"),
])
def test_blog_post_creation(title, content, author_name, expected_author):
post = BlogPost.objects.create(title=title, content=content, author_name=author_name)
assert post.title == title
assert post.content == content
assert post.author_name == expected_authorThis is all thanks to @pytest.mark.parametrize decorator. It provides the input values (title, content, author_name) and Pytest executes the test for each set of input values provided in the list, effectively running multiple test cases within a single test function.
Using django with pytest
To test Django projects, we will need to use the existing pytest-django plugin. First, install it using pip.
pip3 install pytest-djangoYou can also install it inside a virtual environment like pipenv.
pipenv install pytest-djangoNow you can set up Pytest for your project. There are multiple ways to do this. You can use a Pytest configuration file, which you create in the root directory of your project. This file points Pytest to the settings of your project. Create a file called pytest.ini and inside it, you will add this code.
[pytest]
# Pointing to the project settings
DJANGO_SETTINGS_MODULE = yourproject.settings
python_files = test.py test_*.py *_test.pyThe last line instructs Pytest to collect tests in Django’s default app layouts, too.
To run the test, simply just type pytest on the terminal.
pytestNote: You need to run this command from the root directory of the project where the pytest.ini file is located.
Writing fixtures
Fixtures are a cornerstone of testing in Pytest. They provide a very useful mechanism in the setup and teardown process in a more modular manner. They also help improve the readability of your test code. To define a fixture, you would use the @pytest.fixture decorator. Here is an example that defines a user fixture.
import pytest
from django.contrib.auth import get_user_model
@pytest.fixture
def sample_user():
user = get_user_model().objects.create_user(
username="test_user",
email="[email protected]",
password="foobar")
user.save()
yield user # The fixture value
user.delete()This simple fixture creates a test user to use for the test. They yield keyword turns a regular function into a special type called a generator function. What this means is that at this point, the function halts it's execution, as if saying "let me pause and give you this data and when you are done we can continue". Execution continues after the data provided is no longer in use. So fixtures provide a test with data using yield and after the data is used for the purpose it was needed, they delete the data.
To use the fixture in a test, simply just pass the fixture name as an argument to the test function. Pytest will automatically recognize the fixture and provide its value to the test. Here is how you would use the sample_user fixture. The sample_user fixture provides the test with an instance of the user model, which we can use to test the model with.
# Testing the user model
def test_user_profile(sample_user):
user = sample_user
assert user.username == "test_user"
assert user.email == "[email protected]"Fixtures can also depend on other fixtures, allowing for a sort of hierarchical approach to testing. Say you had a profile model with one of the fields being a foreign key to the default User model, here is how you would go about writing fixtures for its test.
import pytest
from app.models import Profile
from django.contrib.auth.models import User
# User fixture
@pytest.fixture
def sample_user():
user = User.objects.create(
username="test_user",
email="[email protected]",
password="foobar")
user.save()
yield user
user.delete()
# Profile fixtuer
@pytest.fixture
def sample_profile(sample_user):
profile = Profile.objects.create(user=sample_user)
profile.save()
yield profile
profile.delete()Fixtures also have scopes that determine how long the fixtures persist and how many times it is set up during test execution. They include the function scope, class scope, session scope, and module scope. Here is an example of how to define fixture scope.
import pytest
from blog.models import Post
@pytest.fixture(scope="class")
def sample_post():
post = Post.objects.create(title="Sample Post", content="This is a sample post.")
yield post
post.delete()And here is a brief description of each type of fixture scope:
Function scope
Also known as the default scope. The function scope ensures that a fixture is set up and torn down after each test function. When using the default scope, you do not need to define scope. Use function scope when the fixture data should be isolated for each test function.
Class scope
With the class scope the fixture is set up once for the entire test class and torn down after all methods in the class are executed. Use this scope when you want to share fixture data across methods within a test class.
import pytest
from blog.models import Post
@pytest.fixture(scope="class")
def sample_post():
post = Post.objects.create(title="Sample Post", content="This is a simple post.")
yield post
post.delete()
@pytest.mark.django_db
class TestBlogModel:
@pytest.fixture(autouse=True)
def setup_teardown(self, sample_post):
self.sample_post = sample_post
yield
def test_post_model_title(self):
assert self.sample_post.title == "Sample Post"
def test_post_model_content(self):
assert self.sample_post.content == "This is a sample post."Yield in this scenario is used to indicate the phase where setup is complete and test methods can be run.
Session scope
In this scope the fixture is set up once at the beginning of the test session and the data is shared by all the test functions in the entire test session before teardown takes place.
import pytest
from blog.models import Post
@pytest.fixture(scope="session")
def sample_post_session_scope():
# Setup phase (runs once at the beginning of the test session)
post = Post.objects.create(title="Sample Post for Session Scope", content="This is a test post.")
post.save()
yield post # Fixture value provided to tests
# Teardown phase (runs once at the end of the test session)
post.delete()
def test_session_scoped_post_title(sample_post_session_scope):
post = sample_post_session_scope
assert post.title == "Sample Post for Session Scope"
def test_session_scoped_post_content(sample_post_session_scope):
post = sample_post_session_scope
assert post.content == "This is a test post."Module scope
In this scenario the fixture is set up once for the entire module and torn down after all tests in the module/file are executed. Use module scope when the fixture data needs to be shared across all test functions within a module.
import pytest
from blog.models import Post
@pytest.fixture(scope="module")
def module_scoped_post():
# Setup: Creating a shared resource for the entire module
post = Post.objects.create(title="Module Scoped Post", content="This is a test post.")
post.save()
yield post # Fixture value accessible to all test functions in the module
# Teardown: Cleaning up the resource after all test functions in the module complete
post.delete()
def test_module_scoped_post_title(module_scoped_post):
assert module_scoped_post.title == "Module Scoped Post"
def test_module_scoped_post_content(module_scoped_post):
assert module_scoped_post.content == "This is a test post"Now that most of the fixture basics are covered, how about we talk about fixture finalization.
Fixture finalization in pytest
Fixture finalization in Pytest is a mechanism that allows a programmer to define actions to take place after a fixture is no longer needed. This comes in handy when trying to do cleanup activities, ensuring resources are appropriately released. There are a few ways you can go about it.
Using the request object. Pytest has a request object which is automatically provided by it to the fixtures. Here is how you would go about it.
import pytest
from blog.models import Post
@pytest.fixture
def sample_post(request):
# Fixture setup
post = Post.objects.create(title="Test Post", content="This is a test post.")
post.save()
yield post
# Fixture teardown
def fin():
post.delete()
request.addfinalizer(fin)You can also do this using yield and try... finally to ensure finalization logic is executed even if an error occurs. Here is how you would do it.
import pytest
from blog.models import Post
@pytest.fixture
def sample_post(request):
# Fixture setup
post = Post.objects.create(title="Test Post", content="This is a test post.")
post.save()
try:
yield post
except Exception as e:
print(f'Error occurred: {e}')
finally:
post.delete()Here is how you would do the same thing but with the default TestCase class.
from django.test import TestCase
from blog.models import Post
class TestBlogModel(TestCase):
def setUp(self):
self.post = Post.objects.create(title="Test Post", content="This is a test post.")
self.post.save()
def test_model_str(self):
self.assertEqual(self.post.title, "Test Post")
def tearDown(self):
self.post.delete()So far we have covered most of the ways you would use pytest in your Django projects, let's talk about code organization.
Organizing your code in pytest
One of the strengths of Pytest lies in its flexibility and the ability to structure your test code in a way that promotes maintainability and readability. One way of organizing test code is by dividing your tests into modular files based on functionality or features. For instance, if your Django project has separate apps, consider creating test modules for each app.
project_root/
|-- app1/
| |-- __init__.py
| |-- module1.py
| |-- tests/
| |-- __init__.py
| |-- test_feature1.py
| |-- test_module1.py
|-- app2/
| |-- __init__.py
| |-- module2.py
| |-- tests/
| |-- __init__.py
| |-- test_feature2.py
| |-- test_module2.py
|-- tests/
| |-- __init__.py
| |-- test_integration.py
|-- pytest.ini
|-- requirements.txt
|-- .gitignoreIn this structure, each Django app has its own tests directory containing modular test files. Additionally, there's a central tests directory for integration tests that span multiple apps.
Here is an example of pytest markers with selective test executions.
# Inside test_feature1.py
import pytest
@pytest.mark.slow_stuff
def test_slow_feature():
assert True
def test_fast_feature():
assert TrueWith the given structure, you can use the -k option in Pytest to selectively run tests based on markers.
For example, to run only tests marked as 'slow_stuff':
pytest -k slow_stuffAnd to run tests excluding those marked as 'slow_stuff':
pytest -k 'not slow_stuff'Additionally, leveraging fixtures for set up and teardown to make your code more modular and reusable is also another is another way. You can also group related tests within a single test class or module. This can be very useful when you have multiple test scenarios for one specific feature.
Some developers use Pytest markers to categorize and selectively run specific groups of tests. Markers allow you to tag tests with labels, enabling you to run tests based on these labels. Say you want to run only the integration tests, all you have to do is enter the following in your terminal.
pytest -m integrationBy adopting these practices, you can create a well-organized and maintainable test suite that adapts to the evolving nature of your Django project.
Conclusion
Embracing Pytest isn't just a recommendation; it's a strategic move for your Django projects. Whether your team is starting with a clean slate or transitioning from Django's TestCase, the advantages of adopting Pytest far surpass the initial learning curve. It's akin to future-proofing your test suite, ensuring adaptability and efficiency. The seamless integration with other teams using Pytest outside the Django realm enhances knowledge sharing and collaboration. So, don't just consider Pytest; make it an integral part of your Django testing journey