Зачем нужны фабрики в тестировании

“В больших проектах есть необходимость контролировать очень много критичных частей, и не всегда есть время на их контроль вручную”

Эта фраза знакома каждому разработчику, который хоть раз сталкивался с поддержкой 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() == "Стругацкий"

Этот код читаемый и понятный для всех, но есть несколько НО:

  1. Каждый раз создаем все объекты в ручную. Жестко привязывая к конкретным значениям.

  2. Если пишем много таких однотипных данных, то мы столкнёмся с дублированием кода, что в свою очередь приведет к не правильной тестировки кода, что является проблемой.

  3. Создание, добавление связей и проверка, все это в одной функции. Нужно дробить и разделять код, для более легкой поддержки и масштабируемости проекта.

  4. При создании новых объектов, мы можем не правильно создать его, что приведет к багам и опять трате времени на правку ошибок.

Одно из решений: фабрики

Фабрики решают проблему с созданием объектов в ручную, помогая проверять в тестах более обширные данные и не тратить на это время. В добавок их можно переиспользовать.

Базовая модель фабрики будет выглядеть так:

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() == "Стругацкий"

Уже старый вариант не выглядит так привлекательно как новый.

Сделаем выводы

  1. В фабриках мы не создаем объекты в ручную и они не жестко привязаны к конкретным значениям.

  2. Появление однотипных данных крайне мала, ведь сочетания данных почти не возможно повторить и код будет более широко тестироваться.

  3. В данном примере, разделили логику создания данных через фабрики и уже фактической тестировки их.

  4. Создание не “правильных” объектов почти не возможно, если конечно не надо прописать отдельно такой тест, в добавок создать баг крайне сложно в таких условиях.

Планы на будущее

  1. Можно до конца довести некоторые моменты в создании фабрики, например связь ManyToMany внутри BookFactory с Author.

  2. Статья создана для элементарного понятия как это работает и не хватает более сложных примеров кода для уже более опытных.

  3. Слишком малый объем решаемых проблем показано в данной статье, в будущем возможны дополнительные примеры.

Заключение

“Фабрики сокращают время написания тестов на 40-60% и уменьшают количество багов, связанных с некорректными тестовыми данными” - главная мысль статьи.

В данной статье мы рассмотрели, как можно использовать фабрики в тестирование, а точнее при использование инструментов pytest и Django. В будущем планирую расширять знания в плане фабрик и тестирования и делиться им в статьях. Надеюсь у тебя не осталось вопросов, как это работает и зачем это нужно. Если будут вопросы, то буду ждать.