Варианты подделки тестовых данных

Написать хороший тест не так уж и сложно, если функциональный код реализован в соответствии с SOLID и не перегружен логикой, то тест тоже должен оказаться простым и лаконичным, но… на этом этапе все упирается в модели.

Пока мы оперируем понятиями «метод принимает string», «метод возвращает int» все работает прекрасно, но, когда мы переходим к работе с объектами, возникает проблема подстановки данных в каждую требуемую модель в каждом тесте.

Ниже я приведу описание типичного, на мой взгляд, пути эволюции тестирующего кода и возможно вы где-то узнаете свой проект)

Все свое ношу с собой

Первая вариация основывается на все том же подходе с примитивами – если я могу определить значение переменной int прямо в тесте, почему бы не заполнить там же ссылочный тип?

Для простых объектов вроде класса Role с полями Id, да Name все это, конечно, работает, но раз есть role, то есть и user, которому эта роль принадлежит, еще есть UserInRole, который связывает эти две сущности… Если в функциональном коде есть метод вроде «получить все имена ролей пользователя с логином x», то нам придется прописывать уже не одну, а три модели.

Тест разрастается с ростом модели, читать его становится крайне неудобно – как понять какие из этих данных действительно важны для теста (тот же логин пользователя), а какие нет (его имя, например)?

При таком подходе мы

  • Ломаем читаемость

  • Забываем о лаконичности

  • А еще тратим уйму времени на то, чтобы написать каждый тест

Специальные методы в тестовых классах

Наконец, кто-то обращает внимание на то, что в классе X куча тестов содержат дублирующий код и это дублирование выносится в отдельный метод, здесь же в классе X.

Тесты снова стали небольшими, мы перестали нарушать каноны о запрете на дублирование кода… хотя стоп, но таких запретов нет в отношении тестирующего кода! Чего же мы добились? Можем ли мы сказать, что починили тест в части лаконичности? Стоит признать, что да, код стал чище… вот и первая победа)

Что же с минусами?

  • Читаемость не только не улучшилась, вероятно мы ее даже ухудшили – теперь, чтобы понять, что происходит в тесте приходится скакать от теста к фабричному методу и обратно.

  • Есть множество ситуаций, когда один и тот же объект должен использоваться в разных классах, если мы пишем подделку в классе А, то что делать в классе Б? Дублировать? В маленьком проекте это заметно не сразу, но стоит масштабу вырасти, и мы оказываемся по уши в проблемах…

Рукописная «база данных»

В какой-то момент приходит «гениальная» идея: можно захардкодить поддельную базу данных в статических коллекциях, прописать все значения и связи и пользоваться этими данными в тестах!

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

Мы не решаем этим ничего, централизация управления поддельными данными теряется на фоне огромных минусов…

При росте масштаба становится фантастически сложно учесть и прописать все взаимосвязи в этой системе.

Инициализатор поддельного контекста превращается в монстра непомерной длинны.

Кроме того, разработчики в таких историях склонны опираться на данные в этих коллекциях – раз нужное значение уже задано, то будем при написании теста держать это в голове и использовать именно его. Вот и все, теперь мы окончательно попрощались с читаемостью)

Плюс ко всему, эти коллекции становятся сакральны – упаси бог тронуть хоть что-нибудь, сразу рухнет половина тестов…

Рукописные фабрики

В конце концов нам надоедает писать одно и то же в каждом тесте или трястись над самописной «базой данных» и принимается решение перейти на фабрики объектов. Так, если у нас есть класс Role, то в тестирующем проекте мы создаем RoleFakeFactory, с методами в духе Create, CreateMany. Да, придется решить ряд вопросов: статическими ли должны быть эти фабрики? Как они получают контекст БД или вовсе не должны знать о нем? И тому подобное.

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

И опять, чего же мы добились?

Удалось окончательно побороться с дублированием, теперь все лежит в отведенном месте. Да… вот, собственно, и все.

Минусы?

С нами по-прежнему плохая читаемость кода. Да, теперь управление подделками централизовано, но как трактовать эти значения полей?

public static class UserFactory
 {
    public static User Create()
    {
       return new User
       {
          Id = 0,
          Name = "0",
          Email = "0@cru.cru",
          PasswordHash = "WZRHGrs=",
          PhoneNumber = "12345",
          EmailConfirmed = true,
          PhoneNumberConfirmed = true,
          EmailConfirmationToken = "12345"
       }
    }
 }

EmailConfirmed == true для какого-то конкретного теста? Для большинства тестов? Это дефолтное значение? Решительно не понятно откуда тут взялись все эти значения и каким из них стоит придавать смысл, а каким нет.

Читаемость по-прежнему на уровне предыдущего пункта.

Сакральность никуда не делась, мы просто чуть иначе оформили «волшебные» значения, не поменяв, в сущности, подхода.

Автоматизация

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

В ближайшее время выйдет еще одна статья, рассказывающая о том, как применять на практике этот инструмент, пока же мы просто рассуждаем о подходах…

Главное преимущество - генерация объектов в runtime рандомными значениями, больше не придется угадывать назначение захардкоженных переменных, - все, что действительно необходимо для теста будет доопределено в нем явно.

Создание экземпляров классов в одну строчку. Прощаемся с фабриками, спец.методами и прочим, мы получаем возможность создать работоспособный объект в любом месте и с любой конфигурацией.

Чего мы добились, введя автогенерацию?

  • Читаемостью кода мы определенно теперь можем гордиться. Все необходимые данные находятся прямо в тесте.

  • Лаконичность тоже на высоте – все, что написано, написано для обеспечения конкретного теста, никаких лишних данных.

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

  • Скорость – следствие простоты и удобства работы с системой, а чем выше эти показатели, тем выше вовлеченность разработчиков в процесс, тем охотнее они будут писать тесты и тем выше в итоге станет качество продукционного кода.

Минусы?

  • Чуть более высокий порог входа. Начинающие разработчики, только пришедшие в проект довольно легко способны принять идею тех же фабрик, ибо «это знакомо», но много хуже генерацию кода в runtime с помощью сторонних библиотек. При этом стоит признать, что проблема не острая и глядя на уже написанный тест любой кнопкодав программист разберется с шаблонным подходом.

  • Скорость прогона тестов. Она закономерно падает, относительно той же самописной базы данных весьма существенно. С другой стороны, разница между, скажем, 4мя и 6ю секундами на прогон 400 тестов хоть и существенна, но, imho, окупается описанными плюсами и не является супер-критичной. Безусловно, если вы имеете дело с проектом космических масштабов в котором наличествует 10000+ тестов вы могли бы сказать, что разница становится по-настоящему существенной… если бы действительно запускали все тесты разом. В действительности нет никакой необходимости запускать 100500 тестов постоянно и при росте проекта частенько выделяют группы тестов для раздельного прогона в процессе разработки. Кроме того, современные среды разработки, вроде Rider, позволяют автоматически определить какой код был затронут в процессе разработки и выбрать тесты, относящиеся к нему.

  • Есть еще один сложный момент… В некоторых проектах сложность и количество связей между сущностями настолько велики, зациклены и запутаны, что автогенерация не приносит практически ничего, приходится хардкодить буквально все и вся и держать в голове уйму связей… Но я не считаю, что это стоит всерьез обсуждать, как минус подхода. О том, как побороться с циклическими зависимостями при автогенерации мы поговорим в следующих статьях, а сложности связей и вагон констант, которые надо учитывать… никто и не обещал, что говнокод сложноструктурированный код легко будет тестировать, его и поддерживать-то невозможно)