TestCase, Client, RequestFactory, pytest-django, fixtures, factories
Хороший тест фиксирует наблюдаемое правило: черновик не виден гостю, чужой объект нельзя изменить, невалидная форма ничего не сохраняет, повторный webhook не создаёт вторую оплату. Проверка каждого присваивания модели даёт объём, но мало уверенности.
Материал актуален для Django 5.2 LTS и 6.0.
| Класс | База данных | Когда применять |
|---|---|---|
SimpleTestCase | запрещена по умолчанию | pure functions, URL resolution, settings, код без ORM |
TestCase | да | большинство model/form/view tests |
TransactionTestCase | да, с реальными commit/rollback | transaction boundaries, locking, поведение после commit |
TestCase наследуется от TransactionTestCase, но ускоряет изоляцию: при поддержке транзакций открывает atomic blocks и откатывает изменения, вместо flush после каждого теста. Из-за внешней транзакции обычный commit и on_commit() ведут себя не так, как в production.
TransactionTestCase не означает «без транзакций». Напротив, код теста может commit/rollback и наблюдать их эффект; очистка базы выполняется flush, поэтому класс медленнее.
from django.test import SimpleTestCase, TestCase, TransactionTestCaseНазвание «unit» не определяется классом. Тест с TestCase, Client, ORM и шаблоном уже проверяет несколько компонентов, и это нормально, если он быстрый и полезный.
Django создаёт отдельную test database из DATABASES и удаляет/сохраняет её по настройкам runner. Если production использует PostgreSQL, принудительный in-memory SQLite может скрыть различия constraints, indexes, JSON, locking, collation и SQL.
Обычно тестовые settings меняют credentials/host, cache, email и внешние integrations, но оставляют тот же database backend. Никогда не направляйте тесты на production database. CI-пользователю нужны права создать test database или заранее подготовленный безопасный контур.
Запуск встроенным runner:
python manage.py test
python manage.py test blog.tests.test_views
python manage.py test blog.tests.test_views.PostUpdateTests.test_owner_can_update
python manage.py test --parallel --keepdb--keepdb ускоряет повторный запуск, сохраняя схему test DB. Coverage — сигнал о непроверенных ветвях, а не цель сам по себе.
from django.contrib.auth import get_user_model
from django.test import TestCase
from blog.models import Post
User = get_user_model()
class PostModelTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.author = User.objects.create_user(
username="author",
password="test-password",
)
def test_publish_sets_timestamp_once(self):
post = Post.objects.create(
title="Draft",
body="Text",
author=self.author,
)
post.publish(by=self.author)
first_timestamp = post.published_at
post.publish(by=self.author)
self.assertEqual(post.status, Post.Status.PUBLISHED)
self.assertEqual(post.published_at, first_timestamp)setUpTestData() создаёт общие данные один раз на класс; Django изолирует назначенные class attributes между тестами с помощью копирования. setUp() выполняется перед каждым test method и подходит изменяемым данным/клиенту.
Проверка idempotency публикации полезнее утверждения «после objects.create() появился pk» — последнее в основном тестирует Django.
from django.db import IntegrityError, transaction
def test_slug_is_unique(self):
Post.objects.create(slug="same", title="First", author=self.author)
with self.assertRaises(IntegrityError):
with transaction.atomic():
Post.objects.create(slug="same", title="Second", author=self.author)Вложенный atomic() нужен, чтобы ожидаемый IntegrityError откатился до savepoint и не оставил внешнюю транзакцию TestCase сломанной для последующих assertions.
Database-specific constraints и select_for_update() тестируют на production backend. На SQLite некоторые locking-тесты дадут ложную уверенность.
from blog.forms import BookingForm
class BookingFormTests(SimpleTestCase):
def test_end_must_be_after_start(self):
form = BookingForm(data={
"starts_at": "2026-07-19 12:00",
"ends_at": "2026-07-19 11:00",
})
self.assertFalse(form.is_valid())
self.assertIn("ends_at", form.errors)
self.assertEqual(
form.errors.as_data()["ends_at"][0].code,
"invalid_interval",
)Если форма делает ORM lookup, нужен TestCase, не SimpleTestCase. Проверяйте error code, когда текст переводится. Для valid path проверяйте нормализованный тип в cleaned_data, а для ModelForm — whitelist полей и сохранённые значения.
Не вызывайте form.save() до is_valid(). Тест invalid POST должен дополнительно доказать отсутствие записи.
from django.urls import reverse
class PostDetailTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.author = User.objects.create_user(username="author")
cls.published = Post.objects.create(
title="Published",
body="Text",
author=cls.author,
status=Post.Status.PUBLISHED,
)
cls.draft = Post.objects.create(
title="Secret draft",
body="Text",
author=cls.author,
status=Post.Status.DRAFT,
)
def test_guest_sees_published_post(self):
response = self.client.get(reverse(
"blog:post_detail",
kwargs={"pk": self.published.pk},
))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "blog/post_detail.html")
self.assertContains(response, "Published")
def test_guest_cannot_discover_draft(self):
response = self.client.get(reverse(
"blog:post_detail",
kwargs={"pk": self.draft.pk},
))
self.assertEqual(response.status_code, 404)Client проходит URL resolver, middleware, view и template engine, но не открывает socket и не исполняет JavaScript. response.context и assertTemplateUsed() доступны благодаря test environment.
assertContains(response, text) по умолчанию ожидает status 200, но принимает другой status_code=. Для JSON используйте response.json() и сравнивайте структуру, а не строковое форматирование.
Когда проверяется доступ уже вошедшего пользователя, не тратьте время на password hashing:
self.client.force_login(self.author)Когда проверяется сама login form/backend, используйте client.login() или POST в LoginView с реальным password и проверяйте safe redirect, inactive user, общий текст ошибки и rate-limit integration.
Тест изменения объекта должен включать минимум три роли:
class PostUpdateTests(TestCase):
def test_other_user_cannot_update(self):
self.client.force_login(self.other_user)
response = self.client.post(
reverse("blog:post_update", kwargs={"pk": self.post.pk}),
{"title": "Stolen", "body": "Changed"},
)
self.assertIn(response.status_code, {403, 404})
self.post.refresh_from_db()
self.assertNotEqual(self.post.title, "Stolen")Выберите 403 или 404 как контракт проекта, вместо постоянного множества в реальном suite. Здесь множество лишь показывает два допустимых дизайна.
Также проверьте anonymous, owner, privileged staff, invalid payload и GET на mutation URL. Один happy path не является тестом авторизации.
response = self.client.post(create_url, valid_data)
self.assertRedirects(
response,
reverse("blog:post_detail", kwargs={"pk": created_post.pk}),
)assertEqual(status_code, 302) не замечает redirect на login или внешний open redirect. assertRedirects() проверяет location и, по умолчанию, конечный response. follow=True удобно для messages, но может скрыть промежуточный неправильный status — сначала тестируйте сам redirect.
from django.test import RequestFactory
class HealthViewTests(SimpleTestCase):
def test_health(self):
request = RequestFactory().get("/health/")
response = health(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"ok")RequestFactory создаёт HttpRequest и вызывает view напрямую. Middleware не выполняется: request.user, session, messages и locale нужно добавить осознанно либо выбрать Client. Он также не рендерит TemplateResponse автоматически — при необходимости вызовите response.render().
Для CBV вызывают MyView.as_view()(request, **kwargs), чтобы сработали setup() и dispatch(). Прямой MyView().get() пропускает часть контракта.
Ручное поочерёдное применение SessionMiddleware и AuthenticationMiddleware в каждом тесте часто сложнее Client и привязывает тест к реализации. RequestFactory выгоден только при реально узкой границе.
on_commit() внутри TestCaseВнешняя транзакция TestCase откатывается и не коммитится, поэтому callbacks сами не выполнятся. Django предоставляет capture helper:
from unittest.mock import patch
@patch("blog.services.notify_post_published")
def test_notification_runs_after_commit(self, notify):
with self.captureOnCommitCallbacks(execute=True) as callbacks:
publish_post(post_id=self.post.pk, actor=self.author)
self.assertEqual(len(callbacks), 1)
notify.assert_called_once_with(self.post.pk)Если нужно доказать реальное отсутствие эффекта до commit и его появление после commit, используйте TransactionTestCase и отдельные connections/threads там, где это требует СУБД.
def test_post_list_has_bounded_queries(self):
PostFactory.create_batch(20)
with self.assertNumQueries(3):
response = self.client.get(reverse("blog:post_list"))
list(response.context["posts"])Число зависит от auth/session, pagination и prefetch, поэтому сначала измерьте конкретный endpoint. Такой тест ценен для предотвращения N+1, но становится хрупким, если фиксирует случайные служебные запросы. Можно использовать CaptureQueriesContext и проверять отсутствие повторяющегося шаблона.
JSON/XML/YAML fixtures удобны для редких эталонных справочников и миграционных сценариев, но:
auth.user.Factories (model_bakery, factory_boy или свои builders) создают минимальные данные рядом с тестом:
from model_bakery import baker
post = baker.make(
Post,
status=Post.Status.PUBLISHED,
author__username="author",
)Случайные defaults улучшают разнообразие, но делают падение невоспроизводимым. Явно задавайте поля, важные сценарию, и фиксируйте seed property-based тестов.
from unittest.mock import patch
@patch("billing.services.payment_gateway.charge")
def test_declined_payment_does_not_activate_order(self, charge):
charge.side_effect = PaymentDeclined
...Patch делается там, где имя используется, не обязательно там, где функция определена. Не mock-айте QuerySet ради ускорения model logic: реальная test DB лучше ловит неверные filters и constraints. Время, UUID, email gateway и HTTP client — хорошие границы для mock/fake.
class AsyncStatusTests(TestCase):
async def test_status(self):
response = await self.async_client.get("/async-status/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})Используется self.async_client, не await self.client.get(). Заголовки AsyncClient передаются обычными именами (ACCEPT, а не обязательно WSGI-префиксом), и он не превращает sync ORM в async. Транзакционные ограничения async-кода обсуждаются в отдельной главе.
pytest-django даёт fixtures db/transactional_db, marker django_db и удобную параметризацию. Это поддерживаемая альтернатива built-in runner, а не обязательный слой курса:
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
python_files = ["test_*.py"]Не смешивайте молча semantics db и transactional_db: разница аналогична TestCase и TransactionTestCase. Команда pytest валидна только после установки/configuration plugin.
unittest-стиль Django, Client, RequestFactory и fixtures актуальны. Legacy suites часто принудительно переходят на SQLite, проверяют только 302, отключают signals глобально и создают пользователя с plaintext password fixture. Это проблемы достоверности, не удалённый API.
Старое утверждение «TransactionTestCase не использует транзакции» неверно: класс нужен именно для наблюдения настоящих commit/rollback, а платит за это более дорогой очисткой базы.
Далее: Тестирование: Integration & E2E