Python · Testing with pytest · Expert

Testing Django Apps with pytest-django

5 tasks

Test Django views, models, and forms using pytest-django's built-in fixtures.

Testing Django Apps with pytest-django

#
pytest-django bridges the pytest ecosystem with Django's test infrastructure. It gives you all pytest features — fixtures, parametrize, marks, plugins — while still managing Django's database, test client, and settings. ## Installation and setup ```bash pip install pytest-django ``` Tell pytest where your Django settings live by adding to `pytest.ini` in the project root: ```ini [pytest] DJANGO_SETTINGS_MODULE = myproject.settings ``` Without this line pytest-django cannot bootstrap Django, and any import from your models will raise `django.core.exceptions.AppRegistryNotReady`. ## Database access is disabled by default The most important rule in pytest-django: **database access is off by default.** Any test that tries to query the database will raise an error unless you explicitly grant access. This is intentional — tests that don't need the database should never pay the cost or risk the side effects of one that does. Enable access per test with the `@pytest.mark.django_db` marker: ```python import pytest from myapp.models import Task @pytest.mark.django_db def test_task_creation(): task = Task.objects.create(title='Buy milk') assert Task.objects.count() == 1 assert task.title == 'Buy milk' ``` ## Transaction isolation Each `@pytest.mark.django_db` test runs inside a database transaction that is **rolled back** after the test completes. Every test starts with a clean state and cannot pollute other tests through leftover rows: ```python @pytest.mark.django_db def test_a(): Task.objects.create(title='Task from test_a') assert Task.objects.count() == 1 @pytest.mark.django_db def test_b(): assert Task.objects.count() == 0 # test_a's row was rolled back ``` For tests that need **real** committed transactions — code using `on_commit()` hooks, `select_for_update()`, or cross-process database reads — use `@pytest.mark.django_db(transaction=True)`. This mode flushes the database after each test instead of rolling back, which is slower but accurate for transaction-sensitive code. ## Key built-in fixtures pytest-django provides several fixtures automatically, injected by parameter name: **`client`** — a Django `TestClient` that sends HTTP requests through the full Django stack (routing, middleware, views) without starting a real server: ```python @pytest.mark.django_db def test_task_list(client): response = client.get('/tasks/') assert response.status_code == 200 ``` **`rf`** — a `RequestFactory` that builds `HttpRequest` objects directly, bypassing URL routing and all middleware. Use it to test a view function in complete isolation: ```python from myapp.views import task_list @pytest.mark.django_db def test_task_list_view(rf): request = rf.get('/tasks/') response = task_list(request) assert response.status_code == 200 ``` **`admin_client`** — like `client` but pre-authenticated as a Django superuser. Useful for testing admin views without creating credentials manually. **`settings`** — lets you override any Django setting for the duration of one test. Changes revert automatically after the test finishes: ```python def test_custom_cache(settings): settings.CACHES = { 'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'} } from django.core.cache import cache cache.set('key', 'value', 30) assert cache.get('key') == 'value' ``` **`django_user_model`** — returns the User model class configured in `AUTH_USER_MODEL`. Always prefer this over importing `User` directly so your tests work with custom user models: ```python @pytest.mark.django_db def test_user_creation(django_user_model): user = django_user_model.objects.create_user(username='alice', password='x') assert user.username == 'alice' ``` ## Granting DB access to fixtures When a fixture needs to query the database, add the built-in `db` fixture as a parameter. This is the fixture-level equivalent of `@pytest.mark.django_db`: ```python @pytest.fixture def sample_task(db): from myapp.models import Task return Task.objects.create(title='Fixture task') @pytest.mark.django_db def test_task_is_incomplete(sample_task): assert sample_task.completed is False ``` If the test itself carries `@pytest.mark.django_db`, its fixtures inherit database access automatically — you do not need to add `db` to every fixture. The `db` parameter is mainly needed when the fixture lives in `conftest.py` and is used across multiple test files where some tests may not carry the marker. ## Email testing with `mailoutbox` The `mailoutbox` fixture intercepts all outgoing Django emails sent during a test. It automatically switches the email backend to Django's in-memory `locmem` backend — no manual `settings.EMAIL_BACKEND` override needed: ```python import pytest @pytest.mark.django_db def test_welcome_email_is_sent(mailoutbox, django_user_model): django_user_model.objects.create_user(username='alice', password='x') assert len(mailoutbox) == 1 msg = mailoutbox[0] assert msg.subject == 'Welcome, alice!' assert '[email protected]' in msg.to ``` `mailoutbox` is a list of `django.core.mail.EmailMessage` objects. Each message exposes `subject`, `body`, `from_email`, `to`, `cc`, `bcc`, and `attachments`. The list resets to empty before each test automatically — no manual `mail.outbox.clear()` needed. The alternative is to set `settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'` in the test and read `django.core.mail.outbox` directly. Both work, but `mailoutbox` is idiomatic pytest-django: no imports, no cleanup, and guaranteed isolation between tests. ## Class-level marker with `pytestmark` Repeating `@pytest.mark.django_db` on every method in a test class is noisy. Use `pytestmark` to apply the marker to an entire class or module: ```python import pytest from myapp.models import Task class TestTaskModel: pytestmark = pytest.mark.django_db # applies to every method in this class def test_creation(self): task = Task.objects.create(title='Buy milk') assert Task.objects.count() == 1 def test_default_completed(self): task = Task.objects.create(title='New task') assert task.completed is False ``` At module level, `pytestmark` marks every test function in the file: ```python # tests/test_models.py import pytest pytestmark = pytest.mark.django_db # every function in this module gets DB access ``` `pytestmark` is a standard pytest convention, not specific to pytest-django. It works with any marker and any scope that makes sense for your organisation — a module-level `pytestmark` is common for dedicated model test files. ## Test database lifecycle and `--reuse-db` By default, pytest-django creates a fresh test database at the start of every `pytest` run and destroys it when the run finishes. For projects with many migrations this adds significant startup time on each run. The `--reuse-db` flag keeps the test database between runs. On subsequent runs, pytest-django checks for unapplied migrations and applies only new ones: ```bash pytest --reuse-db # first run: creates DB; subsequent runs: reuse it pytest --create-db # force a clean rebuild regardless of current state ``` Typical setup for large projects: ```ini [pytest] DJANGO_SETTINGS_MODULE = myproject.settings addopts = --reuse-db ``` Then when you know the schema changed fundamentally (migrations squashed, renamed): ```bash pytest --create-db ``` `--reuse-db` is safe with the default `transaction=False` mode because each test still rolls back its own changes inside a database transaction. The persistent database only holds the schema and any seed data loaded at session start — tests cannot permanently write rows into it. **Exception:** tests marked `transaction=True` commit data to the database. Combined with `--reuse-db`, committed rows from one session survive into the next. Either avoid mixing `transaction=True` with `--reuse-db`, or add a session-scoped teardown fixture that deletes the committed rows. ## `live_server` — tests with a real HTTP server The `live_server` fixture starts a real WSGI server on a random port and yields an object whose `.url` attribute is the server's base URL: ```python @pytest.mark.django_db(transaction=True) def test_homepage_over_http(live_server): import urllib.request resp = urllib.request.urlopen(f'{live_server.url}/') assert resp.status == 200 ``` `live_server` always requires `transaction=True`. The server runs in a separate thread — without actual committed rows, the server thread cannot see data created in the test thread's rolled-back transaction. Use `live_server` for browser-automation tests (Selenium, Playwright) or when you must verify that the full WSGI stack (server, middleware, views) works end to end over a real TCP connection. For all other view and API tests, the `client` fixture (which runs the app in-process) is simpler and faster.

Django Testing Patterns in Practice

#
## The application under test All examples use a small Django app with the following setup: ```python # myapp/models.py from django.db import models from django.contrib.auth.models import User class Task(models.Model): title = models.CharField(max_length=200) completed = models.BooleanField(default=False) owner = models.ForeignKey( User, on_delete=models.CASCADE, null=True, blank=True ) ``` ```python # myapp/views.py from django.http import JsonResponse from django.contrib.auth.decorators import login_required from .models import Task def task_list(request): tasks = list(Task.objects.values('id', 'title', 'completed')) return JsonResponse({'results': tasks}) @login_required def my_tasks(request): tasks = list(Task.objects.filter(owner=request.user).values('id', 'title')) return JsonResponse({'results': tasks}) ``` ```python # myapp/urls.py from django.urls import path from . import views urlpatterns = [ path('tasks/', views.task_list), path('my-tasks/', views.my_tasks), ] ``` ## Testing views with `client` ```python import pytest @pytest.mark.django_db def test_task_list_returns_200(client): response = client.get('/tasks/') assert response.status_code == 200 data = response.json() assert 'results' in data assert isinstance(data['results'], list) @pytest.mark.django_db def test_task_list_shows_created_tasks(client): from myapp.models import Task Task.objects.create(title='Buy groceries') Task.objects.create(title='Write tests') response = client.get('/tasks/') results = response.json()['results'] assert len(results) == 2 titles = [t['title'] for t in results] assert 'Buy groceries' in titles ``` ## Testing model save and retrieve After calling `.save()` or `.create()`, always retrieve a fresh instance from the database to verify that the value was actually persisted — not just held in memory: ```python @pytest.mark.django_db def test_task_default_completed_is_false(): from myapp.models import Task task = Task.objects.create(title='New task') task_from_db = Task.objects.get(pk=task.pk) assert task_from_db.completed is False @pytest.mark.django_db def test_task_can_be_marked_complete(): from myapp.models import Task task = Task.objects.create(title='Finish report', completed=False) task.completed = True task.save() task.refresh_from_db() assert task.completed is True ``` `refresh_from_db()` reloads all fields from the database on the existing instance in place — slightly cleaner than `Task.objects.get(pk=task.pk)` when you already have the object. ## Authenticated views with `force_login` `client.force_login(user)` bypasses password checking and directly marks the session as authenticated. Prefer it over `client.login()` in tests because it doesn't couple your test to password hashing or authentication backend configuration: ```python @pytest.mark.django_db def test_my_tasks_redirects_anonymous(client): response = client.get('/my-tasks/') assert response.status_code == 302 # redirect to login @pytest.mark.django_db def test_my_tasks_returns_only_owners_tasks(client, django_user_model): alice = django_user_model.objects.create_user(username='alice', password='x') bob = django_user_model.objects.create_user(username='bob', password='x') from myapp.models import Task Task.objects.create(title="Alice's task", owner=alice) Task.objects.create(title="Bob's task", owner=bob) client.force_login(alice) response = client.get('/my-tasks/') assert response.status_code == 200 results = response.json()['results'] assert len(results) == 1 assert results[0]['title'] == "Alice's task" ``` ## Overriding settings with the `settings` fixture ```python @pytest.mark.django_db def test_welcome_email_is_sent(settings, django_user_model): settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' from django.core import mail # assume user creation signal sends a welcome email django_user_model.objects.create_user(username='alice', password='x') assert len(mail.outbox) == 1 assert 'Welcome' in mail.outbox[0].subject ``` The `settings` fixture reverts every attribute you set, regardless of whether the test passes or fails. This is why it's always preferred over patching `django.conf.settings` with `unittest.mock.patch`. For email testing specifically, the `mailoutbox` fixture covered in MAT1 is a cleaner alternative — it auto-configures the locmem backend and clears the outbox between tests automatically, without any manual `settings.EMAIL_BACKEND` setup. ## Testing with `rf` (RequestFactory) `rf` builds `HttpRequest` objects without going through URL routing or middleware. The test calls the view function directly: ```python from myapp.views import task_list @pytest.mark.django_db def test_task_list_with_rf(rf): from myapp.models import Task Task.objects.create(title='RF task') request = rf.get('/tasks/') response = task_list(request) assert response.status_code == 200 ``` Because `rf` bypasses URL routing and all request middleware — including authentication and session middleware — `request.user` is not populated from the session and defaults to `AnonymousUser`. **View decorators like `@login_required` still run** as part of the view call: with an anonymous user the decorator redirects. Set `request.user` to an authenticated user object before calling any view that checks it: ```python @pytest.mark.django_db def test_my_tasks_with_rf(rf, django_user_model): from myapp.views import my_tasks from myapp.models import Task user = django_user_model.objects.create_user(username='alice', password='x') Task.objects.create(title="Alice's task", owner=user) request = rf.get('/my-tasks/') request.user = user response = my_tasks(request) assert response.status_code == 200 ``` The tradeoff: `rf` tests are faster and test only view logic. `client` tests are slower but verify that routing, middleware, and authentication all work together. Use `rf` for unit-testing view functions; use `client` for integration-testing the full request cycle. ## Testing Django REST Framework APIs with `APIClient` If your project uses DRF, use `APIClient` from `rest_framework.test` instead of Django's `TestClient`. `APIClient` understands DRF's content negotiation, `response.data`, and authentication layers: ```python import pytest from rest_framework.test import APIClient @pytest.fixture def api_client(): return APIClient() @pytest.mark.django_db def test_task_list_returns_empty(api_client): response = api_client.get('/api/tasks/') assert response.status_code == 200 assert response.data['results'] == [] # .data is already parsed — no .json() needed ``` `response.data` is the parsed Python object produced by DRF's serializer — a dict, list, or `OrderedDict`. It's equivalent to calling `response.json()` but reflects DRF's serializer output directly, including nested relations. For authentication, use `force_authenticate()` instead of `force_login()`: ```python @pytest.mark.django_db def test_create_task_as_alice(api_client, django_user_model): user = django_user_model.objects.create_user(username='alice', password='x') api_client.force_authenticate(user=user) response = api_client.post('/api/tasks/', {'title': 'Deploy fix'}, format='json') assert response.status_code == 201 assert response.data['title'] == 'Deploy fix' ``` `force_authenticate(user=user)` sets the user directly at the DRF permission layer — no session, no token lookup, no password check. Use it to test view and serializer logic in isolation from authentication concerns. `force_login()` (on Django's `TestClient`) operates at the session level and is the right choice when your test specifically needs a session cookie. For DRF APIs protected by token or JWT, `force_authenticate()` is always simpler and faster.

pytest-django Reference

#
## pytest.ini setup ```ini [pytest] DJANGO_SETTINGS_MODULE = myproject.settings testpaths = tests addopts = -v --tb=short ``` ## Built-in fixtures | Fixture | Type | Purpose | |---|---|---| | `client` | `TestClient` | Full-stack HTTP — routing, middleware, views | | `admin_client` | `TestClient` | Pre-authenticated as Django superuser | | `rf` | `RequestFactory` | Request objects only — no routing, no middleware | | `db` | fixture | Grant DB access to a fixture body | | `settings` | `UserSettingsHolder` | Override Django settings; auto-reverts after test | | `django_user_model` | model class | Respects `AUTH_USER_MODEL`; works with custom users | | `mailoutbox` | list | Intercepts outgoing emails; auto-configures locmem backend for the test | | `live_server` | LiveServer | Starts a real WSGI server; access URL via `live_server.url`; for Selenium/Playwright | | `django_db_setup` | session fixture | Controls DB creation/teardown at session level | ## `@pytest.mark.django_db` options | Option | Default | Notes | |---|---|---| | `transaction=False` | ✓ | Wraps test in a rolled-back transaction (fast) | | `transaction=True` | | Flushes DB after test; needed for `on_commit`, `select_for_update` | | `reset_sequences=True` | | Resets auto-increment IDs; only with `transaction=True` | | `databases` | `['default']` | List of DB aliases the test may access | ## Authentication patterns ```python # Preferred in tests — bypasses password check client.force_login(user) # Also works — validates credentials against auth backend client.login(username='alice', password='secret') # For rf: set request.user manually (no middleware to set it) request.user = user ``` ## Fixture DB access patterns ```python # Option A — test-level marker (fixtures inherit access) @pytest.mark.django_db def test_something(): MyModel.objects.create(...) # Option B — fixture requests db explicitly @pytest.fixture def my_obj(db): return MyModel.objects.create(...) # Option C — transactional_db for transaction=True tests @pytest.fixture def my_obj(transactional_db): return MyModel.objects.create(...) ``` ## Common patterns ```python # Verify persistence (not just in-memory state) obj.refresh_from_db() Task.objects.get(pk=task.pk) # POST with JSON body client.post('/api/', data={'key': 'val'}, content_type='application/json') # Access response data (Django TestClient) data = response.json() assert data['key'] == 'value' # DRF APIClient: use response.data (parsed by serializer, no .json() needed) # assert response.data['key'] == 'value' # Check redirect location assert response.status_code == 302 assert response['Location'] == '/login/' ```
01

Test a Django view returns HTTP 200

#

You have a Django view `task_list` at URL `/tasks/` that returns a JSON response with a `results` key containing a list of tasks. Set up `pytest.ini` with `DJANGO_SETTINGS_MODULE` and write a test using the built-in `client` fixture that: 1. Calls `GET /tasks/` 2. Asserts the status code is 200 3. Asserts the response JSON contains a `results` key 4. Asserts `results` is a list

# pytest.ini
# [pytest]
# DJANGO_SETTINGS_MODULE = myproject.settings

# tests/test_views.py
import pytest


@pytest.mark.django_db
def test_task_list_returns_200(client):
    # call GET /tasks/ and assert status + structure
    pass
Solution
# pytest.ini
# [pytest]
# DJANGO_SETTINGS_MODULE = myproject.settings

# tests/test_views.py
import pytest


@pytest.mark.django_db
def test_task_list_returns_200(client):
    response = client.get('/tasks/')
    assert response.status_code == 200
    data = response.json()
    assert 'results' in data
    assert isinstance(data['results'], list)
02

Test model save and database persistence

#

You have a `Task` model with fields `title` (CharField) and `completed` (BooleanField, default=False). Write two tests: 1. **test_default_completed_is_false** — create a `Task`, then retrieve it from the database by primary key and assert `completed` is `False`. 2. **test_task_can_be_completed** — create a `Task` with `completed=False`, set `completed=True`, call `.save()`, then call `.refresh_from_db()` and assert the value persisted.

import pytest
from myapp.models import Task


@pytest.mark.django_db
def test_default_completed_is_false():
    # create and retrieve fresh from DB
    pass


@pytest.mark.django_db
def test_task_can_be_completed():
    # create, update, save, refresh, assert
    pass
Solution
import pytest
from myapp.models import Task


@pytest.mark.django_db
def test_default_completed_is_false():
    task = Task.objects.create(title='New task')
    task_from_db = Task.objects.get(pk=task.pk)
    assert task_from_db.completed is False


@pytest.mark.django_db
def test_task_can_be_completed():
    task = Task.objects.create(title='Finish report', completed=False)
    task.completed = True
    task.save()
    task.refresh_from_db()
    assert task.completed is True
03

Test an authenticated view with force_login

#

You have a view `my_tasks` at `/my-tasks/` decorated with `@login_required`. It returns only the tasks owned by the currently authenticated user as JSON `{'results': [...]}`. Write two tests: 1. **test_my_tasks_redirects_anonymous** — assert an unauthenticated GET returns 302. 2. **test_my_tasks_returns_only_owners_tasks** — create two users and one task per user. Log in as `user1` using `client.force_login()`. Assert status 200 and the response contains exactly the one task that belongs to `user1`.

import pytest
from myapp.models import Task


@pytest.mark.django_db
def test_my_tasks_redirects_anonymous(client):
    pass


@pytest.mark.django_db
def test_my_tasks_returns_only_owners_tasks(client, django_user_model):
    pass
Solution
import pytest
from myapp.models import Task


@pytest.mark.django_db
def test_my_tasks_redirects_anonymous(client):
    response = client.get('/my-tasks/')
    assert response.status_code == 302


@pytest.mark.django_db
def test_my_tasks_returns_only_owners_tasks(client, django_user_model):
    alice = django_user_model.objects.create_user(username='alice', password='x')
    bob = django_user_model.objects.create_user(username='bob', password='x')

    Task.objects.create(title="Alice's task", owner=alice)
    Task.objects.create(title="Bob's task", owner=bob)

    client.force_login(alice)
    response = client.get('/my-tasks/')
    assert response.status_code == 200
    results = response.json()['results']
    assert len(results) == 1
    assert results[0]['title'] == "Alice's task"
04

Override a Django setting for one test with the settings fixture

#

You have a utility function: ```python # myapp/utils.py from django.conf import settings def get_max_tasks(): return getattr(settings, 'MAX_TASKS_PER_USER', 10) ``` Write two tests: 1. **test_get_max_tasks_with_override** — use the `settings` fixture to set `MAX_TASKS_PER_USER = 3` and assert `get_max_tasks()` returns `3`. 2. **test_get_max_tasks_default** — do not use the `settings` fixture; assert `get_max_tasks()` returns the default `10`. Both tests should pass regardless of execution order.

# tests/test_utils.py
import pytest
from myapp.utils import get_max_tasks


def test_get_max_tasks_with_override(settings):
    pass


def test_get_max_tasks_default():
    pass
Solution
# tests/test_utils.py
import pytest
from myapp.utils import get_max_tasks


def test_get_max_tasks_with_override(settings):
    settings.MAX_TASKS_PER_USER = 3
    assert get_max_tasks() == 3


def test_get_max_tasks_default():
    assert get_max_tasks() == 10
05

Test a view function directly with RequestFactory

#

You have the `task_list` view function (not the URL — the function itself). Write a test using the `rf` fixture (RequestFactory) that: 1. Creates two `Task` objects in the database. 2. Builds a GET request with `rf.get('/tasks/')`. 3. Calls the view function directly: `response = task_list(request)`. 4. Asserts status 200 and the JSON response contains exactly 2 tasks. Then write a second test for the authenticated `my_tasks` view. Since `rf` skips middleware, `request.user` is not set automatically — assign it manually.

import json
import pytest
from myapp.views import task_list, my_tasks
from myapp.models import Task


@pytest.mark.django_db
def test_task_list_with_rf(rf):
    pass


@pytest.mark.django_db
def test_my_tasks_with_rf(rf, django_user_model):
    pass
Solution
import json
import pytest
from myapp.views import task_list, my_tasks
from myapp.models import Task


@pytest.mark.django_db
def test_task_list_with_rf(rf):
    Task.objects.create(title='First')
    Task.objects.create(title='Second')

    request = rf.get('/tasks/')
    response = task_list(request)

    assert response.status_code == 200
    data = json.loads(response.content)
    assert len(data['results']) == 2


@pytest.mark.django_db
def test_my_tasks_with_rf(rf, django_user_model):
    user = django_user_model.objects.create_user(username='alice', password='x')
    Task.objects.create(title="Alice's task", owner=user)

    request = rf.get('/my-tasks/')
    request.user = user
    response = my_tasks(request)

    assert response.status_code == 200
    data = json.loads(response.content)
    assert len(data['results']) == 1