Тестовая база данных
Всё, что происходит в тестах — остаётся в тестах.Для полного тестировании проекта необходимо проверить работу с БД, а для этого придётся записывать и считывать данные из базы.Чтобы не замусоривать базу данных тестовыми записями, при тестировании создаётся виртуальная база: её структура полностью повторяет структуру реальной базы Django-проекта, однако никаких данных в этой базе нет: ни постов, ни пользователей — ничего.Все данные в этой временной базе нужно создавать в процессе тестирования. Запросы при тестировании делаются именно к временной базе, основная база не затрагивается.По окончании тестирования виртуальная база автоматически удаляется.
Проект Todo
Для тестирования моделей мы подготовили новый Django-проект Todo: это небольшая программа для записи и просмотра запланированных дел.
- На главной странице проекта есть форма для сохранения задания в базе данных;
- на страницу
/task/
выводится полный список запланированных дел (страница доступна только авторизованному пользователю); - на страницах с адресом вида
/task/<slug:slug>/
показывается полное описание определённой задачи (страница доступна только авторизованному пользователю); - на статичной странице
/about/
выводится описание проекта.
А больше в проекте ничего нет. Склонируйте проект Todo из репозитория и запустите его на своём компьютере.
Приложение staticpages
В этом приложении нет моделей, есть одна небольшая view-функция, которая отвечает за отображение страницы /about/
.
Приложение deals
Это приложение управляет добавлением и отображением запланированных дел.View-классы приложения:
Home()
отвечает за отображение главной страницы с формой для добавления задач: страница/
TaskList()
отвечает за отображение списка запланированных задач: страница/task/
TaskDetail()
отвечает за вывод подробной информации о задаче: страница/task/<slug:slug>/
TaskAddSuccess()
выводит сообщение об успешном добавлении задачи через форму: страница/added/
Модели приложения deals:
# deals/models.py
from django.db import models
# Нужно установить библиотеку pytils:
# pip3 install pytils
from pytils.translit import slugify
class Task(models.Model):
title = models.CharField(
'Заголовок',
max_length=100,
help_text='Дайте короткое название задаче'
)
text = models.TextField(
'Текст',
help_text='Опишите суть задачи.'
)
slug = models.SlugField(
'Адрес для страницы с задачей',
max_length=100,
unique=True,
blank=True,
help_text=(
'Укажите уникальный адрес для страницы задачи. Используйте только '
'латиницу, цифры, дефисы и знаки подчёркивания'
)
)
image = models.ImageField(
'Картинка',
upload_to='tasks/',
blank=True,
null=True,
help_text='Загрузите картинку'
)
def __str__(self):
return self.title
# Расширение встроенного метода save(): если поле slug не заполнено -
# транслитерировать в латиницу содержимое поля title, обрезать до ста знаков
# и сохранить в поле slug
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)[:100]
super().save(*args, **kwargs)
Поля CharField
, TextField
и SlugField
вам знакомы. Поле ImageField
нужно для хранения картинки.
Если при создании экземпляра класса Task поле slug
не было заполнено — его значение будет сгенерировано из содержимого поля title
. За это отвечает доработанный метод save()
.
Хозяйке на заметку: стандартный метод slugify
из модуля django.utils.text
не умеет работать с кириллицей. Нужно установить в виртуальное окружение пакет pytils (pip3 install pytils
) и импортировать slugify
именно из него, как это сделано в листинге deals/models.py.
Тесты модели
Тесты моделей приложения удобно хранить в одноимённом файле:
└── deals
├── __init__.py
├── tests
│ ├── __init__.py
│ ├── test_forms.py
│ ├── test_models.py # Тесты моделей
│ ├── test_urls.py
│ └── test_views.py
├── admin.py
├── forms.py
├── models.py
└── views.py
Что не нужно тестировать
- Работу Python: он сотни раз протестирован и работает.
- Функциональность, предоставляемую Django: фреймворк уже проверен разработчиками.
- Подключаемые библиотеки и приложения.
Не нужно проверять, что данные поля title
сохранены в базу данных как CharField
или slug
— как SlugField
: это часть реализации Django.
Если для полей CharField
или SlugField
в модели указана максимальная длина поля — не стоит тестировать результат: вы впустую потратите время на проверку того, что уже отлажено при разработке Django.
Что тестировать можно, но необязательно
Для моделей пользовательских форм удобно указать человекочитаемое имя: проект будет выглядеть неопрятно, если на странице с формой возле поля ввода будет стоять лейбл title или text.
Будет правильным проверить, что вы не забыли указать читаемое название для всех полей, на основании которых генерируются формы.
Указать человекочитаемое имя для поля можно так:
class Task(models.Model):
title = models.CharField(
'Заголовок', # Человекочитаемое имя (verbose) для поля
max_length=100,
help_text='Дайте короткое название задаче'
)
...
или так:
class Task(models.Model):
title = models.CharField(
verbose_name='Заголовок', # Человекочитаемое имя (verbose) для поля
max_length=100,
help_text='Дайте короткое название задаче'
)
...
Это имя будет отображаться и в админке Django, и во всех формах, связанных с моделью Task
. Документация по verbose доступна на официальном сайте Django.
Аналогичная ситуация с help_text
: если это поле важно — стоит проверить, что про него не забыли в коде.
Полезно проверить, что __str__
возвращает ожидаемый результат. Это мелочь, но она может быть удобна в дальнейшей разработке и поднимет code coverage. Содержимое поля __str__
отображается, например, при выводе объектов в админ-зоне или при запросе объекта из queryset.
Для тестирования моделей веб-клиент не нужен: достаточно создать запись в тестовой базе данных и проверять её через ORM.
# deals/tests/tests_models.py
from django.test import TestCase
from deals.models import Task
class TaskModelTest(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Создаём тестовую запись в БД
# и сохраняем созданную запись в качестве переменной класса
cls.task = Task.objects.create(
title='Заголовок тестовой задачи',
text='Тестовый текст',
slug='test-task'
)
def test_title_label(self):
"""verbose_name поля title совпадает с ожидаемым."""
task = TaskModelTest.task
# Получаем из свойста класса Task значение verbose_name для title
verbose = task._meta.get_field('title').verbose_name
self.assertEqual(verbose, 'Заголовок')
def test_title_help_text(self):
"""help_text поля title совпадает с ожидаемым."""
task = TaskModelTest.task
# Получаем из свойста класса Task значение help_text для title
help_text = task._meta.get_field('title').help_text
self.assertEqual(help_text, 'Дайте короткое название задаче')
# Аналогичным образом можно протестировать мета-значения из полей text, slug, image
def test_object_name_is_title_fild(self):
"""__str__ task - это строчка с содержимым task.title."""
task = TaskModelTest.task # Обратите внимание на синтаксис
expected_object_name = task.title
self.assertEqual(expected_object_name, str(task))
Получить поле verbose_name
напрямую, через group.title.verbose_name
, не получится. Ведь поле group.title
содержит только строку "Тестовый заголовок"
.
Для доступа до verbose_name
есть другой синтаксис
group._meta.get_field('title').verbose_name
Таким же образом можно получить другие параметры, задаваемые при создании поля.Если в тестах Django вы применяете методы класса setUpClass()
и tearDownClass()
— обязательно вызывайте в них super(): super().setUpClass()
и super().tearDownClass()
.Без вызова super()
все тесты сработают нормально, но вы получите ошибку:
AttributeError: type object '<имя_класса>' has no attribute 'cls_atomics'
Эта ошибка возникает именно в Django: в Unittest для Python такой проблемы нет.
К объектам, созданным в методах класса (например, в setUpClass()
), синтаксически правильно обращаться через имя класса; например, обращение к объекту task
должно выглядеть так: TaskModelTest.task
.
Обращение self.task
тоже сработает, если не использовать наследование с переопределением метода в дочерних классах.
Проверять field_label
лучше через метод assertEquals(field_label,'Заголовок')
, а не через assertTrue(field_label == 'Заголовок')
.
Разница в том, что при тестировании через assertTrue()
в отчёте будет просто сказано, что тест провален, а при assertEquals()
в консоли будет указано и актуальное значение field_label
. Это облегчит задачу по отладке кода.
Циклом по тестам: стандартный метод subTest()
Тестирование каждого поля модели может превратиться в ад: в большом проекте для этого придётся писать очень много похожих тестов.
Писать долго, читать неудобно, принцип DRY нарушен — всё плохо.
В подобных ситуациях принято использовать метод subTest. Вместо написания большого количества однотипных тестов можно собрать все проверки и ожидаемые результаты в словарь — и пройти по ним циклом. Много интересного о subTest можно узнать в официальной документации.
Проверка verbose_name
и help_text
для нескольких полей будет выглядеть так:
# deals/tests/tests_models.py
from django.test import TestCase
from deals.models import Task
class TaskModelTest(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Создаём тестовую запись в БД
# и сохраняем созданную запись в качестве переменной класса
cls.task = Task.objects.create(
title='Заголовок тестовой задачи',
text='Тестовый текст',
slug='test-task'
)
def test_verbose_name(self):
"""verbose_name в полях совпадает с ожидаемым."""
task = TaskModelTest.task
field_verboses = {
'title': 'Заголовок',
'text': 'Текст',
'slug': 'Адрес для страницы с задачей',
'image': 'Картинка',
}
for field, expected_value in field_verboses.items():
with self.subTest(field=field):
self.assertEqual(
task._meta.get_field(field).verbose_name, expected_value)
def test_help_text(self):
"""help_text в полях совпадает с ожидаемым."""
task = TaskModelTest.task
field_help_texts = {
'title': 'Дайте короткое название задаче',
'text': 'Опишите суть задачи',
'slug': ('Укажите адрес для страницы задачи. Используйте только '
'латиницу, цифры, дефисы и знаки подчёркивания'),
'image': 'Загрузите картинку',
}
for field, expected_value in field_help_texts.items():
with self.subTest(field=field):
self.assertEqual(
task._meta.get_field(field).help_text, expected_value)
Конструкция for field, expected_value in field_help_texts.items()
распаковывает словарь field_help_texts
с помощью метода items()
— создаёт из него два кортежа, доступных для итерации с помощью цикла:
- кортеж
field
содержит ключи исходного словаряfield_help_texts
; - кортеж
expected_value
содержит значения исходного словаряfield_help_texts
.
Значения из кортежа field
передаются как параметр в self.subTest(field=field)
; метод subTest()
устроен так, что при падении вложенного теста в отчёт будет выведено имя того поля, на котором упал тест. Если не передать этот параметр — в отчёте будет лишь информация «какой-то subTest()
упал», и искать ошибку будет неудобно. Для лучшего понимания обязательно посмотрите документацию по subTest()
.
Если вам платят за каждую строчку кода, как индийским разработчикам из анекдотов — subTest() лучше не использовать, разумеется.
Что в моделях нужно тестировать обязательно
В обязательном порядке тестами должен быть покрыт весь код, который вы написали сами:
- валидация полей моделей,
- методы по работе с моделями,
и всё, что может сломать логику работы программы.В приложении deals обязательно нужно проверить, что при автоматическом создании содержимого поля slug из title текст будет правильно преобразован, а его длина будет не больше ста символов.При тестировании лучше использовать данные, похожие на настоящие. Например, для тестирования метода save()
лучше создать объект, в котором поле title
будет заполнено кириллицей.
# deals/tests/tests_models.py
from django.test import TestCase
from deals.models import Task
class TaskModelTest(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Создаём тестовую запись в БД
# и сохраняем созданную запись в качестве переменной класса.
# Значение slug не указываем, ждём, что при создании объекта
# оно создастся автоматически из title.
# А title сделаем таким, чтобы после транслитерации он стал более 100 символов
# (буква "ж" транслитерируется в два символа: "zh")
cls.task = Task.objects.create(
title='Ж'*100,
text='Тестовый текст'
)
def test_text_convert_to_slug(self):
"""Содержимое поля title преобразуется в slug."""
task = TaskModelTest.task
slug = task.slug
self.assertEqual(slug, 'zh'*50)
def test_text_slug_max_length_not_exceed(self):
"""Длинный slug обрезается и не превышает max_length поля slug в модели."""
task = TaskModelTest.task
max_length_slug = task._meta.get_field('slug').max_length
length_slug = len(task.slug)
self.assertEqual(max_length_slug, length_slug)
Очевидные бонусы
Теперь любое изменение в классе Task, затрагивающее verbose_name
или max_length
у полей title
или slug
, обрушит тесты. Если нужно внести изменения — сначала надо будет исправить тесты под новые требования, как и положено в Test-driven Development.