Тестирование Django
Тесты — это код, который проверяет ваш код: они ловят регрессии до того, как их увидит пользователь, и дают смелость менять проект, не боясь сломать незаметное. Django несёт для этого полноценный встроенный фреймворк.
Автотест — программа, которая выполняет часть вашего приложения с известными входными данными и проверяет, что результат совпал с ожидаемым. В Django тесты строятся на
TestCase, который автоматически поднимает отдельную тестовую базу и откатывает её после каждого теста.
Пока проект маленький, кажется, что тесты — лишняя работа: «и так вижу, что работает». Но проект растёт, и через полгода правка в одном месте незаметно ломает другое — функцию, которую вы давно не трогали и забыли проверить руками. Ручная проверка не масштабируется: перещёлкивать весь сайт после каждого изменения невозможно. Тесты делают это за вас за секунды и при каждом коммите. Django не требует ставить ничего стороннего — фреймворк тестирования встроен и заточен под особенности веб-приложения: БД, HTTP-запросы, формы.
Зачем это на практике
Показательный сценарий: вы меняете формулу расчёта скидки, всё выглядит верно, выкатываете — и через день оказывается, что для заказов с промокодом скидка теперь считается дважды. Один тест test_discount_with_promocode, написанный когда-то, поймал бы это за миллисекунды ещё до коммита. Тесты особенно окупаются на том, что трудно проверить руками: граничные случаи (пустая корзина, отрицательное количество), редкие ветки (что если платёж отклонён), регрессии (баг, который уже чинили — тест не даёт ему вернуться). Это не про «100% покрытие ради цифры», а про страховку критичной логики и про возможность рефакторить без страха.
TestCase и тестовая база
Базовый класс — django.test.TestCase. Его ключевая особенность: перед прогоном Django создаёт отдельную тестовую БД (с префиксом test_), и каждый тест выполняется в транзакции, которая откатывается по завершении. Поэтому тесты изолированы — данные одного не протекают в другой, а ваша рабочая база не трогается вообще.
# app/tests.py
from django.test import TestCase
from app.models import Order
class OrderModelTest(TestCase):
def setUp(self):
# выполняется ПЕРЕД каждым тестом; данные живут только внутри теста
self.order = Order.objects.create(total=100, paid=False)
def test_mark_paid(self):
self.order.mark_paid() # метод, который тестируем
self.assertTrue(self.order.paid) # проверка результата
def test_total_is_positive(self):
self.assertGreater(self.order.total, 0)
# запуск всех тестов проекта
python manage.py test
# только одного приложения / класса / метода:
python manage.py test app
python manage.py test app.tests.OrderModelTest.test_mark_paid
Метод setUp готовит данные перед каждым тестом, а методы test_* — это сами проверки. Утверждения (assertTrue, assertEqual, assertGreater, assertRaises и десятки других) сравнивают факт с ожиданием; если расходится — тест падает с понятным сообщением. Создание тестовой БД с нуля занимает время, поэтому для скорости часто используют setUpTestData — он готовит общие данные один раз на весь класс, а не на каждый тест.
Client: тестируем запросы как браузер
Модели — это половина дела; вьюхи и URL тоже надо проверять. Для этого есть тестовый Client — он эмулирует HTTP-запросы к вашим вьюхам без поднятия настоящего сервера. Он умеет GET, POST, передачу данных формы, проверку статуса, редиректов и содержимого ответа.
from django.test import TestCase
from django.urls import reverse
class ArticleViewTest(TestCase):
def test_list_page_works(self):
resp = self.client.get(reverse("article_list"))
self.assertEqual(resp.status_code, 200) # страница открылась
self.assertContains(resp, "Статьи") # в HTML есть это слово
def test_create_via_post(self):
resp = self.client.post(reverse("article_create"),
{"title": "Новая", "body": "текст"})
self.assertEqual(resp.status_code, 302) # редирект после создания
self.assertEqual(Article.objects.count(), 1) # запись реально создана
def test_requires_login(self):
# анонимного редиректит на логин
resp = self.client.get(reverse("dashboard"))
self.assertEqual(resp.status_code, 302)
# а залогиненного пускает
self.client.force_login(self.user)
resp = self.client.get(reverse("dashboard"))
self.assertEqual(resp.status_code, 200)
Здесь видны важные приёмы: reverse("имя_url") строит URL по имени, чтобы тест не ломался при смене путей; assertContains проверяет и статус, и наличие текста в ответе; force_login логинит пользователя без формы — удобно для проверки доступа. Client прогоняет запрос через весь стек Django (middleware, URL-роутинг, вьюха, шаблон), поэтому такой тест проверяет страницу целиком, а не отдельную функцию.
Тестовые данные: фикстуры и factory
Тестам нужны данные. Создавать их вручную в setUp быстро надоедает, особенно когда у модели десяток полей. Есть два подхода.
Фикстуры — данные из файла
Фикстура — это JSON/YAML-файл с готовыми объектами, который Django загружает в тестовую БД. Подходит для стабильных справочных данных (категории, роли, страны).
class CatalogTest(TestCase):
fixtures = ["categories.json"] # загрузится в тестовую БД перед тестами
def test_categories_loaded(self):
self.assertEqual(Category.objects.count(), 5)
Минус фикстур — они хрупкие: добавили обязательное поле в модель, и все фикстуры надо править руками. Поэтому для изменчивых данных чаще берут фабрики.
Factory — генерация объектов кодом
Библиотека factory_boy описывает «фабрику» объекта один раз, а в тестах создаёт сколько угодно экземпляров, заполняя поля автоматически (в том числе случайными правдоподобными значениями через Faker). Это гибче и устойчивее фикстур.
import factory
from app.models import User
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f"user{n}") # user0, user1, ...
email = factory.Faker("email")
is_active = True
# в тесте: один объект или сразу пачка
user = UserFactory() # все поля заполнятся сами
admin = UserFactory(is_staff=True) # переопределяем нужное
UserFactory.create_batch(10) # десять пользователей разом
Фабрика избавляет от тонн Model.objects.create(...) с повторяющимися полями и не ломается при добавлении новых необязательных полей. Для большинства проектов factory_boy удобнее фикстур; фикстуры оставляют для редко меняющихся справочников.
Что покрывать в первую очередь
Тестировать всё подряд — путь к выгоранию и медленному набору тестов. Расставляйте приоритеты по риску и цене ошибки.
| Покрывать обязательно | Можно не покрывать |
| бизнес-логика (расчёты, скидки, статусы) | чужой код фреймворка и библиотек |
| граничные случаи (пусто, ноль, максимум) | тривиальные геттеры/сеттеры без логики |
| права доступа (кто что может) | автогенерируемый код (миграции) |
| баги-регрессии (тест на каждый чиненный баг) | статичные шаблоны без условий |
| критичные пути (оплата, регистрация) | код, который скоро удалят |
Полезное правило: на каждый найденный баг пишут тест, который его воспроизводит, — тогда баг, однажды починенный, больше не вернётся незаметно. И тестируйте поведение («при отмене заказа возвращаются деньги»), а не реализацию («вызвался такой-то приватный метод») — тесты на поведение переживают рефакторинг, тесты на реализацию ломаются от любой перестановки строк.
Быстрые тесты
Медленный набор тестов перестают запускать — а незапускаемый тест бесполезен. Поэтому скорость важна. Главные приёмы:
- Слабый хеш паролей в тестах. Боевой PBKDF2 намеренно медленный; в тестовых настройках ставят быстрый MD5-хешер — создание пользователей ускоряется в разы.
setUpTestDataвместоsetUp. Общие данные класса создаются один раз, а не перед каждым методом.- Не ходить в сеть и внешние сервисы. Реальные HTTP-запросы и письма в тестах — это медленно и ненадёжно; их подменяют (mock), проверяя, что вызов был, без настоящего обращения.
- Параллельный запуск.
manage.py test --parallelраскидывает тесты по ядрам. SimpleTestCaseдля логики без БД. Если тест не трогает базу, наследуйтесь отSimpleTestCase— он не создаёт транзакций и работает быстрее.
# settings для тестов: быстрый хешер вместо боевого PBKDF2
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
Простой пример проверки чистой логики без всякой БД — на стандартном unittest, чтобы прочувствовать структуру «дано → действие → проверка»:
import unittest
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError("percent вне диапазона")
return round(price * (100 - percent) / 100, 2)
class DiscountTest(unittest.TestCase):
def test_normal(self):
self.assertEqual(apply_discount(200, 10), 180.0)
def test_zero(self):
self.assertEqual(apply_discount(200, 0), 200.0)
def test_invalid_raises(self):
with self.assertRaises(ValueError):
apply_discount(200, 150)
unittest.main(argv=[""], exit=False, verbosity=2)
Вывод:
test_invalid_raises (__main__.DiscountTest) ... ok test_normal (__main__.DiscountTest) ... ok test_zero (__main__.DiscountTest) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.000s OK
Три проверки одной функции: обычный случай, граница (0%) и ошибочный ввод. TestCase Django устроен поверх того же unittest — добавляет лишь работу с тестовой БД и Client.
Как это работает под капотом
Когда вы запускаете manage.py test, Django сначала создаёт тестовую базу — отдельную БД с именем test_<имя>, применяя к ней все миграции (или, для скорости, опционально оставляя её между прогонами через --keepdb). Затем тест-раннер находит все классы-наследники TestCase и методы test_*. Магия изоляции в TestCase — это транзакции: перед каждым тестом открывается транзакция, а после — делается ROLLBACK, который мгновенно стирает все изменения, не удаляя и не пересоздавая таблицы. Поэтому тесты быстрые и независимые: откат транзакции куда дешевле, чем чистка базы вручную. (Есть и TransactionTestCase — он реально коммитит и чистит таблицы, нужен, когда тестируете само транзакционное поведение, но он заметно медленнее.) Тестовый Client при этом не открывает сокет и не поднимает веб-сервер — он напрямую вызывает обработчик запросов Django (WSGI handler) с искусственно собранным объектом запроса и возвращает объект ответа, что и делает HTTP-тесты быстрыми и детерминированными.
Частые ошибки
- Бояться, что тесты испортят данные.
TestCaseработает в отдельной тестовой БД и откатывает транзакцию после каждого теста — рабочая база не трогается. Это безопасно по построению. - Тестировать реализацию, а не поведение. Проверка «вызвался приватный метод X» ломается при любом рефакторинге. Проверяйте наблюдаемый результат («деньги вернулись»), а не внутренние шаги.
- Ходить в реальную сеть и слать настоящие письма. Это делает тесты медленными и хрупкими (внешний сервис лёг — упал тест). Внешние вызовы подменяют (mock).
- Хрупкие фикстуры на изменчивых данных. Каждое новое поле модели ломает JSON-фикстуры. Для меняющихся объектов берите factory_boy, фикстуры — только под стабильные справочники.
- Жертвовать скоростью. Боевой хешер паролей и
setUpна каждый тест незаметно раздувают время; медленный набор перестают гонять, и он умирает. Быстрый хешер,setUpTestData,--parallel.
Итоги
TestCaseподнимает отдельную тестовую БД и откатывает транзакцию после каждого теста, поэтому тесты изолированы и не трогают рабочие данные.- Тестовый
Clientэмулирует HTTP-запросы (GET/POST, формы, редиректы) через весь стек Django без настоящего сервера; URL берут черезreverse. - Данные готовят фикстурами (стабильные справочники) или factory_boy (изменчивые объекты, генерация кодом) — фабрики гибче и устойчивее.
- В первую очередь покрывают бизнес-логику, граничные случаи, права доступа и регрессии; на каждый баг — воспроизводящий тест.
- Скорость держат быстрым хешером паролей,
setUpTestData, моками внешних сервисов и параллельным запуском — иначе набор перестают гонять.