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