Django-Admin.ru

Тестирование Django с pytest-django

Pytest и Django: установка pytest-django, DJANGO_SETTINGS_MODULE, маркер django_db, фикстуры client и admin_client, тесты моделей, views, форм, DRF и CI.

Редакция Django-Admin.ru
· 24 мин чтения

Содержание · 17 разделов
  1. Установка pytest-django
  2. Настройка: DJANGO_SETTINGS_MODULE и конфиг pytest
  3. База данных в тестах: маркер django_db
  4. Транзакции и откат после каждого теста
  5. Не проверяйте конкретные id
  6. Данные на всю сессию
  7. Встроенные фикстуры pytest-django
  8. Тесты моделей
  9. Тесты views: client, admin_client и rf
  10. Тесты форм
  11. Настройки на время теста: фикстура settings
  12. Проверка писем: mailoutbox
  13. Число SQL-запросов: django_assert_num_queries
  14. Тестирование API на Django REST Framework
  15. Фабрики тестовых данных: factory_boy и model_bakery
  16. Ускорение тестов
  17. Быстрый хешер паролей
  18. —reuse-db: не пересоздавать тестовую базу
  19. Параллельный запуск: pytest-xdist
  20. pytest-django или manage.py test
  21. Запуск тестов в CI: GitHub Actions
  22. Частые вопросы
  23. Как запустить тесты одного приложения или один тест?
  24. Почему тест падает с ScopeMismatch?
  25. Как тестировать асинхронные views?
  26. Как посмотреть покрытие кода тестами?
  27. Как найти ошибки в переменных шаблонов?
  28. Можно ли тестировать на SQLite, если в продакшне PostgreSQL?
  29. Итог

Чтобы запускать тесты Django через pytest, установите плагин pytest-django, укажите модуль настроек в pyproject.toml (DJANGO_SETTINGS_MODULE = "config.settings") и пометьте тесты, которым нужна база данных, маркером @pytest.mark.django_db. Запускаются тесты командой pytest вместо python manage.py test:

# pyproject.toml
[tool.pytest]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["test_*.py", "*_test.py", "tests.py"]
# tests/test_models.py
from decimal import Decimal

import pytest

from catalog.models import Category, Product


@pytest.mark.django_db
def test_price_with_vat():
    books = Category.objects.create(name="Книги", slug="books")
    product = Product.objects.create(category=books, name="Django для профи", price=Decimal("1500.00"))

    assert str(product) == "Django для профи"
    assert product.price_with_vat == Decimal("1800.00")
$ pytest
============================= test session starts =============================
platform win32 -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0
django: version: 6.1.2, settings: config.settings (from ini)
rootdir: C:\projects\shop
configfile: pyproject.toml
plugins: django-4.14.0
collected 1 item

tests\test_models.py .                                                   [100%]

============================== 1 passed in 0.22s ==============================

Строка django: version: 6.1.2, settings: config.settings подтверждает, что плагин нашёл настройки. Дальше — конфигурация, работа с тестовой базой и транзакциями, встроенные фикстуры, тесты моделей, views, форм и API на Django REST Framework, фабрики данных, ускорение прогона, сравнение с manage.py test и запуск в CI. Основы самого pytest (assert, фикстуры, параметризация, метки) разобраны в статье pytest: тестирование кода на Python, здесь — только то, что добавляет Django. Все примеры проверены на Django 6.1.2, pytest 9.1.1, pytest-django 4.14.0 и Python 3.12.

Установка pytest-django

Плагин ставится в виртуальное окружение проекта вместе с pytest:

# Windows
py -m pip install pytest pytest-django
# macOS / Linux
python3 -m pip install pytest pytest-django

pytest-django 4.14 работает на Python 3.10 и новее и официально поддерживает Django 5.2 LTS и 6.0; на Django 6.1 примеры статьи тоже проходят. Отдельно подключать плагин не нужно: после установки pytest находит его сам и показывает в строке plugins:.

Для примеров используется проект интернет-магазина: проект config, приложение catalog с моделями Category и Product, страницами каталога, формой обратной связи и API. Если вы только начинаете с Django, сначала пройдите Django для начинающих: там по шагам создаются проект, приложение, модели и views.

shop/
├── catalog/
│   ├── models.py, views.py, forms.py, urls.py
│   ├── api.py, serializers.py, services.py
│   └── templates/catalog/
├── config/
│   ├── settings.py
│   └── urls.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── factories.py
│   └── test_*.py
├── manage.py
└── pyproject.toml
# catalog/models.py
from decimal import Decimal

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(unique=True)

    def __str__(self):
        return self.name


class ProductQuerySet(models.QuerySet):
    def published(self):
        return self.filter(is_published=True)


class Product(models.Model):
    category = models.ForeignKey(
        Category, on_delete=models.PROTECT, related_name="products"
    )
    name = models.CharField(max_length=200)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    is_published = models.BooleanField(default=True)

    objects = ProductQuerySet.as_manager()

    def __str__(self):
        return self.name

    @property
    def price_with_vat(self) -> Decimal:
        return (self.price * Decimal("1.20")).quantize(Decimal("0.01"))

Настройка: DJANGO_SETTINGS_MODULE и конфиг pytest

Без указания настроек тесты падают ещё при сборе: модели нельзя импортировать, пока Django не сконфигурирован.

E   django.core.exceptions.ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.
=========================== short test summary info ===========================
ERROR tests/test_models.py - django.core.exceptions.ImproperlyConfigured: Req...
!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!

Модуль настроек задают одним из трёх способов. Если указано несколько, побеждает верхний в списке:

  1. Флаг командной строки: pytest --ds=config.settings.
  2. Переменная окружения DJANGO_SETTINGS_MODULE.
  3. Ключ DJANGO_SETTINGS_MODULE в файле конфигурации pytest: pyproject.toml, pytest.ini, tox.ini или setup.cfg.

Удобнее всего третий вариант — настройки хранятся в репозитории и одинаковы у всех разработчиков:

# pyproject.toml — pytest 9 и новее
[tool.pytest]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["test_*.py", "*_test.py", "tests.py"]

Таблица [tool.pytest] появилась в pytest 9. Для pytest 8 используйте [tool.pytest.ini_options] с теми же ключами или файл pytest.ini:

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = test_*.py *_test.py tests.py

Строка python_files важна для проектов, созданных командой startapp: она создаёт в приложении файл tests.py, а pytest по умолчанию ищет только test_*.py и *_test.py. Без этой настройки тесты из tests.py молча не запускаются.

Путь импорта pytest-django настраивает сам: находит manage.py рядом с тестами или в родительских папках и добавляет его каталог в sys.path. Поэтому from catalog.models import Product работает без pip install -e .. Если проект устроен иначе (несколько manage.py, раскладка src/), отключите автопоиск ключом django_find_project = false и укажите путь через настройку pytest pythonpath = ["src"].

Совет: отдельные настройки для тестов (быстрый хешер паролей, локальная почта, тестовые ключи API) удобно держать в модуле config/settings_test.py, который импортирует всё из основного файла и переопределяет нужное. Тогда в конфиге pytest указывают DJANGO_SETTINGS_MODULE = "config.settings_test". Как разделять настройки по окружениям — в статье про settings.py в Django.

База данных в тестах: маркер django_db

pytest-django по умолчанию запрещает тестам обращаться к базе. Тот же тест без маркера падает с понятной подсказкой:

E       RuntimeError: Database access not allowed, use the "django_db" mark, or the "db" or "transactional_db" fixtures to enable it.

Запрет помогает видеть, какие тесты зависят от базы: тесты чистой логики (форм без моделей, утилит, сериализации) остаются быстрыми. Доступ открывают тремя способами:

import pytest

# 1. Маркер на одном тесте
@pytest.mark.django_db
def test_one(): ...

# 2. Маркер на всех тестах модуля — строка в начале файла
pytestmark = pytest.mark.django_db

# 3. Маркер на классе
@pytest.mark.django_db
class TestCatalog:
    def test_two(self): ...

Фикстуре, которая сама создаёт записи, маркер теста не поможет: фикстура должна запросить встроенную фикстуру db.

@pytest.fixture
def category(db):
    return Category.objects.create(name="Книги", slug="books")

Фикстура, которая зависит от category, получает доступ к базе через неё и db уже не требует.

Транзакции и откат после каждого теста

Каждый тест с django_db выполняется внутри транзакции, которая откатывается после теста, — так же работает django.test.TestCase. Тестовая база создаётся один раз за прогон, а данные одного теста не видны следующему:

# tests/test_transactions.py
import pytest

from catalog.models import Category


@pytest.mark.django_db
def test_first():
    Category.objects.create(name="Книги", slug="books")
    assert Category.objects.count() == 1


@pytest.mark.django_db
def test_second():
    # Категория из test_first откатилась вместе с транзакцией
    assert Category.objects.count() == 0

Обратная сторона — транзакция теста никогда не фиксируется, поэтому обработчики transaction.on_commit() не вызываются. Пусть функция публикует товар и после фиксации отправляет письмо редактору:

# catalog/services.py
from django.core.mail import send_mail
from django.db import transaction


def publish_product(product):
    product.is_published = True
    product.save(update_fields=["is_published"])
    transaction.on_commit(
        lambda: send_mail(
            subject=f"Опубликован товар: {product.name}",
            message="",
            from_email="noreply@shop.local",
            recipient_list=["editor@shop.local"],
        )
    )

Проверить такой код можно двумя способами: перехватить колбэки фикстурой django_capture_on_commit_callbacks или включить настоящие транзакции через transaction=True.

from catalog.services import publish_product


@pytest.mark.django_db
def test_on_commit_is_not_called(product, mailoutbox):
    publish_product(product)
    assert len(mailoutbox) == 0  # транзакция теста не фиксируется


@pytest.mark.django_db
def test_on_commit_captured(product, mailoutbox, django_capture_on_commit_callbacks):
    with django_capture_on_commit_callbacks(execute=True) as callbacks:
        publish_product(product)

    assert len(callbacks) == 1
    assert mailoutbox[0].subject == "Опубликован товар: Django для профи"


@pytest.mark.django_db(transaction=True)
def test_on_commit_real_transaction(product, mailoutbox):
    publish_product(product)
    assert len(mailoutbox) == 1
tests/test_transactions.py::test_first PASSED                            [ 20%]
tests/test_transactions.py::test_second PASSED                           [ 40%]
tests/test_transactions.py::test_on_commit_is_not_called PASSED          [ 60%]
tests/test_transactions.py::test_on_commit_captured PASSED               [ 80%]
tests/test_transactions.py::test_on_commit_real_transaction PASSED       [100%]

Тест с transaction=True соответствует TransactionTestCase: после него Django очищает все таблицы, а это заметно медленнее отката. Используйте его только там, где нужна реальная фиксация: on_commit без перехвата, select_for_update(), работа с базой из другого потока или процесса, фикстура live_server.

Параметры django_dbАналог в DjangoКогда нужен
без параметровTestCaseбольшинство тестов
transaction=TrueTransactionTestCaseреальные коммиты, on_commit, потоки
reset_sequences=TrueTransactionTestCase.reset_sequencesсброс счётчиков id; работает только вместе с transaction=True
serialized_rollback=Trueserialized_rollbackвернуть данные из data-миграций после очистки таблиц
databases=["default", "other"]databasesнесколько баз в DATABASES; "__all__" — все сразу

Не проверяйте конкретные id

На SQLite откат транзакции возвращает и счётчик первичных ключей, поэтому каждый тест получает объект с pk == 1. В PostgreSQL последовательности не откатываются: в следующем тесте новый объект получит pk == 2. Тест с assert response.json()["id"] == 1 проходит локально на SQLite и падает в CI на PostgreSQL или при параллельном запуске. Сравнивайте с product.pk из фикстуры или ищите объект по уникальному полю (slug, email).

Данные на всю сессию

Фикстуру db нельзя запросить из фикстуры со scope="session": pytest ответит ScopeMismatch: You tried to access the function scoped fixture db with a session scoped request object. Справочные данные, которые нужны всем тестам, создают при подготовке тестовой базы — переопределив фикстуру django_db_setup в conftest.py:

# tests/conftest.py
@pytest.fixture(scope="session")
def django_db_setup(django_db_setup, django_db_blocker):
    with django_db_blocker.unblock():
        Category.objects.get_or_create(slug="electronics", defaults={"name": "Электроника"})

Эти записи создаются вне транзакции теста. Если тест их удалит, следующий тест увидит их снова: удаление откатится вместе с транзакцией теста. get_or_create вместо create нужен для запуска с --reuse-db, когда база с прошлого прогона уже содержит запись.

Встроенные фикстуры pytest-django

Плагин добавляет фикстуры, которые заменяют методы и атрибуты django.test.TestCase. Тест получает фикстуру, указав её имя в аргументах.

ФикстураЧто даёт
clientтестовый клиент django.test.Client: запросы через URL, middleware и view
admin_clientклиент, вошедший как суперпользователь; доступ к базе включается сам
admin_userсуперпользователь admin с паролем password
django_user_modelмодель пользователя из AUTH_USER_MODEL
rfRequestFactory: объект запроса для прямого вызова view
async_client, async_rfасинхронные версии клиента и фабрики запросов
settingsизменение настроек на время теста с автоматическим возвратом
mailoutboxсписок писем, отправленных во время теста
django_assert_num_queriesпроверка точного числа SQL-запросов
django_assert_max_num_queriesпроверка, что запросов не больше заданного числа
django_capture_on_commit_callbacksперехват колбэков transaction.on_commit()
live_serverнастоящий HTTP-сервер в отдельном потоке для Selenium и Playwright
db, transactional_dbдоступ к базе для собственных фикстур

Общие фикстуры проекта кладут в tests/conftest.py. Для примеров ниже он выглядит так:

# tests/conftest.py
from decimal import Decimal

import pytest
from rest_framework.test import APIClient

from catalog.models import Category, Product


@pytest.fixture
def category(db):
    return Category.objects.create(name="Книги", slug="books")


@pytest.fixture
def product(category):
    return Product.objects.create(
        category=category, name="Django для профи", price=Decimal("1500.00")
    )


@pytest.fixture
def user(django_user_model):
    return django_user_model.objects.create_user(username="anna", password="s3cret-pass")


@pytest.fixture
def api_client():
    return APIClient()


@pytest.fixture(autouse=True)
def fast_password_hasher(settings):
    settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

Фикстура user создаётся через django_user_model, а не через прямой импорт User: так тесты не сломаются, если в проекте своя модель пользователя. Зачем нужна последняя фикстура — в разделе про ускорение.

Тесты моделей

Модель тестируют как обычный Python-класс: создают объект и проверяют методы, свойства, менеджеры и ограничения. Вызов Django ORM требует доступа к базе.

# tests/test_models.py
import pytest
from django.db import IntegrityError

from catalog.models import Category, Product

pytestmark = pytest.mark.django_db


def test_price_with_vat(product):
    assert product.price_with_vat == Decimal("1800.00")


def test_published_manager(product):
    Product.objects.create(category=product.category, name="Черновик", price=100, is_published=False)

    assert list(Product.objects.published()) == [product]


def test_category_slug_is_unique(category):
    with pytest.raises(IntegrityError):
        Category.objects.create(name="Другие книги", slug="books")

Проверять, что Django сохраняет CharField, не нужно — это уже покрыто тестами самого фреймворка. Тестируйте свою логику: вычисляемые свойства, методы save() и clean(), собственные QuerySet, ограничения unique и CheckConstraint.

Тесты views: client, admin_client и rf

Фикстура client отправляет запрос через весь стек Django: маршрутизацию, middleware, view и шаблон. Утверждения Django assertContains, assertRedirects, assertTemplateUsed и остальные из TestCase доступны в модуле pytest_django.asserts.

# tests/test_views.py
import pytest
from django.urls import reverse
from pytest_django.asserts import assertContains, assertRedirects, assertTemplateUsed

from catalog.views import product_detail

pytestmark = pytest.mark.django_db


def test_product_list(client, product):
    response = client.get(reverse("product_list"))

    assert response.status_code == 200
    assertTemplateUsed(response, "catalog/product_list.html")
    assertContains(response, "Django для профи")


def test_hidden_product_returns_404(client, product):
    product.is_published = False
    product.save()

    response = client.get(reverse("product_detail", args=[product.pk]))

    assert response.status_code == 404


def test_dashboard_requires_login(client):
    response = client.get(reverse("dashboard"))

    assertRedirects(response, "/accounts/login/?next=/dashboard/", fetch_redirect_response=False)


def test_dashboard_for_logged_in_user(client, user):
    client.force_login(user)

    response = client.get(reverse("dashboard"))

    assert response.status_code == 200


def test_admin_product_list(admin_client, product):
    response = admin_client.get("/admin/catalog/product/")

    assertContains(response, "Django для профи")


def test_product_detail_view_directly(rf, product):
    request = rf.get(f"/products/{product.pk}/")

    response = product_detail(request, pk=product.pk)

    assert response.status_code == 200
    assert "1800.00" in response.content.decode()
$ pytest tests/test_views.py -v
tests/test_views.py::test_product_list PASSED                            [ 16%]
tests/test_views.py::test_hidden_product_returns_404 PASSED              [ 33%]
tests/test_views.py::test_dashboard_requires_login PASSED                [ 50%]
tests/test_views.py::test_dashboard_for_logged_in_user PASSED            [ 66%]
tests/test_views.py::test_admin_product_list PASSED                      [ 83%]
tests/test_views.py::test_product_detail_view_directly PASSED            [100%]
  • client.force_login(user) авторизует без проверки пароля и быстрее, чем client.login(username=..., password=...).
  • admin_client подходит для проверки страниц админки: списков, форм, своих действий.
  • rf создаёт голый объект запроса: middleware не выполняются, поэтому request.user, сессии и сообщения нужно задать вручную (request.user = user). Такой тест быстрее и проверяет одну функцию view изолированно.

Тесты форм

Форму проверяют без HTTP-запроса: передают словарь data, вызывают is_valid() и смотрят errors или cleaned_data. Форме без моделей база не нужна, ModelForm с полем ForeignKey — нужна, потому что проверяет существование связанной записи.

# catalog/forms.py
from django import forms

from .models import Product


class ProductForm(forms.ModelForm):
    class Meta:
        model = Product
        fields = ["category", "name", "price"]

    def clean_price(self):
        price = self.cleaned_data["price"]
        if price <= 0:
            raise forms.ValidationError("Цена должна быть больше нуля")
        return price


class FeedbackForm(forms.Form):
    email = forms.EmailField()
    message = forms.CharField(min_length=10)
# tests/test_forms.py
from decimal import Decimal

import pytest

from catalog.forms import FeedbackForm, ProductForm


@pytest.mark.django_db
def test_product_form_valid(category):
    form = ProductForm(data={"category": category.pk, "name": "Чайник", "price": "2490"})

    assert form.is_valid(), form.errors
    product = form.save()
    assert product.price == Decimal("2490")


@pytest.mark.django_db
def test_product_form_rejects_zero_price(category):
    form = ProductForm(data={"category": category.pk, "name": "Чайник", "price": "0"})

    assert not form.is_valid()
    assert form.errors["price"] == ["Цена должна быть больше нуля"]


@pytest.mark.parametrize(
    ("data", "field"),
    [
        ({"email": "не-почта", "message": "Длинное сообщение"}, "email"),
        ({"email": "anna@example.com", "message": "Коротко"}, "message"),
    ],
)
def test_feedback_form_errors(data, field):
    form = FeedbackForm(data=data)

    assert not form.is_valid()
    assert field in form.errors

form.errors вторым аргументом assert выводит ошибки формы в отчёт, если тест упал. Параметризация удобна для набора невалидных данных: каждый вариант — отдельный тест. Подробнее о валидации — в статье про формы Django.

Настройки на время теста: фикстура settings

Изменения через фикстуру settings действуют только внутри теста, после него pytest-django возвращает прежние значения:

# tests/test_settings_fixture.py
from django.urls import reverse


def test_custom_login_url(client, settings):
    settings.LOGIN_URL = "/login/"

    response = client.get(reverse("dashboard"))

    assert response.url == "/login/?next=/dashboard/"


def test_login_url_restored(client):
    response = client.get(reverse("dashboard"))

    assert response.url == "/accounts/login/?next=/dashboard/"

Фикстура с autouse=True в conftest.py применяет настройку ко всем тестам — так в примере выше подключён быстрый хешер паролей. Учтите, что тесты всегда идут с DEBUG = False, даже если в settings.py стоит True: так тесты ближе к продакшну. Оставить значение из настроек можно ключом django_debug_mode = "keep" в конфиге pytest.

Проверка писем: mailoutbox

Во время тестов Django подменяет почтовый бэкенд на locmem: письма никуда не уходят, а складываются в память. Фикстура mailoutbox возвращает их список, пустой в начале каждого теста.

# catalog/views.py (фрагмент)
def feedback(request):
    form = FeedbackForm(request.POST or None)
    if form.is_valid():
        send_mail(
            subject="Новое сообщение с сайта",
            message=form.cleaned_data["message"],
            from_email="noreply@shop.local",
            recipient_list=["support@shop.local"],
        )
        return redirect("product_list")
    return render(request, "catalog/feedback.html", {"form": form})
# tests/test_mail.py
from django.urls import reverse


def test_feedback_sends_email(client, mailoutbox):
    response = client.post(
        reverse("feedback"),
        {"email": "anna@example.com", "message": "Когда будет скидка?"},
    )

    assert response.status_code == 302
    assert len(mailoutbox) == 1
    assert mailoutbox[0].subject == "Новое сообщение с сайта"
    assert mailoutbox[0].to == ["support@shop.local"]

Этому тесту маркер django_db не нужен: view не обращается к базе. У каждого письма есть атрибуты subject, body, from_email, to, cc, bcc и attachments.

Число SQL-запросов: django_assert_num_queries

Фикстура django_assert_num_queries — аналог assertNumQueries из TestCase. Она ловит проблему N+1: страница списка делает запрос за товарами и ещё по запросу за категорией каждого товара.

# tests/test_queries.py
import pytest
from django.urls import reverse

from catalog.models import Product


@pytest.mark.django_db
def test_product_list_has_no_n_plus_one(client, category, django_assert_num_queries):
    for i in range(5):
        Product.objects.create(category=category, name=f"Товар {i}", price=100)

    with django_assert_num_queries(1):
        client.get(reverse("product_list"))

Если убрать из view select_related("category"), тест упадёт и покажет фактическое число запросов, а с флагом -v — и сам SQL:

E               Failed: Expected to perform 1 queries but 6 were done (add -v option to show queries)

Тест закрепляет оптимизацию: если кто-то потом уберёт select_related, это сразу будет видно. Когда точное число запросов неважно, используйте django_assert_max_num_queries(3) — тест упадёт, только если запросов больше. Как находить и устранять такие запросы — в статье про N+1 в Django ORM.

Тестирование API на Django REST Framework

Для DRF нужен APIClient из rest_framework.test: он отправляет JSON через format="json" и умеет авторизовать без пароля и токена методом force_authenticate(). Готовой фикстуры для него в pytest-django нет, поэтому её объявляют в conftest.py (фикстура api_client выше). API в примере — ModelViewSet с правами IsAuthenticatedOrReadOnly по адресу /api/products/.

# tests/test_api.py
import pytest

pytestmark = pytest.mark.django_db


def test_list_products(api_client, product):
    response = api_client.get("/api/products/")

    assert response.status_code == 200
    assert response.json() == [
        {
            "id": product.pk,
            "category": product.category_id,
            "name": "Django для профи",
            "price": "1500.00",
            "is_published": True,
        }
    ]


def test_anonymous_cannot_create(api_client, category):
    response = api_client.post(
        "/api/products/",
        {"category": category.pk, "name": "Чайник", "price": "2490.00"},
        format="json",
    )

    assert response.status_code == 403


def test_user_creates_product(api_client, user, category):
    api_client.force_authenticate(user=user)

    response = api_client.post(
        "/api/products/",
        {"category": category.pk, "name": "Чайник", "price": "2490.00"},
        format="json",
    )

    assert response.status_code == 201
    assert response.data["name"] == "Чайник"

Анонимный запрос получает 403, а не 401. По умолчанию первым классом аутентификации в DRF стоит SessionAuthentication, он не задаёт заголовок WWW-Authenticate, и DRF отвечает «доступ запрещён». Если первым указать TokenAuthentication или JWT, тот же запрос получит 401. В response.data лежат данные до рендеринга в JSON, в response.json() — разобранный ответ. Сериализаторы, права и APITestCase подробно разобраны в руководстве по Django REST Framework.

Фабрики тестовых данных: factory_boy и model_bakery

Когда у модели много обязательных полей и связей, фикстуры с objects.create() разрастаются. Фабрика создаёт объект с разумными значениями по умолчанию, а тест задаёт только то, что для него важно. Самые распространённые библиотеки — factory_boy и model_bakery.

pip install factory_boy model_bakery

factory_boy описывает фабрику классом: какие значения ставить в поля и как создавать связанные объекты.

# tests/factories.py
from decimal import Decimal

import factory
from factory.django import DjangoModelFactory

from catalog.models import Category, Product


class CategoryFactory(DjangoModelFactory):
    class Meta:
        model = Category
        django_get_or_create = ["slug"]

    name = factory.Sequence(lambda n: f"Категория {n}")
    slug = factory.Sequence(lambda n: f"category-{n}")


class ProductFactory(DjangoModelFactory):
    class Meta:
        model = Product

    category = factory.SubFactory(CategoryFactory)
    name = factory.Faker("sentence", nb_words=3, locale="ru_RU")
    price = Decimal("990.00")

model_bakery не требует классов: baker.make() сам заполняет обязательные поля случайными значениями и создаёт связанные объекты.

# tests/test_factories.py
import pytest
from django.urls import reverse
from model_bakery import baker

from catalog.models import Product
from tests.factories import ProductFactory

pytestmark = pytest.mark.django_db


def test_list_shows_only_published(client):
    ProductFactory.create_batch(3)
    hidden = ProductFactory(is_published=False)

    response = client.get(reverse("product_list"))

    assert len(response.context["products"]) == 3
    assert hidden not in response.context["products"]


def test_with_model_bakery(client):
    baker.make(Product, is_published=True, _quantity=3)
    baker.make(Product, is_published=False)

    response = client.get(reverse("product_list"))

    assert len(response.context["products"]) == 3
Критерийfactory_boymodel_bakery
Подготовкакласс фабрики на каждую модельне нужна: baker.make(Product)
Значения полейзадаёте сами: Sequence, Faker, LazyAttributeслучайные строки и числа, можно переопределить
СвязиSubFactory, RelatedFactoryсоздаются автоматически
Без сохранения в базуProductFactory.build()baker.prepare(Product)
Несколько объектовcreate_batch(3)_quantity=3
Повторно используемые наборынаследование фабрик, Traitрецепты Recipe
Читаемость данных в отчётеосмысленные значенияслучайные строки вроде lWisqGYlqbyb...

model_bakery быстрее подключить: тест пишется сразу, без описания фабрик. factory_boy требует больше кода, зато данные предсказуемы и читаемы, а логика создания сложных объектов хранится в одном месте — это окупается в крупных проектах. С factory_boy удобно сочетать плагин pytest-factoryboy, который превращает фабрики в фикстуры.

Важно: случайные значения не должны нарушать ограничения модели. Для полей с unique=True в factory_boy используйте factory.Sequence, а не Faker: два случайных слова иногда совпадают, и тест падает с IntegrityError раз в сотню прогонов.

Ускорение тестов

Быстрый хешер паролей

Самый заметный выигрыш даёт замена хешера паролей. В Django 6.1 PBKDF2 выполняет 1 500 000 итераций — это защита от перебора, но в тестах каждое create_user() и admin_user стоит заметного времени. Замер на 20 тестах, каждый из которых создаёт пользователя:

ХешерВремя прогона
PBKDF2 (по умолчанию)22,31 с
MD5PasswordHasher0,69 с

Хешер меняют фикстурой fast_password_hasher из conftest.py выше или строкой PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] в настройках для тестов. MD5 подходит только для тестов: в рабочих настройках оставьте хешер по умолчанию.

—reuse-db: не пересоздавать тестовую базу

По умолчанию pytest-django при каждом запуске создаёт тестовую базу, применяет все миграции и удаляет её в конце. Флаг --reuse-db оставляет базу после прогона и использует её в следующий раз:

[tool.pytest]
DJANGO_SETTINGS_MODULE = "config.settings"
addopts = ["--reuse-db"]
  • Новые миграции при --reuse-db применяются: Django выполняет migrate и для сохранённой базы. Изменения уже применённых миграций (правка файла, squashmigrations, переключение на ветку с другой историей) он не видит — тест упадёт с ошибкой вроде table catalog_product has no column named article. В таком случае один раз запустите pytest --create-db.
  • Для SQLite тестовая база по умолчанию создаётся в памяти, поэтому --reuse-db ничего не ускоряет. Флаг полезен с PostgreSQL и MySQL или с SQLite, если в DATABASES["default"]["TEST"]["NAME"] указан файл.
  • В CI --reuse-db не нужен: каждый запуск начинается с чистого окружения.

Флаг --no-migrations создаёт таблицы прямо по моделям, без прогона миграций. Это быстрее на проекте с сотнями миграций, но ошибки в самих миграциях и данные из data-миграций такой прогон не увидит. Поэтому в CI тесты запускают с миграциями.

Параллельный запуск: pytest-xdist

pip install pytest-xdist
pytest -n auto

pytest-django создаёт для каждого процесса свою тестовую базу и добавляет к её имени суффикс воркера (gw0, gw1 и т. д.). Поэтому тесты не должны зависеть от порядка выполнения и от конкретных id. На маленьком наборе запуск процессов съедает выигрыш: 28 тестов примера с -n 4 шли от 4 до 8 с против 1,6 с в один поток. Параллельность окупается на сотнях тестов или медленных интеграционных проверках.

pytest-django или manage.py test

Стандартный python manage.py test запускает тесты на unittest: классы-наследники django.test.TestCase с методами self.assertEqual, self.assertContains и подготовкой в setUp и setUpTestData.

Критерийpytest-djangomanage.py test
Установкаpip install pytest pytest-djangoвходит в Django
Форма тестафункция или класс, обычный assertкласс TestCase, методы assert...
Подготовка данныхфикстуры с зависимостями, conftest.pysetUp, setUpTestData, JSON-фикстуры
Доступ к базетолько с django_db или фикстурой dbу любого TestCase
Параметризация@pytest.mark.parametrizesubTest в цикле
Повторное использование базы--reuse-db--keepdb
Параллельный запускpytest-xdist: -n autoвстроенный --parallel
Запуск тестов другого стилязапускает и классы TestCaseфункции pytest не видит

Переход на pytest не требует переписывать тесты. Существующий класс TestCase pytest-django выполняет как есть — с базой, откатом и setUpTestData:

# tests/test_legacy.py
from django.test import TestCase

from catalog.models import Category


class CategoryTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.category = Category.objects.create(name="Книги", slug="books")

    def test_str(self):
        self.assertEqual(str(self.category), "Книги")
$ pytest tests/test_legacy.py -v
tests/test_legacy.py::CategoryTests::test_str PASSED                     [100%]

Обратное не работает: manage.py test в том же проекте находит только этот класс, а 27 тестов-функций пропускает:

$ python manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

Поэтому после перехода запускайте тесты только через pytest. Новые тесты пишите функциями, старые классы переводите постепенно — или не переводите вовсе.

Совет: при общем прогоне pytest-django меняет порядок: сначала идут тесты с django_db, затем с transaction=True, в конце — тесты без базы. Это нужно для корректной очистки базы, так что не удивляйтесь, если файл встречается в отчёте дважды.

Запуск тестов в CI: GitHub Actions

В CI тесты гоняют на той же СУБД, что и в продакшне, — обычно PostgreSQL. Пусть настройки базы читаются из переменных окружения:

# config/settings.py (фрагмент)
import os

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ.get("DB_HOST", "localhost"),
        "NAME": os.environ.get("DB_NAME", "shop"),
        "USER": os.environ.get("DB_USER", "shop"),
        "PASSWORD": os.environ.get("DB_PASSWORD", ""),
    }
}

Тогда workflow GitHub Actions поднимает PostgreSQL сервисным контейнером и передаёт параметры подключения через env:

# .github/workflows/tests.yml
name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: shop
          POSTGRES_PASSWORD: shop
          POSTGRES_DB: shop
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 5
    env:
      DB_HOST: localhost
      DB_NAME: shop
      DB_USER: shop
      DB_PASSWORD: shop
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v6
        with:
          python-version: '3.12'
          cache: pip
      - run: pip install -r requirements.txt
      - run: python manage.py makemigrations --check --dry-run
      - run: pytest -ra

Шаг makemigrations --check --dry-run завершается с ошибкой, если кто-то изменил модели и забыл создать миграцию. pytest возвращает ненулевой код при упавших тестах, поэтому отдельная проверка результата не нужна. Пользователю shop Django сам создаст базу test_shop: у владельца базы в официальном образе PostgreSQL есть права суперпользователя. Как настроить DATABASES для PostgreSQL через переменные окружения — в статье про Django и PostgreSQL.

Частые вопросы

Как запустить тесты одного приложения или один тест?

Передайте pytest путь к папке, файлу или тесту: pytest tests/test_api.py, pytest tests/test_api.py::test_list_products. Фильтр по имени — pytest -k "api and not anonymous". Если тесты лежат внутри приложений, запуск тестов одного приложения — pytest catalog/.

Почему тест падает с ScopeMismatch?

Фикстура со scope="module" или "session" запросила db, у которой область — функция. Для общих справочных данных переопределите django_db_setup и создавайте записи внутри django_db_blocker.unblock() (пример в разделе про базу данных) или оставьте фикстуре область по умолчанию.

Как тестировать асинхронные views?

Установите pytest-asyncio и используйте фикстуру async_client. Тест пишется как async def с метками @pytest.mark.asyncio и @pytest.mark.django_db, запрос — response = await async_client.get("/"). Синхронные view через async_client тоже работают: Django выполняет их в отдельном потоке.

Как посмотреть покрытие кода тестами?

Установите pytest-cov и запустите pytest --cov=catalog --cov-report=term-missing. В колонке Missing будут строки, которые тесты не выполнили. HTML-отчёт строит флаг --cov-report=html.

Как найти ошибки в переменных шаблонов?

Запустите тесты с флагом --fail-on-template-vars: тест, отрисовавший шаблон с несуществующей переменной (например, опечатка {{ prodcut.name }}), упадёт. Без флага Django молча подставляет пустую строку. Отдельные тесты можно исключить меткой @pytest.mark.ignore_template_errors.

Можно ли тестировать на SQLite, если в продакшне PostgreSQL?

Для быстрой локальной проверки — да, но часть различий SQLite не покажет: поведение счётчиков id, учёт регистра в contains и startswith, блокировки select_for_update() (в SQLite они ничего не делают). Поля из django.contrib.postgres, например ArrayField, на SQLite не работают вовсе. Запускайте полный набор на той же СУБД, что и в продакшне, хотя бы в CI.

Итог

  • Установите pytest и pytest-django, задайте DJANGO_SETTINGS_MODULE в pyproject.toml или pytest.ini и добавьте tests.py в python_files, если тесты лежат в приложениях.
  • База в тестах закрыта по умолчанию: открывайте её маркером @pytest.mark.django_db, а в фикстурах — запросом db. Каждый тест откатывается; transaction=True нужен только для реальных коммитов и on_commit.
  • client, admin_client и rf покрывают тесты views, settings — временные настройки, mailoutbox — письма, django_assert_num_queries — защиту от N+1. Для DRF добавьте фикстуру с APIClient.
  • Тестовые данные удобно создавать фабриками: factory_boy — для предсказуемых данных в больших проектах, model_bakery — для быстрого старта.
  • Ускоряйте прогон быстрым хешером паролей, --reuse-db на PostgreSQL и pytest-xdist на больших наборах; после изменения старых миграций запускайте --create-db.
  • Старые тесты на TestCase pytest выполняет без изменений, а manage.py test функции pytest не видит — после перехода запускайте всё через pytest.
  • #Django
  • #pytest
  • #pytest-django
  • #Тестирование
  • #Django REST Framework
↑ ↓ выборEnter открытьEsc закрыть