Зачем нужны фабрики в тестировании
“В больших проектах есть необходимость контролировать очень много критичных частей, и не всегда есть время на их контроль вручную”
Эта фраза знакома каждому разработчику, который хоть раз сталкивался с поддержкой legacy-кода или пытался написать тесты для сложной бизнес-логики. Чем масштабнее проект, тем больше в нем связей: ForeignKey, ManyToMany, кастомные валидаторы, сигналы, сложные бизнес-правила. И каждый новый тест требует создания десятков связанных объектов. В этой статье я расскажу, как фабрики (factory_boy) помогают решить эту проблему на примере простых(даже через чур) тестов, через pytest для Django моделей:
-
быстрыми — не нужно писать boilerplate-код в каждом тесте
-
надежными — данные генерируются автоматически, а не хардкодятся
-
поддерживаемыми — изменение модели не ломает сотни тестов
Сами модели, для понимания примеров, они создаются через Django:
Gener - хранит название жанра
class Genre(models.Model):
name = models.CharField(max_length=200,
help_text='Введите жанр книги',
verbose_name='Жанр книги')
def __str__(self):
return self.name
Language - хранит название языков
class Language(models.Model):
name = models.CharField(max_length=20,
help_text='Введите язык книги',
verbose_name='Язык книги')
def __str__(self):
return self.name
Author - информация об авторе и его краткие данные
class Author(models.Model):
first_name = models.CharField(max_length=100,
help_text="Введите имя автора",
verbose_name="Имя автора")
last_name = models.CharField(max_length=100,
help_text="Введите фамилию автора",
verbose_name="Фамилия автора")
data_of_birth = models.DateField(help_text="Введите дату рождения",
verbose_name='Дату рождения',
null=True, blank=True)
data_of_death = models.DateField(help_text='Введите дату смерти',
verbose_name='Дата смерти',
null=True, blank=True)
def __str__(self):
return self.last_name
Book - информация о книге, в которой еще прикрепляются все выше перечисленные модели
class Book(models.Model):
title = models.CharField(max_length=200,
help_text='Введите названия книги',
verbose_name='Название книги')
genre = models.ForeignKey('Genre', on_delete=models.CASCADE,
help_text='Выберите жанр книги',
verbose_name='Жанр книги', null=True)
language = models.ForeignKey('Language', on_delete=models.CASCADE,
help_text='Выберите язык книги',
verbose_name='Язык книги', null=True)
author = models.ManyToManyField('Author',
help_text='Выберите автора книги',
verbose_name='Автор книги')
summary = models.TextField(max_length=1000,
help_text='Введите краткое описание книги',
verbose_name='Аннотация книг')
isbn = models.CharField(max_length=13,
help_text='Должно содержать 13 символов',
verbose_name='ISBN книги')
def display_author(self):
return ", ".join([author.last_name for author in self.author.all()])
display_author.short_description = 'Авторы'
def __str__(self):
return self.title
Эти модели, наглядно нужны, для понимания работы фабрик в данных примерах и их применение в тестах. Сами по себе это таблицы со своими атрибутами.
Проблема: ручное создание данных — это антипаттерн
антипаттерн - это распространённый подход к решению класса часто встречающихся проблем, являющийся неэффективным, рискованным или непродуктивным...
Представьте, что вы тестировщик и вам дали задание. Протестировать часть моделей, для проверки правильно работающего кода. Первым с чем вы столкнётесь, это не как правильно предугадать поведение модели в бизнес коде, а просто приготовить эти данные для тестов. Ниже приведен плохой пример кода:
def test_book_creation():
# Создаем жанр
genre = Genre.objects.create(name="Фантастика")
# Создаем язык
language = Language.objects.create(name="Русский")
# Создаем автора
author = Author.objects.create(
first_name="Аркадий",
last_name="Стругацкий",
data_of_birth="1925-08-28"
)
# Создаем книгу
book = Book.objects.create(
title="Пикник на обочине",
genre=genre,
language=language,
summary="Одна из самых известных повестей...",
isbn="9785171180975"
)
book.author.add(author)
# Теперь можно тестировать
assert book.display_author() == "Стругацкий"
Этот код читаемый и понятный для всех, но есть несколько НО:
-
Каждый раз создаем все объекты в ручную. Жестко привязывая к конкретным значениям.
-
Если пишем много таких однотипных данных, то мы столкнёмся с дублированием кода, что в свою очередь приведет к не правильной тестировки кода, что является проблемой.
-
Создание, добавление связей и проверка, все это в одной функции. Нужно дробить и разделять код, для более легкой поддержки и масштабируемости проекта.
-
При создании новых объектов, мы можем не правильно создать его, что приведет к багам и опять трате времени на правку ошибок.
Одно из решений: фабрики
Фабрики решают проблему с созданием объектов в ручную, помогая проверять в тестах более обширные данные и не тратить на это время. В добавок их можно переиспользовать.
Базовая модель фабрики будет выглядеть так:
class AuthorFactory(factory.django.DjangoModelFactory):
class Meta:
model = Author
first_name = factory.Faker("first_name")
last_name = factory.Faker("last_name")
data_of_birth = factory.Faker("date_time")
data_of_death = factory.Faker("date_time")
Хочу прояснить, генерация атрибутов идет через библиотеку Faker, которая выдает определенные данные, под указанный атрибут. В данных примерах почти всю генерацию, мы производим через неё. Заметим, что factory упирается на уже созданию модель в БД, что помогает предотвратить некоторые конфликты и ошибки, которые мы могли не предусмотреть.
Теперь создание автора будет выглядеть так:
author = AuthorFactory.create()
vs
author = Author.objects.create(
first_name="Аркадий",
last_name="Стругацкий",
data_of_birth="1925-08-28"
)
Уже создание самой модели становиться уже намного проще и нам не надо запариваться, что же хранить в данном объекте. Если нам так понадобиться, то мы можем создать не стандартную модель, но это как раз таки проверка 1% сценариев, а фабрики закрывают обычные 99% .
А теперь посмотрим как измениться неудачный пример кода, при замене создания обычных объектов, на фабрики:
def test_book_creation():
# Создаем жанр
genre = GenreFactory.create()
# Создаем язык
language = LanguageFactory.create()
# Создаем авторов
author1 = AuthorFactory.create()
author2 = AuthorFactory.create()
# Создаем книгу
book_create = BookFactory.create(author=[author1, author2], genre=genre, language=language)
# Теперь можно тестировать
assert len(book.author.all()) == 2
И что же мы видим. Первое что бросается в глаза, то что мы просто создаем объекты через фабрики, не заморачиваесь над атрибутами. Что в свою очередь делает код надежным, кратким и переиспользуемым, потому что каждый раз будут создаваться разные данные.
А теперь разберем что произошло. Мы создаем 1 объект Genre, 1 объект Language, 2 объекта Author и потом создаем объект Book, присваивая ему все объекты выше. После чего уже можем тестировать, как душе угодно. Ниже я расскажу что можно не создавать в ручную Genre и Language, ведь они относятся один ко многим и это можно тоже оптимизировать. А вот авторов нет, потому что там связь ManyToMany. И у вас закономерно появляется вопрос, а как связывать объекты в их генерации? Сейчас расскажу, а то пример выше может немного смутить.
class GenreFactory(factory.django.DjangoModelFactory):
class Meta:
model = Genre
name = factory.Faker("name")
class LanguageFactory(factory.django.DjangoModelFactory):
class Meta:
model = Language
name = factory.Faker("name")
class BookFactory(factory.django.DjangoModelFactory):
class Meta:
model = Book
title = factory.Faker("sentence")
genre = factory.SubFactory(GenreFactory)
language = factory.SubFactory(LanguageFactory)
summary = factory.Faker("paragraph")
isbn = str(random.randint(1000000000000, 9999999999999))
@factory.post_generation
def author(self, create, extracted, **kwargs):
if not create or not extracted:
return
self.author.add(*extracted)
Что мы тут делаем. Прописывая заранее фабрики для жанра и языка, после мы просто закидываем их внутрь фабрики книги. А вот авторов мы не можем так же легко засунуть в модель, из-за связи. Но все же можем присвоить список авторов, как в примере выше и свободно его генерировать за счет фабрики.
Но давайте вернемся к нашему примеру, он не идеален, давайте его доработаем, например просто проверим тип данных. Но сделаем в стиле pytest. Для начала мы создадим фикстуру, которая будет подготавливать для нас данные, традиционно в conftest.py.
@pytest.fixture(scope='function')
def test_book_factory() -> Book:
author1: Author = AuthorFactory.create()
author2: Author = AuthorFactory.create()
book_create: Book = BookFactory.create(author=[author1, author2])
return book_create
Уже мы видим, что не надо отдельно создавать данные для жанра и языков, а сразу мы создаем 2 авторов и уже книгу. После чего мы передаем её. Заметим что мы уже разделили ответвенность, по сравнению со старым кодом, в котором было одновременно создание и проверка данных.
Далее мы создадим тест в отдельном файле, назовем его test_models.py, и напишем код, указанный ниже.
# test_book_factory - название фикстуры и мы её вызываем так в тестах
@pytest.mark.django_db
def test_model_book(test_book_factory: Book) -> None:
title: str = test_book_factory.title
genre: Genre = test_book_factory.genre
language: Language = test_book_factory.language
summary: str = test_book_factory.summary
isbn: str = test_book_factory.isbn
authors: QuerySet = test_book_factory.author.all()
assert len(summary) <= 1000
assert len(title) <= 200
assert len(isbn) <= 13
assert isinstance(title, str)
assert isinstance(genre, Genre)
assert isinstance(language, Language)
assert isinstance(summary, str)
assert isinstance(isbn, str)
for author in authors:
assert isinstance(author, Author)
Что мы делаем здесь, мы распаковываем сгенерированную модель и проверяем тип данных и не выходят ли они за рамки длинны, а еще проверяем каждого автора на “подлинность” модели. Сам тест завернут в декоратор, который создает временную БД Django, что позволяет тестировать фабрики, не смешивая их с настоящей БД. Тест простой, я бы сказал элементарный, но он показывает работу, на этом месте могла быть проверка поведения сгенерированных данных уже в бизнес логике, что уже повысила цену этого теста. Но не будем об этом углубляться. Давайте лучше сравним с самым первым вариантом теста.
def test_book_creation():
# Создаем жанр
genre = Genre.objects.create(name="Фантастика")
# Создаем язык
language = Language.objects.create(name="Русский")
# Создаем автора
author = Author.objects.create(
first_name="Аркадий",
last_name="Стругацкий",
data_of_birth="1925-08-28"
)
# Создаем книгу
book = Book.objects.create(
title="Пикник на обочине",
genre=genre,
language=language,
summary="Одна из самых известных повестей...",
isbn="9785171180975"
)
book.author.add(author)
# Теперь можно тестировать
assert book.display_author() == "Стругацкий"
Уже старый вариант не выглядит так привлекательно как новый.
Сделаем выводы
-
В фабриках мы не создаем объекты в ручную и они не жестко привязаны к конкретным значениям.
-
Появление однотипных данных крайне мала, ведь сочетания данных почти не возможно повторить и код будет более широко тестироваться.
-
В данном примере, разделили логику создания данных через фабрики и уже фактической тестировки их.
-
Создание не “правильных” объектов почти не возможно, если конечно не надо прописать отдельно такой тест, в добавок создать баг крайне сложно в таких условиях.
Планы на будущее
-
Можно до конца довести некоторые моменты в создании фабрики, например связь ManyToMany внутри BookFactory с Author.
-
Статья создана для элементарного понятия как это работает и не хватает более сложных примеров кода для уже более опытных.
-
Слишком малый объем решаемых проблем показано в данной статье, в будущем возможны дополнительные примеры.
Заключение
“Фабрики сокращают время написания тестов на 40-60% и уменьшают количество багов, связанных с некорректными тестовыми данными” - главная мысль статьи.
В данной статье мы рассмотрели, как можно использовать фабрики в тестирование, а точнее при использование инструментов pytest и Django. В будущем планирую расширять знания в плане фабрик и тестирования и делиться им в статьях. Надеюсь у тебя не осталось вопросов, как это работает и зачем это нужно. Если будут вопросы, то буду ждать.
Автор: provider228
