Тестирование — неотъемлемая часть разработки, особенно если речь идёт о сложных приложениях. Оно позволяет нам быть уверенными в качестве кода и предотвращать неожиданные ошибки. Но важный вопрос, который рано или поздно возникает у каждого разработчика: как балансировать между качественным покрытием тестами и затратами на их написание? И, что ещё более важно, как справляться с рутиной при этом?
Работа с тестовыми данными — проблема, способная выжать все силы у даже самых терпеливых разработчиков. Простые тесты с примитивными параметрами пишутся быстро и легко. Но когда ваш метод оперирует сложными объектами с вложенными структурами, ситуация усложняется многократно. С каждым новым тестом всё больше времени уходит на составление данных, разрушая читаемость и увеличивая объём работы.
Эта история — не про рекламу библиотек или продвижение инструментов. Это мой личный опыт борьбы с типовыми проблемами генерации данных в тестах TypeScript. Я решил поделиться тем, с какими сложностями я столкнулся, как привычные подходы из других языков не сработали и к каким решениям в итоге удалось прийти и, возможно, найти новый взгляд на проблему от сообщества.
Проблема тестовых данных в юнит-тестах
Написать хороший тест не так уж и сложно, если функциональный код реализован в соответствии с SOLID и не перегружен логикой, то тест тоже должен оказаться простым и ёмким, но… на этом этапе все упирается в модели.
Пока мы оперируем понятиями «метод принимает string», «метод возвращает int» все работает прекрасно, но, когда мы переходим к работе с объектами, возникает проблема подстановки данных в каждую требуемую модель в каждом тесте.
На первый взгляд задача их генерирования может показаться тривиальной: мы просто создаем объекты с нужными значениями. Но на практике:
Сложность сущностей. Чем сложнее типы данных в коде (например, вложенные структуры или коллекции с разными объектами), тем труднее генерировать валидную тестовую выборку вручную.
Объем. Тестов много, а значит, формирование каждого набора данных занимает огромное время.
Поддержка. При рефакторинге или изменении структуры типов обновлять вручную тестовые данные становится еще одной затратной задачей.
Подходы к формированию тестовых данных
Ниже я приведу описание типичного, на мой взгляд, пути эволюции программиста тестирующего кода и, возможно, вы где-то узнаете свой проект)
Все свое ношу с собой
Первая вариация основывается на все том же подходе с примитивами – если я могу определить значение переменной int прямо в тесте, почему бы не заполнить там же ссылочный тип?
Для простых объектов вроде класса Role с полями Id, да Name все это, конечно, работает, но раз есть role, то есть и user, которому эта роль принадлежит, еще есть UserInRole, который связывает эти две сущности… Если в функциональном коде есть метод вроде «получить все имена ролей пользователя с логином x», то нам придется прописывать уже не одну, а три модели.
Тест разрастается с ростом модели, читать его становится крайне неудобно – как понять, какие из этих данных действительно важны для теста (тот же логин пользователя), а какие нет?
При таком подходе мы
Ломаем читаемость
Забываем о лаконичности
А еще тратим уйму времени на то, чтобы написать каждый тест
Специальные методы в тестовых классах
Наконец, кто-то обращает внимание на то, что в классе X куча тестов содержат дублирующий код и это дублирование выносится в отдельный метод, здесь же в классе X.
Тесты снова стали небольшими, мы перестали нарушать каноны о запрете на дублирование кода… хотя стоп, но таких запретов нет в отношении тестирующего кода! Чего же мы добились? Можем ли мы сказать, что починили тест в части лаконичности? Стоит признать, что да, код стал чище… вот и первая победа)
Что же с минусами?
Читаемость не только не улучшилась, вероятно мы ее даже ухудшили – теперь, чтобы понять, что происходит в тесте приходится скакать от теста к фабричному методу и обратно. Есть множество ситуаций, когда один и тот же объект должен использоваться в разных классах, если мы пишем подделку в классе А, то что делать в классе Б? Дублировать? В маленьком проекте это заметно не сразу, но стоит масштабу вырасти, и мы оказываемся по уши в проблемах… Рукописная «база данных» В какой-то момент приходит «гениальная» идея: можно захардкодить поддельную базу данных в статических коллекциях, прописать все значения и связи и пользоваться этими данными в тестах!
Не вижу смысла особо рассуждать по этому варианту, ибо он годится только для проектов с тремя классами и желательно без связей между ними)
Мы не решаем этим ничего, централизация управления поддельными данными теряется на фоне огромных минусов…
При росте масштаба становится фантастически сложно учесть и прописать все взаимосвязи в этой системе.
Инициализатор поддельного контекста превращается в монстра непомерной длинны.
Кроме того, разработчики в таких историях склонны опираться на данные в этих коллекциях – раз нужное значение уже задано, то будем при написании теста держать это в голове и использовать именно его. Вот и все, теперь мы окончательно попрощались с читаемостью)
Плюс ко всему, эти коллекции становятся сакральны – упаси бог тронуть хоть что-нибудь, сразу рухнет половина тестов…
Фабрики
В конце концов нам надоедает писать одно и то же в каждом тесте или трястись над самописной «базой данных» и принимается решение перейти на фабрики объектов. Так, если у нас есть класс Role, то в тестирующем проекте мы создаем RoleFakeFactory, с методами в духе Create, CreateMany. Да, придется решить ряд вопросов: статическими ли должны быть эти фабрики? Как они получают контекст данных? И тому подобное.
В действительности эти вещи мало влияют на удобство обсуждаемого подхода. Опять же, при небольшом объеме кода создается ощущение, что все сделано правильно и решение действительно работает. И первые пару недель все, наверняка, довольны проделанной работой)
И опять, чего же мы добились?
Удалось окончательно побороться с дублированием, теперь все лежит в отведенном месте. Да… вот, собственно, и все.
Минусы?
С нами по-прежнему плохая читаемость кода. Да, теперь управление подделками централизовано, но как трактовать эти значения полей?
public class UserFactory
{
public static Create(): User
{
return new User
{
Id = 345,
Name = "Vasya",
Email = "mail@test.test",
PhoneNumber = "123456789",
EmailConfirmed = true,
PhoneNumberConfirmed = true
}
}
}
EmailConfirmed == true для какого-то конкретного теста? Для большинства тестов? Это дефолтное значение? Решительно не понятно откуда тут взялись все эти значения и каким из них стоит придавать смысл, а каким нет.
Читаемость по-прежнему на уровне предыдущего пункта.
Сакральность никуда не делась, мы просто чуть иначе оформили «волшебные» значения, не поменяв, в сущности, подхода.
Автоматическая генерация объектов
Во многих языках есть свои способы формирования рандомых объектов в целях unit-тестирования. Не столь важно что конкретно делается для такой генерации, но (в рамках наших рассуждений) важно понять какие последствия несёт такая автоматизация.
Генерация объектов в runtime рандомными значениями, больше не придется гадать назначение захардкоженных переменных, - все, что действительно необходимо для теста будет доопределено в нем явно.
Создание экземпляров классов в одну строчку. Прощаемся с фабриками, спец.методами и прочим, мы получаем возможность создать работоспособный объект в любом месте и слюбой конфигурацией.
Чего мы добились, введя автогенерацию?
Читаемостью кода мы определенно теперь можем гордиться. Все необходимые данные находятся прямо в тесте. Лаконичность тоже на высоте – все, что написано, написано для обеспечения конкретного теста, никаких лишних данных. Скорость чтения и особенно написания тестов значительно увеличилась, относительно некоторых описанных подходов увеличение может быть кратным. Скорость – следствие простоты и удобства работы с системой, а чем выше эти показатели, тем выше вовлеченность разработчиков в процесс, тем охотнее они будут писать тесты и тем выше в итоге станет качество продукционного кода.
Я часто говорю о том, что все уже придумано за нас и программистам не стоит изобретать велосипеды на каждом шагу, но, в случае с typescript, эта концепция дала сбой...
Вдохновение из C#: AutoFixture
AutoFixture стал настоящим помощником в моей работе с тестами на C#. Эта библиотека позволяет автоматически генерировать объекты на основе их типа, минимизируя ручной труд. В основе работы AutoFixture лежит рефлексия — доступ к метаданным типов во время выполнения программы, что делает библиотеку мощной и удобной.
Попробовав AutoFixture однажды, я подружился с этим подходом: меньше повторяющегося кода, больше фокуса на логике тестов. Работало это настолько гладко, что, переходя к TypeScript и Angular, мне стало не хватать такой же возможности.
TypeScript и проблема тестовых данных
TypeScript — мощный язык, который стал незаменимым в разработке сложных фронтенд-приложений. Но есть важное ограничение: все типы существуют только на этапе компиляции. Когда код превращается в JavaScript, вся информация о типах исчезает.
Таким образом, то, что легко реализуется через рефлексию на C#, недостижимо в привычном TypeScript-коде. Я долго искал аналоги AutoFixture в TypeScript, но все было тщетно: либо библиотеки обладали слабым функционалом, либо требовали явного указания типов при генерации, что не снимало основную проблему.
TypeScript-трансформеры как путь к решению
Во время поисков я наткнулся на TypeScript-трансформеры (TypeScript Transformers). Это невероятно мощный, но малоиспользуемый инструмент.
TypeScript-трансформеры — это плагины, которые вмешиваются в процесс компиляции TypeScript в JavaScript. Они позволяют добавлять или изменять код "на лету". Именно благодаря трансформерам можно делать то, что недоступно обычному TypeScript-коду, — передавать метаинформацию о типах в финальный JavaScript.
Будучи вдохновленным идеей, я отправился в путь разработки собственной библиотеки автоматической генерации объектов для тестов.
В общем, выглядело все круто, но... как и у большинства мощных инструментов, здесь есть своя ложка дегтя. Главная сложность — использование TypeScript-трансформеров. Это невероятно полезная технология, но она требует некоторых "танцев с бубном". TypeScript-трансформеры вмешиваются в процесс компиляции, а значит, их нужно интегрировать в проект вручную.
В итоге на простой установке либы через npm install был поставлен крест. Чтобы нормально работать с трансформациями нужно:
Использовать сборщик с поддержкой TypeScript-трансформеров (например, Webpack с ts-loader, или другие инструменты, такие как esbuild или ts-patch).
Зарегистрировать трансформер в конфигурации компилятора — это не просто декларация, это добавление нового этапа в процесс сборки проекта.
Иногда обновлять настройки при изменении версии TypeScript — обновления могут не всегда быть бесшовными
Хотя по шагам это звучит не так уж сложно, на практике не каждый разработчик хочет углубляться в настройки сборки для использования одной библиотеки. Со временем мне, конечно, удалось свести всё к простейшим шагам, но любые "интересные" подходы всегда приводят к большей багонагрузке и сложностям для пользователей, однако... имеем, что имеем - пока я не придумал ничего лучше. При этом зависимости от инструментария остались на месте - не все сборщики адекватно работают с трансформациями, например, Webpack и ts-loader, tsc работают нормально, но вот всякие "легковесные" инструменты для разработки, такие как Vite или esbuild, могут потребовать оптовой закупки бубнов.
Проще говоря, я так и не смог заставить либу работать "из коробки", что лично мне не мешает, ибо в стандартных для меня проектах Angular все работает ок, в редких "чистых" ts'ках с Jest тоже, а вот за границами этого - туман войны, до которого руки не доходят.
Стоит ли игра свеч?
С учетом всех минусов работы с трансформерами, можно задаться вопросом: нужно ли всё это вообще? Ведь всё равно генерацию данных можно настроить вручную, или использовать упрощённое API.
Для меня ответ однозначен: все эти "танцы с бубном" однозначно стоят затраченных усилий. Я считаю оправданным даже то время которое я убил на разборки с документацией TS и написание/тестирование библиотеки, а уж о простом использовании и речи не идет - я сейчас включаю её во все свои проекты по умолчанию.
Кроме того, сама технология трансформеров всё еще развивается. Возможно, со временем экосистема TypeScript-сборщиков сделает процесс подключения проще, чем сейчас.
Итог
В конце концов, я пришел к форме тестов, максимально похожей на логику тех, что писал в C#:
it('number of lines is correct', () => {
const items = Forger.create<LineItem[]>()!;
//
const result = service.convert(items);
//
should().array(result).length(items.length);
});
ListItem
здесь - сложный объект с кучей полей, но поскольку его содержимое мне не интересно в рамках теста, достаточно просто заполнить его хоть чем-нибудь, чтобы тестируемый код корректно работал, позволяя протестировать узкую логику теста.
Библиотека активно используется в моих проектах и, надеюсь, станет удобным инструментом для других разработчиков, которые стремятся сделать свои тесты не только качественными, но и удобными в написании. А может быть кто-то сможет подсказать новые пути и подходы. Познакомиться с возможностями можно тут, а с предложениями добро пожаловать в комментарии и личку, буду рад конструктивным идеям.