В этой статье я хочу предложить альтернативу традиционному стилю дизайна тестов, используя концепции функционального программирования в Scala. Подход был навеян многомесячной болью от поддержки десятков и сотен падающих тестов и жгучим желанием сделать их проще и понятнее. Несмотря на то, что код написан на Scala, предлагаемые идеи будут актуальны для разработчиков и тестировщиков на всех языках, поддерживающих парадигму функционального программирования. Ссылку на Github с полным решением и примером вы сможете найти в конце статьи. Если вы когда-либо имели дело с тестами (не важно — юнит-тестами, интеграционными или функциональными), скорее всего они были написаны в виде набора последовательных инструкций. К примеру: Это предпочтительный для большинства, не требующий освоения, способ описания тестов. В нашем проекте около 1000 тестов разных уровней (юнит-тестов, интеграционных, end-to-end), и все они, до недавнего времени, были написаны в подобном стиле. По мере роста проекта, мы начали ощущать значительные проблемы и замедление при поддержке таких тестов: приведение тестов в порядок занимало не меньше времени, чем написание бизнес-значимого кода. При написании новых тестов всегда приходилось придумывать с нуля, как подготовить данные. Зачастую копипастой шагов из соседних тестов. Как следствие, когда изменялась модель данных в приложении, карточный домик рассыпался и приходилось собирать его по-новой в каждом тесте: в лучшем случае, всего лишь изменением функций-хелперов, в худшем — глубоким погружением в тест и его переписыванием. Когда тест падал честно — т. е. из-за бага в бизнес-логике, а не из-за проблем в самом тесте — понять, где что-то пошло не так, без дебага было невозможно. Из-за того, что в тестах нужно было долго разбираться, никто в полной мере не обладал знаниями о требованиях — каким образом система должна себя вести при определенных условиях. Вся эта боль — симптомы двух более глубоких проблем такого дизайна: Можем ли мы сделать лучше? (Спойлер: можем.) Давайте рассмотрим, из чего состоит этот тест. Тестируемый код, как правило, будет ждать на вход некие явные параметры — идентификаторы, размеры, объемы, фильтры и т. п. Также зачастую ему будут необходимы и данные из реального мира — мы видим, что для получения меню и шаблонов меню приложение обращается к базе данных. Для надежного выполнения теста нам понадобится фикстура (fixture) — состояние, в котором должна находиться система и/или провайдеры данных до запуска теста и входные параметры, нередко связанные с состоянием. Этой фикстурой мы подготовим зависимость (dependency) — наполним базу данных (очередь, внешний сервис и т. п.). Подготовленной зависимостью мы инициализируем тестируемый класс (сервисы, модули, репозитории, т. п.). Исполнив тестируемый код на некоторых входных параметрах, мы получим бизнес-значимый результат (output) — как явный (возвращенный методом), так и неявный — изменение пресловутого состояния: базы данных, внешнего сервиса и т. д. Наконец, мы проверим, что результаты оказались ровно такими, какими ожидали, подводя итог теста одной или несколькими проверками (assertion).  Можно прийти к выводу, что в целом тест состоит из одних и тех же стадий: подготовки входных параметров, выполнения на них тестируемого кода и сравнения результатов с ожидаемыми. Мы можем использовать этот факт, чтобы избавиться от первой проблемы в тесте — слишком свободной формы, явно разделив тест на стадии. Эта идея не нова и уже давно применяется в тестах в BDD-стиле (behavior-driven development). Что насчет расширяемости? Любой из шагов процесса тестирования может содержать в себе сколько угодно промежуточных. Забегая вперед, мы могли бы формировать фикстуру, создавая сначала некую человекочитаемую структуру, а потом конвертировать ее в объекты, которыми наполняется БД. Процесс тестирования бесконечно расширяем, но, в конечном счете, всегда сводится к основным стадиям.  Попробуем воплотить идею разделения теста на стадии, но сначала определим, каким бы мы хотели видеть конечный результат. В целом, нам хочется сделать написание и поддержку тестов менее трудозатратным и более приятным процессом. Чем меньше в теле теста явных неуникальных (повторящихся где-то еще) инструкций, тем меньше изменений нужно будет вносить в тесты после изменения контрактов или рефакторинга и тем меньше времени понадобится на прочтение теста. Дизайн теста должен подталкивать к переиспользованию часто используемых кусков кода и препятствовать бездумному копированию. Неплохо было бы, если бы тесты имели унифицированный вид. Предсказуемость улучшает читаемость и экономит время — представьте, как много времени бы уходило у студентов-физиков на освоение каждой новой формулы, если бы они были описаны словами в свободной форме, а не математическим языком. Таким образом, наша цель — скрыть все отвлекающее и лишнее, оставив на виду только критически важную для понимания приложения информацию: что тестируется, что ожидается на входе, а что — на выходе.  Вернемся к модели устройства теста. Технически каждую точку на этом графике может представлять тип данных, а переходы из одной в другую — функции. Прийти от начального типа данных к конечному можно, поочередно применяя последующую функцию к результату предыдущей. Иными словами, используя композицию функций: подготовки данных (назовем ее prepare), исполнения тестируемого кода (execute) и проверки ожидаемого результата (check). На вход этой композиции мы передадим первую точку графика — фикстуру. Получившуюся функцию высшего порядка назовем функцией жизненного цикла теста. Встает вопрос, откуда возьмутся внутренние функции? Готовить данные мы будем ограниченным количеством способов — наполнять базу, мокать, т. п. — поэтому варианты функции prepare будут общие для всех тестов. Как следствие, проще будет сделать специализированные функции жизненного цикла, скрывающие в себе конкретные реализации подготовки данных. Поскольку способы вызова проверяемого кода и проверки — относительно уникальные для каждого теста, execute и check будут подаваться явно. Делегируя все административные нюансы в функцию жизненного цикла, мы получаем возможность расширять процесс тестирования, не влезая ни в один уже написанный тест. За счет композиции мы можем внедряться в любую точку процесса, извлекать или добавлять туда данные. Чтобы лучше проиллюстрировать возможности такого подхода, решим вторую проблему нашего изначального теста — отсутствие вспомогательной информации для локализации проблем. Добавим логирование при получении ответа от тестируемого метода. Наше логирование не будет изменять тип данных, а лишь будет производить сайд-эффект — вывод сообщения на консоль. Поэтому после сайд-эффекта мы вернем его как есть. Вот таким простым движением мы добавили логирование возвращаемого результата и состояния базы в каждом тесте. Преимущество таких маленьких функций в том, что их легко понимать, легко композировать для переиспользования и легко избавиться, если они станут не нужны.  В результате наш тест будет выглядеть следующим образом: Тело теста стало немногословным, фикстуру и проверки можно переиспользовать в других тестах, и мы больше нигде вручную не подготавливаем базу данных. Остается лишь одна проблема... В коде выше мы использовали предположение, что фикстура уже откуда-то возьмется в готовом виде и ее лишь нужно будет передать в функцию жизненного цикла. Поскольку данные — ключевой ингредиент простых и поддерживаемых тестов, мы не можем не коснуться того, как их формировать. Предположим, у нашего тестируемого магазина есть типичная БД среднего размера (для простоты примера с 4 таблицами, но в реальности их могут быть сотни). Часть содержит справочную информацию, часть —непосредственно бизнесовую, и все вместе это можно связать в несколько полноценных логических сущностей. Таблицы связаны между собой ключами (foreign keys) — чтобы создать сущность Bonus, потребуется сущность Package, а ей в свою очередь — User. И так далее.  Обходы ограничений схемы и всяческие хаки ведут к неконсистентности и, как следствие, к нестабильности тестов и часам увлекательного дебага. По этой причине мы будем наполнять базу по-честному. Мы могли бы использовать боевые методы для наполнения, но даже при поверхностном рассмотрении этой идеи возникает много непростых вопросов. Что будет готовить данные в тестах на сами эти методы? Нужно ли будет переписывать тесты, если поменяется контракт? Что делать, если данные доставляются вообще не тестируемым приложением (например, импортом кем-то еще)? Как много различных запросов придется сделать, чтобы создать сущность, зависимую от многих других? Разрозненные хелпер-методы, как в изначальном примере — это та же проблема, но под другим соусом. Они возлагают ответственность по управлению зависимыми объектами и их связями на нас самих, а нам бы хотелось этого избежать. В идеале, хотелось бы иметь такой тип данных, одного взгляда на который достаточно, чтобы в общих чертах понять, в каком состоянии будет находиться система во время теста. Одним из неплохих кандидатов для визуализации состояния является таблица (а-ля датасеты в PHP и Python), где нет ничего лишнего, кроме критичных для бизнес-логики полей. Если в фиче изменится бизнес-логика, вся поддержка тестов сведется к актуализации ячеек в датасете. Например:  Из нашей таблицы, мы сформируем ключи — связи сущностей по ID. При этом, если сущность зависит от другой, сформируется ключ и для зависимости. Может случиться так, что две разные сущности сгенерируют зависимость с одинаковым идентификатором, что может привести к нарушению ограничения по первичному ключу БД (primary key). Но на этом этапе данные предельно дешево дедуплицировать — поскольку ключи содержат только идентификаторы, мы можем сложить их в коллекцию, обеспечивающую дедупликацию, например в Set. Если этого окажется недостаточно, мы всегда можем сделать более умную дедупликацию в виде дополнительной функции, скомпозированной в функцию жизненного цикла. Генерацию фейкового наполнения полей (например, имен) мы делегируем отдельному классу. Затем, прибегая к помощи этого класса и правил конвертации ключей, мы получим объекты-строки, предназначенные непосредственно для вставки в базу. Дефолтных фейковых данных, как правило, нам будет недостаточно, поэтому нужно будет иметь возможность переопределить конкретные поля. Мы можем воспользоваться линзами — пробегать по всем созданным строкам и менять поля только тех, которых нужно. Поскольку линзы в итоге — это обычные функции, их можно композировать, и в этом заключается их полезность. Благодаря композиции, внутри всего процесса мы сможем применить различные оптимизации и улучшения — к примеру, сгруппировать строки по таблицам, чтобы вставлять их одним insert’ом, уменьшая время прохождения тестов, или залогировать конечное состояние БД для упрощения отлова проблем. Все вместе даст нам фикстуру, которая наполнит зависимость для теста — базу данных. В самом тесте при этом не будет видно ничего лишнего, кроме изначального датасета — все подробности будут скрыты внутри композиции функций.  Наш тест-сьют теперь будет выглядеть следующим образом: А вспомогательный код: Добавление новых тест-кейсов в таблицу становится тривиальной задачей, что позволяет сконцентрироваться на покрытии максимального числа граничных условий, а не на бойлерплейте. Хорошо, мы написали много кода для подготовки фикстур в одном конкретном проекте, потратив на это немало времени. Что, если у нас несколько проектов? Обречены ли мы каждый раз переизобретать велосипед и копипастить? Мы можем абстрагировать подготовку фикстур от конкретной доменной модели. В мире ФП существует концепция тайпкласса (typeclass). Если коротко, тайпклассы — это не классы из ООП, а нечто вроде интерфейсов, они определяют некое поведение группы типов. Принципиальное различие в том, что эта группа типов определяется не наследованием классами, а инстанцированием, как обычные переменные. Так же, как с наследованием, резолвинг инстансов тайпклассов (через имплиситы) происходит статически, на этапе компиляции. Для простоты в наших целях, тайпклассы можно воспринимать как расширения (extensions) из Kotlin и C#. Чтобы залогировать объект, нам не нужно знать, что у этого объекта внутри, какие у него поля и методы. Нам важно лишь, чтобы для него было определено поведение log с определенной сигнатурой. Имплементировать некий интерфейс Logged в каждом классе было бы трудозатратно, да и не всегда возможно — например, у библиотечных или стандартных классов. В случае с тайпклассами все намного проще. Мы можем создать инстанс тайпкласса Logged, например, для фикстуры, и вывести ее в удобочитаемом виде. А для всех остальных типов создать инстанс для типа Any и использовать стандартный метод toString, чтобы бесплатно логировать любые объекты в своем внутреннем представлении. Кроме логирования, мы можем расширить этот подход на весь процесс подготовки фикстур. Решение для тестов будет предлагать свои тайпклассы и абстрактную имплементацию функций на их основе. Ответственность использующего его проекта — написать свои инстансы тайпклассов для типов. При проектировании генератора фикстур, я ориентировался на выполнение принципов программирования и проектирования SOLID как на индикатор его устойчивости и адаптируемости к разным системам: Убедившись в выполнении всех принципов, можно утверждать, что наше решение выглядит достаточно поддерживаемо и расширяемо, чтобы пользоваться им в разных проектах. Написав функции жизненного цикла, генерации фикстур и преобразования датасетов в фикстуры, а также абстрагировавшись от конкретной доменной модели приложения, мы, наконец, готовы масштабировать наше решение на все тесты. Мы перешли от традиционного (пошагового) стиля дизайна тестов к функциональному. Пошаговый стиль хорош на ранних этапах и небольших проектах тем, что не требует дополнительных трудозатрат и не ограничивает разработчика, но начинает проигрывать, когда тестов на проекте становится достаточно много. Функциональный стиль не призван решить все проблемы в тестировании, но позволяет значительно облегчить масштабирование и поддержку тестов в проектах, где их количество исчисляется сотнями или тысячами. Тесты в функциональном стиле получаются компактнее и фокусируются на том, что действительно важно (данные, тестируемый код и ожидаемый результат), а не на промежуточных шагах. Кроме того, мы рассмотрели на живом примере, насколько мощными являются концепции композирования и тайпклассов в функциональном программировании. С их помощью легко проектировать решения, неотъемлемой частью которых являются расширяемость и переиспользуемость. После нескольких месяцев эксплуатации можно сказать, что хоть команде и потребовалось некоторое время на то, чтобы привыкнуть к новым тестам, она осталась довольна результатом. Новые тесты пишутся быстрее, логи упрощают жизнь, а к датасетам удобно обращаться, когда возникают вопросы по корнер-кейсам. Мы стремимся к тому, чтобы постепенно перевести все тесты на новый стиль. Приятного вам тестирования! Ссылка на решение и пример: Github Scala разработчик / Разработчик в тестировании