Зачем нужны фабрики в тестировании
“В больших проектах есть необходимость контролировать очень много критичных частей, и не всегда есть время на их контроль вручную”
Эта фраза знакома каждому разработчику, который хоть раз сталкивался с поддержкой 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. В будущем планирую расширять знания в плане фабрик и тестирования и делиться им в статьях. Надеюсь у тебя не осталось вопросов, как это работает и зачем это нужно. Если будут вопросы, то буду ждать.
