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

На проекте с 40 вьюхами и покрытием 91% в прод уехал баг: страница заказа отдавала 500 при пустой корзине. Тесты были зелёными, потому что каждый из них создавал корзину с товаром — сценарий «корзина пуста» никто не проверял. Покрытие меряло строки, а не поведение. Это типичная ловушка тестов представлений: легко написать сто тестов, которые гоняют один и тот же счастливый путь, и ни один — граничный. Разберём, что во вьюхах Django (версия 5.x, но всё сказанное работает и на 4.2 LTS) стоит проверять на самом деле, чем платят разные подходы к клиенту и где тесты дают ложную зелёную полосу.
Что вообще тестируем во вьюхе
Вьюха в Django — это тонкий слой: принять запрос, проверить права, дёрнуть бизнес-логику, вернуть ответ или редирект. Если тестировать её как чёрный ящик через self.client.get(), вы проверяете три вещи одновременно: маршрутизацию, саму вьюху и рендеринг шаблона. Это и плюс, и источник хрупкости.
Полезные тесты вьюхи проверяют контракт, а не реализацию:
- код ответа для разных ролей: аноним, авторизованный, чужой пользователь, владелец объекта;
- редиректы: куда уходит неавторизованный, куда — после успешного POST;
- что попало в контекст шаблона (
response.context), а не что видно в HTML; - побочные эффекты: создалась ли запись, отправилось ли письмо, вызвался ли внешний сервис;
- граничные случаи: пустой список, несуществующий
pk, невалидная форма.
Проверять точный текст на странице через assertContains имеет смысл там, где текст — часть контракта (сообщение об ошибке, цена). Ассерты на вёрстку («есть ли div с таким классом») ломаются при первом редизайне и ничего не гарантируют.
Клиент: три способа дёрнуть вьюху и их цена
У Django есть несколько инструментов, чтобы прогнать запрос через вьюху. Они не взаимозаменяемы.
Client — полный цикл HTTP
django.test.Client проходит middleware, URL-резолвинг, вьюху и рендеринг. Это интеграционный тест: если вы забыли зарегистрировать URL или сломали middleware, тест упадёт. Плата — скорость и то, что причину падения приходится искать по всей цепочке.
RequestFactory — вьюха в изоляции
RequestFactory собирает объект запроса и передаёт его вьюхе напрямую, минуя middleware и резолвинг URL. Быстро и точечно, но есть подводный камень: у собранного запроса нет middleware, поэтому request.user и request.session вы проставляете руками. Забыли — получите AttributeError внутри вьюхи, а не осмысленную ошибку теста.
APIClient из DRF
Если вьюхи — это DRF, тестируйте через rest_framework.test.APIClient. Он умеет force_authenticate без прохода через реальную аутентификацию и корректно сериализует тело в JSON. Обычный Client.post(data=..., content_type='application/json') тоже работает, но JSON придётся сериализовать самому.
| Инструмент | Проходит middleware | Скорость | Когда брать |
|---|---|---|---|
| Client | да | медленнее | проверка полного цикла: права, редиректы, шаблон |
| RequestFactory | нет | быстро | изолированный тест логики вьюхи, CBV-методов |
| APIClient (DRF) | да | средне | API-эндпоинты, JSON, токен-аутентификация |
На практике 80% тестов вьюх — это Client с логином, и это нормально: вам важен именно контракт «запрос → ответ с учётом прав».
Аутентификация: force_login вместо login
Частая ошибка — логинить тестового пользователя через self.client.login(username=..., password=...). Это гоняет реальный бэкенд аутентификации и хеширование пароля на каждый тест. При сотнях тестов набегают секунды. Правильнее:
from django.contrib.auth import get_user_model
from django.test import TestCase
User = get_user_model()
class OrderViewTests(TestCase):
@classmethod
def setUpTestData(cls):
# создаётся один раз на весь класс, откатывается транзакцией
cls.owner = User.objects.create_user("owner", password="x")
cls.stranger = User.objects.create_user("stranger", password="x")
def test_owner_sees_order(self):
self.client.force_login(self.owner)
response = self.client.get(f"/orders/{self.order.pk}/")
self.assertEqual(response.status_code, 200)
def test_stranger_gets_403(self):
self.client.force_login(self.stranger)
response = self.client.get(f"/orders/{self.order.pk}/")
self.assertEqual(response.status_code, 403)
force_login кладёт пользователя в сессию напрямую, без проверки пароля. А setUpTestData создаёт фикстуры один раз на класс, а не в каждом setUp — на больших наборах это заметно быстрее.
Зелёное покрытие вьюх без теста на чужого пользователя и на аноним — это не тесты прав доступа, а тесты того, что владелец видит свою страницу. Дыры в авторизации живут ровно там, где нет негативного сценария.
Где тесты дают ложную зелёную полосу
Транзакции скрывают проблему с фиксацией
TestCase оборачивает каждый тест в транзакцию и откатывает её. Из-за этого код, который завязан на transaction.on_commit (например, отправка задачи в Celery после сохранения заказа), в обычном TestCase не срабатывает — коммита-то нет. Тест «отправилось ли письмо после заказа» будет зелёным, а в проде хендлер не вызовется, если логика ждёт коммита. Для таких случаев используйте captureOnCommitCallbacks:
def test_order_triggers_notification(self):
self.client.force_login(self.owner)
with self.captureOnCommitCallbacks(execute=True) as callbacks:
self.client.post("/orders/create/", {"product": self.product.pk})
self.assertEqual(len(callbacks), 1)
captureOnCommitCallbacks(execute=True) заставляет колбэки on_commit выполниться в рамках теста. Без него они молча пропадают.
Кеш переживает тесты
Если вьюха кеширует ответ (cache_page или ручной cache.set), а в тестах используется реальный бэкенд кеша или локальная память, значение может утечь между тестами и дать ложный результат. Явно чистите кеш или подменяйте бэкенд на время теста:
from django.test import override_settings
@override_settings(CACHES={"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}})
class CachedViewTests(TestCase):
def setUp(self):
from django.core.cache import cache
cache.clear()
Внешние вызовы бьют по реальным сервисам
Вьюха, которая ходит в платёжный шлюз или отправляет запрос в стороннее API, в тестах не должна дёргать сеть. Мокайте на границе — там, где ваш код вызывает клиент внешнего сервиса, а не саму requests-функцию где-то в глубине. Проверяйте два исхода: успех и ошибку сервиса (таймаут, 500). Тест только на успешный ответ платёжки бесполезен ровно в тот момент, когда платёжка ляжет.
assertNumQueries ловит N+1 раньше прода
Список-вьюха с ForeignKey без select_related в тестах на трёх объектах пролетает, а на проде даёт сотни запросов. Зафиксируйте ожидаемое число запросов:
def test_list_view_query_count(self):
self.client.force_login(self.owner)
with self.assertNumQueries(3):
self.client.get("/orders/")
Если после рефакторинга число вырастет — тест упадёт и покажет, что где-то отвалился prefetch_related. Это единственный дешёвый способ поймать N+1 автоматически.
Когда что выбирать
- Права, редиректы, полный ответ пользователю —
Clientсforce_login, по тесту на каждую роль. - Логика метода CBV в отрыве от URL —
RequestFactory, но не забудьте проrequest.user. - Побочный эффект после сохранения —
captureOnCommitCallbacks, иначе тест лжёт. - Производительность списков —
assertNumQueriesна реалистичном числе объектов, а не на одном.
Что делать дальше
Начните с негативных сценариев: для каждой вьюхи, кроме теста «владелец видит», добавьте «аноним получает редирект/403» и «чужой получает 403». Затем прогоните покрытие с флагом ветвлений (coverage run --branch) — оно показывает непройденные ветки if, а не только строки, и именно там прячутся пустые корзины и несуществующие pk. Инвалидация кеша и тестирование WebSocket-вьюх через Channels — отдельные темы, здесь разбирали только синхронные HTTP-представления. Дальше стоит посмотреть на фабрики объектов (factory_boy) вместо ручных фикстур в setUpTestData — это уберёт дублирование при росте числа тестов.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Django docs — Testing tools (Client, RequestFactory, assertNumQueries), Django docs — TestCase.captureOnCommitCallbacks
Частые вопросы
У меня Django 4.2, а не 5.x — что-то из этого не сработает?
Почему тест на on_commit-логику проходит без captureOnCommitCallbacks, но эффект не наступает?
assertNumQueries падает с числом, которое я не ожидал — что делать?
Стоит ли использовать RequestFactory вместо Client везде ради скорости?
Как тестировать вьюху, которая ходит во внешнее API, не трогая сеть?
Обязательно ли гнаться за 100% покрытием вьюх?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.