Тестирование Django с pytest-django
Pytest и Django: установка pytest-django, DJANGO_SETTINGS_MODULE, маркер django_db, фикстуры client и admin_client, тесты моделей, views, форм, DRF и CI.
Содержание · 17 разделов
- Установка pytest-django
- Настройка: DJANGO_SETTINGS_MODULE и конфиг pytest
- База данных в тестах: маркер django_db
- Транзакции и откат после каждого теста
- Не проверяйте конкретные id
- Данные на всю сессию
- Встроенные фикстуры pytest-django
- Тесты моделей
- Тесты views: client, admin_client и rf
- Тесты форм
- Настройки на время теста: фикстура settings
- Проверка писем: mailoutbox
- Число SQL-запросов: django_assert_num_queries
- Тестирование API на Django REST Framework
- Фабрики тестовых данных: factory_boy и model_bakery
- Ускорение тестов
- Быстрый хешер паролей
- —reuse-db: не пересоздавать тестовую базу
- Параллельный запуск: pytest-xdist
- pytest-django или manage.py test
- Запуск тестов в CI: GitHub Actions
- Частые вопросы
- Как запустить тесты одного приложения или один тест?
- Почему тест падает с ScopeMismatch?
- Как тестировать асинхронные views?
- Как посмотреть покрытие кода тестами?
- Как найти ошибки в переменных шаблонов?
- Можно ли тестировать на SQLite, если в продакшне PostgreSQL?
- Итог
Чтобы запускать тесты 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 !!!!!!!!!!!!!!!!!!!!
Модуль настроек задают одним из трёх способов. Если указано несколько, побеждает верхний в списке:
- Флаг командной строки:
pytest --ds=config.settings. - Переменная окружения
DJANGO_SETTINGS_MODULE. - Ключ
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=True | TransactionTestCase | реальные коммиты, on_commit, потоки |
reset_sequences=True | TransactionTestCase.reset_sequences | сброс счётчиков id; работает только вместе с transaction=True |
serialized_rollback=True | serialized_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 |
rf | RequestFactory: объект запроса для прямого вызова 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_boy | model_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 с |
| MD5PasswordHasher | 0,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-django | manage.py test |
|---|---|---|
| Установка | pip install pytest pytest-django | входит в Django |
| Форма теста | функция или класс, обычный assert | класс TestCase, методы assert... |
| Подготовка данных | фикстуры с зависимостями, conftest.py | setUp, setUpTestData, JSON-фикстуры |
| Доступ к базе | только с django_db или фикстурой db | у любого TestCase |
| Параметризация | @pytest.mark.parametrize | subTest в цикле |
| Повторное использование базы | --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. - Старые тесты на
TestCasepytest выполняет без изменений, аmanage.py testфункции pytest не видит — после перехода запускайте всё черезpytest.
- #Django
- #pytest
- #pytest-django
- #Тестирование
- #Django REST Framework