Unit Testing in Django with Pytest: A Practical Guide
- General
- Quality Engineering
Unit Testing in Django with Pytest: A Practical Guide
Unit testing isn’t just about catching bugs — it’s a mindset that helps you write cleaner, more maintainable, and bug-resistant code from the start. When building Django applications, adopting pytest with proper patterns like fixtures, mocks, and factories makes testing powerful yet elegant.
In this blog, we’ll walk through:
- Why testing matters
- Pytest fundamentals
- Practical tools: conftest.py, fixtures, RequestFactory, mocking
- How to write clean, reusable, and scalable test cases
🔍 Why Is Testing Important?
Good testing ensures:
- Code Quality – Helps you write robust and predictable code.
- Facilitates Refactoring – Safe refactoring with test coverage.
- Saves Time and Cost – Detects issues early.
- Encourages Better Design – Forces you to modularize.
- Prevents Regressions – Alerts you when something breaks unexpectedly.
🧱 Types of Tests
- Unit Tests: Test individual components, such as functions and models.
- Integration Tests: Validate interactions between modules or systems.
- Functional Tests: Simulate user-level operations, ensuring the whole system works.
We’ll focus on unit tests here using pytest in a Django context.
⚙️ Pytest Setup in Django
Install dependencies:
1 |
pip install pytest pytest-django factory_boy |
In your project root, create or update pytest.ini:
1 2 3 4 5 |
[pytest] DJANGO_SETTINGS_MODULE = your_project.settings python_files = tests.py test_*.py *_tests.py |
Now you’re ready to test Django apps with Pytest!
🗂 Recommended Test Folder Structure
Inside each Django app, use a folder named tests/
:
1 2 3 4 5 6 7 8 9 10 11 |
your_project/ ├── your_app/ │ ├── models.py │ ├── views.py │ ├── tests/ │ │ ├── __init__.py │ │ ├── factories.py # Factory Boy classes │ │ ├── test_models.py # Tests for models │ │ ├── test_views.py # Tests for views ├── conftest.py # Shared fixtures ├── pytest.ini |
Writing and Using Factories (Factory Boy)
What is a Factory?
In the context of testing, a factory is a helper object that generates model instances (or other Python objects) with default values. Instead of writing repetitive User.objects.create(…) or manually filling each field with test data, a factory automates this process and gives you complete control to override values when needed. Think of it like a blueprint for creating test data. Once defined, you can use it in your tests to quickly spin up realistic test objects without writing the same code over and over again. Factories are commonly implemented using the Factory Boy library in Django.
Why Use It?
- Avoid writing User.objects.create(…) in every test
- Generate fake but realistic data
- Easily customize fields
Step-by-Step
In your app’s test folder, create a file:
📁 your_app/tests/factories.py
Here, we define default values for our model so we don’t need to pass them manually every time.
1 2 3 4 5 6 7 8 9 10 |
import factory from django.contrib.auth.models import User class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User username = factory.Faker("user_name") email = factory.Faker("email") is_active = True |
How to Use It
Example 1: Use it directly in a test
You can import and call this factory wherever you need a test user.
📁 test_models.py
1 2 3 4 5 6 7 8 |
import pytest from your_app.tests.factories import UserFactory @pytest.mark.django_db def test_user_creation(): user = UserFactory() assert user.username is not None assert user.is_active |
Example 2: Customize fields
If you want to override some values while keeping the rest default:
1 |
user = UserFactory(username="Jhon", is_active=False) |
Example 3: Create a batch of users
To generate multiple users for bulk testing:
1 2 |
users = UserFactory.create_batch(5) assert len(users) == 5 |
Writing Fixtures
What is a Fixture?
In pytest, a fixture is a function that provides setup code that your test functions can use. Think of it like a preconfigured toolbox that runs before your test and prepares the environment or data needed for the test to run properly. Instead of repeating the same setup logic in every test, you define a fixture once and then simply reference it by name as a test function argument. Pytest automatically calls it and passes its result to your test.
Why Use Fixtures?
- Reduce duplication
- Keep tests clean
- Centralized test setup logic
Step-by-Step
To define a reusable fixture, create a conftest.py
file in the project root, where pytest will automatically discover it.
📁 conftest.py
To define a reusable fixture, create a conftest.py file in the project root (the same level as pytest.ini and manage.py). Pytest automatically discovers this file and makes any fixtures defined in it globally available to all te- st files without needing to import them manually.
How conftest.py works:
- Any fixture declared here is automatically available to any test in your Django project.
- You do not need to import the fixture — pytest handles this behind the scenes.
- Useful for fixtures that are used across multiple apps or files (e.g., user factories, test settings, database connections).
- Scoping: You can also control fixture scope using the @pytest.fixture(scope=”…”) argument. For example, scope=”session” will run the fixture only once per test session.
App-level conftest.py:
- You can also define a conftest.py inside a specific Django app’s test folder to keep test setup modular.
- In this case, fixtures in that file will only be available to tests within the same directory tree (i.e., within that app).
- This is useful for app-specific test data or mocking logic.
Note: If a fixture is defined in both the root and app-level conftest.py, the app-level version will override it for tests in that app.
Here, we wrap the factory to return a fresh object every time it’s needed:
1 2 3 4 5 6 |
import pytest from your_app.tests.factories import UserFactory @pytest.fixture def test_user(): return UserFactory() |
How to Use It
Fixtures in pytest can be directly injected into your test functions by just adding them as parameters.
📁 test_models.py
1 2 3 |
@pytest.mark.django_db def test_user_is_active(test_user): assert test_user.is_active |
Here, test_user
is automatically created by the fixture we defined in conftest.py
.
Markers and Parametrization
Markers are special decorators in pytest that provide additional information about how tests should be executed or categorized. They help customize test behavior by allowing you to label tests with specific attributes, which can then be used to control their execution, reporting, and organization.
Common Uses of Markers:
1. Categorization:
Markers can be used to group tests into categories for easier identification, such as @pytest.mark.smoke
, @pytest.mark.regression
, or @pytest.mark.performance
. This allows you to selectively run groups of tests based on their category.
Example:
1 2 3 4 5 6 7 8 9 |
import pytest @pytest.mark.smoke def test_login(): assert True @pytest.mark.regression def test_user_profile_update(): assert True |
You can then run only smoke tests with:
1 |
pytest -m smoke |
Certain markers can specify conditions under which a test should be executed or skipped. For instance, @pytest.mark.skip
can be used to skip a test, while @pytest.mark.skipif(condition)
skips it only if a certain condition is met.
Example:
1 2 3 4 5 6 7 8 9 10 |
import pytest import sys @pytest.mark.skip(reason="This feature is deprecated") def test_old_api(): assert True @pytest.mark.skipif(sys.platform == "win32", reason="Does not run on Windows") def test_linux_only(): assert True |
3. Parameterization:
Markers can facilitate parameterized testing, allowing a single test function to run with multiple sets of input data using @pytest.mark.parametrize
. This is useful for testing the same functionality with different scenarios.
Example:
1 2 3 |
@pytest.mark.parametrize("a,b,expected", [(1,2,3), (2,3,5)]) def test_add(a, b, expected): assert a + b == expected |
4. Custom Test Behavior:
Users can define their own markers to implement custom behaviors, such as tagging tests that require database access or need to run in a specific environment. By utilizing markers, you can achieve more organized, flexible, and efficient test management in your pytest test suite.
Example:
1 2 3 4 5 6 |
import pytest @pytest.mark.requires_db @pytest.mark.slow def test_data_migration(): assert True |
You must register custom markers in pytest.ini
to avoid warnings:
1 2 3 4 5 6 |
[pytest] markers = smoke: smoke test regression: regression test requires_db: tests that require database setup slow: marks tests as slow |
Mocking External Services
Mocking involves substituting real external systems—such as APIs, files, and emails—with simulated objects. This allows for testing and development without relying on the actual external systems.
This is especially useful when:
- The real system is slow (e.g., network call)
- The real system has side effects (e.g., sending emails)
- The real system is unavailable during tests (e.g., a third-party API)
By using mocking, you can control external dependencies, simulate different scenarios (e.g., success, failure, timeout), and make your tests fast, stable, and independent.
Step-by-Step: Creating and Using a Mock Function
Imagine you have a service function like this:
📁 your_app/services.py
1 2 3 4 |
def send_email(to, subject, body): # Code that sends an email using SMTP or external service print(f"Sending email to {to} with subject: {subject}") return True |
Now let’s say you want to test a function that uses it:
📁 your_app/utils.py
1 2 3 4 5 6 |
from your_app.services import send_email def notify_user(user): if user.is_active: return send_email(user.email, "Welcome", "Thanks for joining!") return False |
To test this without actually sending an email, you mock send_email
:
📁 your_app/tests/test_utils.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import pytest from unittest.mock import patch from your_app.utils import notify_user from your_app.tests.factories import UserFactory @pytest.mark.django_db @patch("your_app.utils.send_email") def test_notify_user_sends_email(mock_send_email): # Arrange: simulate send_email returning True mock_send_email.return_value = True user = UserFactory(is_active=True) # Act result = notify_user(user) # Assert mock_send_email.assert_called_once_with(user.email, "Welcome", "Thanks for joining!") assert result is True @patch("your_app.utils.send_email") def test_notify_user_does_not_send_email_if_inactive(mock_send_email): user = UserFactory(is_active=False) result = notify_user(user) mock_send_email.assert_not_called() |
Summary:
Note: When using @patch
You must mock the function where it is imported, not where it is defined.
For example:
- If
send_email
is defined inyour_app.services
but used inyour_app.utils
, then you should patchyour_app.utils.send_email
, notyour_app.services.send_email
. - Always patch the location of usage, because that’s where Python looks it up during execution.
- We used
@patch
to replace the realsend_email
with a mock. - We controlled its return value.
- We verified whether it was called and with what arguments.
Here, the function mock_send_email is not created manually — it’s automatically provided by the @patch(…) decorator from unittest.mock. mock_send_email is the mock object injected by patch into the test function. So, you don’t create mock_send_email manually, the decorator handles it for you.
When using multiple @patch decorators, the order of the decorators determines the order of the arguments passed into the test function, and it’s reversed. Here’s how it works:
1 2 3 4 5 6 7 8 |
@patch("your_app.utils.send_sms") @patch("your_app.utils.send_email") def test_notify_user(mock_send_email, mock_send_sms): # mock_send_email is for the inner decorator # mock_send_sms is for the outer decorator mock_send_email.return_value = True mock_send_sms.return_value = True |
RequestFactory for View Testing
What is RequestFactory?
RequestFactory is a utility class provided by Django that allows you to create mock HTTP requests (like GET, POST, PUT, etc.) for unit testing views. It simulates requests without actually running the server or going through Django’s full request-response cycle.
This is different from Django’s Client, which performs an end-to-end request through URL routing, middleware, and template rendering. RequestFactory skips all that and directly targets the view logic.
Why Use It?
- Test views in isolation: You can focus purely on view logic without worrying about middleware, routing, or template rendering.
- Speed: Since it bypasses routing and middleware, it’s faster than using Django’s test Client.
- Control: Allows you to customize request headers, methods, and data to simulate edge cases and complex requests.
Example:
1 2 3 4 5 6 7 8 9 |
from django.test import RequestFactory from your_app.views import some_view @pytest.mark.django_db def test_view(): factory = RequestFactory() request = factory.get("/some-url/") response = some_view(request) assert response.status_code == 200 |
Tips for Clean Tests
- Test one thing at a time.
- Use factories and fixtures to reduce redundancy.
- Keep test names descriptive: test_user_login_fails_on_invalid_password.
- Avoid hard-coded data—use faker, factories.
- Organize tests per app or feature.
Testing is an investment. With Pytest + Django, you’re equipped with a framework that is:
- Powerful (supports mocking, fixtures, DB, view tests)
- Flexible (custom configuration via conftest.py)
- Efficient (fast feedback loop)
Don’t test just for the sake of 100% coverage. Test for confidence, reliability, and maintainability.
📚 Further Reading and Official References
For more advanced usage and deep dives, check out the official resources:
- ✅ Pytest Documentation: https://docs.pytest.org/en/stable/
- ✅ Pytest-Django Plugin Docs: https://pytest-django.readthedocs.io/en/latest/
- ✅ Django Testing Framework: https://docs.djangoproject.com/en/stable/topics/testing/
- ✅ Factory Boy Docs: https://factoryboy.readthedocs.io/en/stable/
- ✅ Unittest.mock (Python stdlib): https://docs.python.org/3/library/unittest.mock.html
Happy testing! No guessing. Just clean, reliable, maintainable tests.
Related content
Auriga: Leveling Up for Enterprise Growth!
Auriga’s journey began in 2010 crafting products for India’s