Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Тестирование: Unit Tests
testing_unit

Тестирование: Unit Tests

TestCase, Client, RequestFactory, pytest-django, fixtures, factories

Тестирование Django: быстрые проверки поведения

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

Материал актуален для Django 5.2 LTS и 6.0.

#Выберите минимальный подходящий TestCase

КлассБаза данныхКогда применять
SimpleTestCaseзапрещена по умолчаниюpure functions, URL resolution, settings, код без ORM
TestCaseдабольшинство model/form/view tests
TransactionTestCaseда, с реальными commit/rollbacktransaction 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 и шаблоном уже проверяет несколько компонентов, и это нормально, если он быстрый и полезный.

#Не подменяйте production database без причины

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 — сигнал о непроверенных ветвях, а не цель сам по себе.

#Структура теста: arrange, act, assert

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.

#Constraint проверяют на уровне базы

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 должен дополнительно доказать отсутствие записи.

#Client проверяет Django-стек без live server

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 не является тестом авторизации.

#Redirect проверяйте по имени и назначению

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.

#RequestFactory — изоляция без middleware

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 там, где это требует СУБД.

#Бюджет SQL — функциональная гарантия производительности

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 и проверять отсутствие повторяющегося шаблона.

#Fixtures и factories

JSON/XML/YAML fixtures удобны для редких эталонных справочников и миграционных сценариев, но:

  • зависят от model label и полей;
  • тяжело читаются в diff;
  • password должен быть валидным encoded hash, а не plaintext;
  • custom user ломает fixture 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 тестов.

#Mock внешней границы, не ORM

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.

#Асинхронные views

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 — другой runner, те же границы

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.

#Legacy и актуальность

unittest-стиль Django, Client, RequestFactory и fixtures актуальны. Legacy suites часто принудительно переходят на SQLite, проверяют только 302, отключают signals глобально и создают пользователя с plaintext password fixture. Это проблемы достоверности, не удалённый API.

Старое утверждение «TransactionTestCase не использует транзакции» неверно: класс нужен именно для наблюдения настоящих commit/rollback, а платит за это более дорогой очисткой базы.

#Документация

  • Testing in Django
  • Testing tools
  • Django test cases

Далее: Тестирование: Integration & E2E