Тестирование 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, моками внешних сервисов и параллельным запуском — иначе набор перестают гонять.
Проверьте себя
1. Почему тесты на основе django.test.TestCase безопасны для рабочих данных?
AОни вообще не обращаются к базе данных
BDjango создаёт отдельную тестовую БД (с префиксом test_), а каждый тест выполняется в транзакции, которая откатывается по завершении
CTestCase делает резервную копию рабочей базы перед каждым тестом
DТесты можно запускать только на копии сервера
2. Для чего предназначен тестовый Client в Django?
AДля подключения к внешним API в тестах
BЧтобы эмулировать HTTP-запросы к вьюхам (GET, POST, формы, проверка статуса и содержимого) через весь стек Django без запуска настоящего сервера
CДля ускорения работы базы данных
DЧтобы автоматически писать тесты за разработчика
3. В чём преимущество factory_boy перед JSON-фикстурами для изменчивых данных?
AФикстуры работают только с PostgreSQL
BФабрика описывает объект кодом и генерирует поля автоматически, поэтому не ломается при добавлении новых необязательных полей, тогда как фикстуры приходится править вручную
Cfactory_boy не требует устанавливать ничего стороннего
DФикстуры нельзя использовать в TestCase
4. Какой приём ускоряет тесты Django, активно создающие пользователей?
AОтключить тестовую базу данных
BВ тестовых настройках заменить боевой медленный PBKDF2 на быстрый хешер паролей (например, MD5PasswordHasher)
CЗапускать тесты по одному вручную
DХранить пароли в открытом виде