Каждый из нас знакомился с новомодными библиотеками, фреймворками, инструментами по getting started статьям из документации. В них всё складывается как по полочкам, в пёстрых красках показывается как всё просто и легко. Однако зачастую картина меняется, когда в Ваш новорожденный проект требуется подключить не одну условную библиотеку, а набор. Getting started осложняются появлением инородных элементов, и в процесс приходится подключать инстинкты. Когда за плечами многолетний опыт разработки и не один поднятый с колен проект, такая задача не видится проблемной. Однако, когда Вы делаете это в первый раз, инстинкты подводят. Впоследствии оглядываясь назад, мы жалеем о том, что в начале у нас не было опыта, который есть сейчас. Да и откуда было бы его получить? Ведь в getting started о таком не пишут, а проекты, в которых мы работаем не с самого начала, уже прошли этап становления. Этой статьёй я хотел рассказать свою историю рождения нового проекта. Я и сам, в общем то, не эксперт в затронутых областях, но, возможно, Вы сможете найти для себя кое-что на заметку, или подскажете, как сделать лучше. Не смотря на то, что в названии скучает в одиночестве Gradle, в самой статье мы упомянем gradle плагины java-test-suite и java-test-fixtures, testcontainers, liquibase и прочее. Дело в том, что конфигурация у нас заявлена не тривиальная, а для детривиализации конфигурации нужно больше её элементов. Появилась необходимость реализовать проект на Java. С нуля. Проект представляет из себя реинкарнацию древнего легаси, от которого в новом проекте остаются только ожидания. Легаси - это любительская поделка на чуждом заказчику стеке, поэтому никакой код от туда в новый проект не мигрирует, тем более, что процесс ознакомления с ним вызывает грусть и желание выпить. На основе известных ожиданий были сформированы требования к новому проекту: Java 17, Gradle, Spring boot, Hexagonal Architecture, unit-тесты, интеграционные тесты и прочие прелести. Опустим процесс выбора и его причины, сосредоточимся только на реализации. Проект, о котором, говорится в прологе, по объективным причинам не может быть публичным. Поэтому его роль в статье будет играть вымышленный. Дабы не усложнять себе задачу, ограничимся единственным функциональным требованием к нему: Система должна сохранять заметки и возвращать их список по запросу. Условимся, что UI - не наша задача. Наша задача - предоставить api для него. Getting Started по Gradle предписывает нам воспользоваться IDE для создания проекта или установить Gradle локально, что бы воспользоваться им в терминале. Не позволю себе наглость советовать Вам, что из этого выбрать, а лишь продемонстрирую экзотический способ Так уж вышло, что мне больше нравится делать это в терминале. Но для разового запуска устанавливать локальный Gradle не хочется. Поэтому я "придумал" запускать его в Docker. Тем более, что он нам обязательно понадобится дальше. Бонусом идёт то, что Gradle в проекте после инициализации будет самой свежей версии. Выбираем тип проекта basic, Groovy DSL, и имя проекта - в моём случае это simple-note. Дальше, разумеется, Gradle нужно запускать через wrapper - gradlew.bat, который появился после инициализации в корне проекта - в текущей папке. Теперь у нас в руках есть чистый лист, который нам нужно расчертить - описать структуру проекта. И тут имеются в виду не продиктованные соглашениями папочки вроде main, resources и прочее. Дело в том, что мы выбрали подход на основе принципов гексагональной архитектуры. А это значит, что у нас будет много модулей и зависимостей между ними. Про Hexagonal Architecture можно погуглить, но если в двух словах, то нам нужно ядро - модуль с минимально возможным количеством зависимостей, имплементирующим бизнес-логику, и несколько модулей-адаптеров, зависящих от ядра и имплементирующих декларированные в нём потребности и возможности его взаимодействия с окружением. У нас в корне проекта есть файл build.gradle. Он содержит только комментарий со ссылкой на документацию, если вы делали как я. Это корневой файл сборки и всем модули будут его подмодулями, поэтому давайте сразу обозначим в нём общие настройки для всех них: Применение плагина io.spring.dependency-management обеспечит нам одинаковую версию spring boot во всех зависящих от него модулях. Ну и Java 17. Добавим модуль business-core, то самое ядро с бизнес-логикой. как Вы помните у него минимум зависимостей, поэтому его build.gradle будет пустым. Условимся, что пакет simple.note будет корнем для всего кода приложения и создадим в модуле business-core пакет simple.note.core. Затем создадим в нём описание доменной сущности - заметки. Я использую record потому, что мне нравится эта конструкция. Только ради неё стоит "соскочить" с Java 8. Обратите внимание, так же, что я добавил сущность в пакет domain, для порядка. Объявим в нашем ядре сервис, позволяющий выполнять заявленный в первой части бизнес сценарий - сохранение заметки и получение их списка. Опять же, в гексагональной архитектуре есть порты, поэтому пакет port. В данном случае порт входящий, поэтому in. Для порядка. Для реализации сервиса нам понадобится взаимодействие с хранилищем данных. Мы пока не знаем какое оно будет, но нам пока и не надо. Объявим ещё один порт - исходящий. В пакете port.out. Теперь мы можем реализовать наш бизнес-сервис в пакете service Наибольший интерес тут представляет метод create. На вход он принимает текст, однако при сохранении в хранилище заполняется ещё размер заметки. После сохранения получаем идентификатор объекта в хранилище и уже используя этот идентификатор, получаем его от туда целиком. Чем не кейс для unit теста? Да, строить структуру проекта мы ещё не закончили, но про это будет вся статья. А сейчас время для unit тестов. Мало, кто из  дочитавших до сюда, получил какое-то откровение, я думаю. Пока что всё на уровне "Hello, world!". Должен предупредить, что в этой части для вас тоже вряд ли будет что-то новое. Однако, продолжим. Unit тесты распространяются на все модули, поэтому опять идём в корневой gradle.build и добавляем: То есть, для всех модулей мы добавляем одинаковые зависимости на JUnit и Mockito. Уверен, Вы знаете, что это такое. Если нет - можно погуглить. А так выглядит тест на нашу реализацию бизнес-сервиса: Запускаем в корне проекта gradlew test, наблюдаем зелёный BUILD SUCCESSFUL и идём дальше. Теперь нужно обеспечить бизнес-сервису реализацию исходящего порта для хранения наших заметок. Для этого добавим модуль adapters. Он будет пустой. Мы будем использовать его в качестве агрегата для модулей адаптеров, первым из которых станет storage. Добавляем. Так как базовая конфигурация сборки у нас уже готова, в build.gradle модуля :adapters:storage указываем только кое-какие зависимости: Вот тут у нас наглядный пример того, что адаптеры зависят от ядра, а не наоборот. А так же появляется первый компонент Spring boot. Мы будем использовать JPA для взаимодействия с БД PostgreSQL. Поэтому создаём entity-класс: Так как JPA не умеет работать с записями (record), то я добавил lombok, что бы не писать руками шаблонный код, который я не люблю больше, чем lombok. Добавим и репозиторий заодно: Обращаем внимание на пакеты, в которых расположен этот код. И идём реализовывать порт: Не забудем аннотировать класс как Component, ведь мы уже имеем дело со Spring Boot. Нашу реализацию порта хранилища тестировать unit тестами особого смысла не имеет, поэтому давайте разбираться с интеграционными. Обычно в гайдах по интеграционным тестам их пишут в стандартном наборе test (который рядом с main). Однако в test у нас уже unit тесты, и такой подход приведёт к путанице, в которой различать unit и интеграционные тесты будет не просто. Мне приходилось видеть решение в виде соглашения об именовании, согласно которому классы с unit тестами должны заканчиваться на *Test, а классы с интеграционными на *Tests. В конце концов, почему бы и нет? Ведь вся экосистема Java строится на фундаменте соглашения об именовании. Но в таком случае в набор test свалится куча зависимостей, которые в руках человека сотрут грань между типами тестов. А мы собрались тут не для этого, поэтому давайте так не делать. И да, мы условились не спорить о том, почему это нужно, а разобраться как это сделать. Давайте сделаем по gradle'вски. Для этого нам понадобится штатный JVM Test Suite Plugin. По первым двум абзацам его описания в документации можно сделать вывод, что создан он как раз под нашу задачу - группировку тестов. Когда я наткнулся на этот плагин, то был удивлён, что ранее мне не приходилось видеть его применения в реальных проектах. Я видел разные велосипеды, но только не то решение, которое Gradle предлагает из коробки. Хотя, конечно, у меня в этом не так много опыта. Взаимодействие нашего приложения с окружающим миром идёт через адаптеры, поэтому давайте вернёмся к build.gradle модуля adapters, который до сих пор у нас оставался пустым и добавим в него следующее: Этим самым во все подмодули adapters мы добавляем новый source set - integrationTest. Эта группа зависит от самого модуля, в котором расположена (строка 6), тесты из неё запускаются после тестов из группы test (строка 13), так же используется JUnit (строка 18), Implementation и RuntimeOnly группы расширяются аналогичными самого модуля (строки 24 и 25), а стандартная задача check так же запускает и тесты из созданной группы (строка 29). Согласно документации группа test устроена аналогичным образом, но, в виду её широкого распространения, конструкция, объявляющая группу test спрятана внутри Gradle (под плагином java) и включена в сборку по умолчанию. Ещё мы добавили группе integrationTest зависимость spring-boot-starter-test (строка 7). Раз приложение у нас строится с помощью spring-boot, то здесь ей самое место. Теперь мы можем добавить новые директории в src нашего адаптера storage: integrationTest/java и intergationTest/resources. Если Вы пользуетесь IDE, то она теперь даже предложит вам эти имена сама. Классы интеграционных тестов для компонентов Spring boot обвешиваются группой аннотаций, и что бы не обвешивать каждый класс, давайте сделаем для этого абстрактный, который тестовые классы будут расширять: Здесь есть всё, что нужно для запуска интеграционного теста, в отдельности про каждый атрибут расписывать, пожалуй, не буду - об этом кричат все гайды. Да и погуглить можно. А нам пришло время сделать тест: Здесь мы проверяем, что реализация порта хранилища способна сохранить наш объект и сохранить именно так как мы его попросили. Для этого мы получаем в наш тестовый класс реализацию NoteStorage через аннотацию Autowired, а сам класс аннотируем ContextConfiguration и обозначаем, что нам тут нужен класс с реализацией. Запускаем тесты командой gradlew integrationTest в корне нашего проекта и видим, что сборка валится. Самые внимательные давно догадались, что так этим всё и закончится потому, что у нас нет никакой БД, интеграцию с которой мы собрались тестировать. И тут на сцену выходит... В третьей части про реализацию хранилища уже было обозначено, что в нашем проекте в качестве БД будет использоваться PostgreSQL. Давайте подключим её для наших интеграционных тестов. Сначала добавляем необходимые зависимости в build.gradle модуля :adapters:storage: А так же создадим в ресурсах этого модуля файл application.properties со следующим содержимым: Теперь нам нужен класс, который будет предоставлять нам экземпляр контейнера БД для наших тестов: Это класс с приватным конструктором и публичным методом для получения экземпляра контейнера. Такая конструкция выдаст нам один контейнер на весь прогон. В методе start устанавливаются значения системным переменным, которые обозначены в только что созданном файле application.properties. Благодаря этому тестируемый сервис будет знать о том, как подключиться к БД в запущенном контейнере. Объявленная в классе статическая константа IMAGE_VERSION в моём примере несёт исключительно демонстрационный характер. Формат строки, которую мы передаём в конструктор базового класса имеет вид "image:tag". Здесь мы используем обычное имя образа с дефолтным тэгом, хотя сам я обычно использую банальный "postgres:14". На случай, если читатель впервые слышит про Testcontainers и ещё не успел загуглить, то стоит отменить, что эта штука работает через Docker, и если по какой-то причине Вы его ещё не установили, то сейчас самое время. Это как раз тот момент, о котором я писал выше, утверждая, что Docker нам ещё понадобится. Следуем гайдам по Testconteqners и добавляем ещё одну абстракцию: И расширим этой абстракцией предыдущую - IntegrationTest. Не забудем проверить, что Docker у нас запущен и снова выполняем gradlew integrationTest. И снова видим, что сборка провалилась - тест не прошёл. Всё верно, мы запустили БД, однако там нет ничего, включая таблицу, в которой должны храниться наши заметки. Возвращаемся в main нашего адаптера storage и создаём там в ресурсах changelog для liquibase. Я не буду заострять на этом внимание, просто покажу changeSet для создания таблицы note, вдруг читатель не знает, что changeSet можно писать как XML, а не SQL: Что бы liquibase автоматически применял наши миграции для БД на старте приложения, добавим в зависимости адаптера storage следующее:   А в application.properties группы integrationTest следующее: Здесь changelog.xml - это, разумеется, наш файл со списком миграций БД. Запускаем gradlew integrationTest и видим, что теперь наше хранилище данных работает - тест проходит. Однако наш тест проверяет запись, которую сам и создал. А как будет работать адаптер с данными, которые уже есть в БД? Давайте проверим: Запустим этот тест и увидим, что он ожидаемо проваливается. Разумеется, ведь у нас нет в БД записи с идентификатором 100. Данные для интеграционных тестов будет добавлять тем же liquibase. Для этого уже в ресурсах группы integrationTest модуля :adapters:storage добавляем свой changeset, который будет добавлять в базу данные, используемые тестами (назовём этот файл 0000.add_row_to_note.xml в папке test-data в ресурсах integrationTest модуля :adapters:storage): А вот теперь я не могу воздержаться от демонстрации своей структуры файлов для liquibase. В ресурсы группу integrationTest, кроме файла, содержимое которого представлено выше, я добавил ещё один changeset.storage.intergation-test.xml со следующим содержимым: Тут мы сначала просим liquibase применить миграции к нашей БД, а затем применить changeset с тестовыми данными. Ещё нам нужно поменять значение параметра spring.liquibase.change-log в application.properties в ресурсах набора IntegrationTest адаптера storage: Запускаем gradlew integrationTest - тесты проходят. Часть с liquibase получилась немного запутана. Если Вам не удалось понять что где лежит, то можно заглянуть в репозиторий и разобраться. А я постараюсь ответить на вопрос, почему я не использовал context для выделения тестовых данных от общей массы миграций. Дело в том, что при использовании контекста, все файлы changeset должны лежать в одной куче. Если это будет так, то при сборке приложения для вывода в условный production, в неё попадут все файлы для liquibase, включая файлы с тестовыми данными. Разумеется, тестовые данные в продуктивной среде игнорируются благодаря настройкам фильтра контекста, однако само существование их в сборке допускает вероятность нежелательного сценария. Вспоминаем закон Мёрфи - "Если что-нибудь может пойти не так, оно пойдёт не так". Подход, выбранный нами для нашего проекта исключает такой сценарий - если в сборке нет тестовых данных, то они не смогут попасть в прод. На этом, пожалуй, оставим адаптер хранилища. Кажется, он работает как надо. В первой части мы договорились предоставить API для ребят из отдела UI, что бы они могли с ним поработать между митингами, пока пьют кофе. Добавляем в проект новый адаптер - модуль :adapters:rest-api, а содержимое его build.gradle делаем таким: Здесь нам нужна зависимость от ядра нашего приложения, что бы пользоваться бизнес логикой, а так же spring-boot-starter-web что бы реализовать задуманное. Зависимость springdoc-openapi-ui добавим в качестве бонуса, что бы не писать документацию по API самостоятельно, а ребятам из отдела UI отдать ссылку на автосгенерированную (по которой они, кстати, смогут сгенерировать клиентский код, пока наливают кофе). Добавляем контроллер: Обращаем внимание на пакет, с котором контроллер находится. Для порядка. Нам осталось заставить всё это работать вместе. У нас есть пара адаптеров, которые ничего друг про друга не знают. И не должны. Теперь нам нужно "очертить" внешний контур нашего приложения спроектированного по принципам гексагональной архитектуры. Для этого мы создадим модуль, который будет иметь в зависимостях все наши адаптеры и при этом запускаться будет как обычное web приложение Spring boot. Назовём тип таких модулей словом "конфигурация", а сам модуль webapp. Забегая вперёд скажу, что модулей конфигурации у нас может быть несколько с разной геометрией внешнего контура, то есть с разным набором адаптеров. Это могут быть, например, консольные приложения, потребители сообщений из очереди, приложения, предоставляющие api по QraphQL или всё это вместе одновременно - всё зависит от функций нашего бизнес-ядра, количества адаптеров к нему и потребности в декомпозиции отдельных экземпляров нашего приложения. Поэтому объединим их в одном модуле - configurations, аналогично адаптерам. Build.gradle нашей конфигурации будет выглядеть вот так: Наглядно видно, что он зависит от наших адаптеров и не зависит от бизнеса. А так же, видно, что мы будем использовать spring-boot-starter-web для запуска. Создаём класс приложения: Обратите внимание, что класс приложения лежит в корневом пакете simple.note. Это позволяет Spring'у сканировать все компоненты находящиеся ниже по иерархии, а они все именно там и находятся. Класс выглядит вполне канонически, не хватает только канонического application.properties. Исправляемся: Обращаем внимание на то, что в application.properties нашей конфигурации нужно поместить все необходимые параметры, включая change-log для liquibase, что бы применить новые изменения для БД, которая, кстати, нашему приложению нужна для работы. В этот раз мы про неё не забудем: Эта команда запустит контейнер с экземпляром PostreSQL, в котором будут нужная нам БД и логин/пароль для подключения. Теперь можно запускать приложение. И убедиться, что оно не запустится. Дело в том, что наш rest контроллер из восьмой части использует порт NoteService из самой первой части. А класс с его реализацией не аннотирован как Component, поэтому в контекст Spring он не попадает. Очевидным решением было бы добавить нужный атрибут и забыть про это недоразумение. Однако для этого нам понадобится добавить в модуль business-core зависимость от spring-context, чего в ядре нашего приложения мы всеми силами стараемся избежать. Поэтому пойдём другим путём. Мы создадим ещё один модуль - common. И добавим зависимость ему. А внутри common создадим свою аннотацию: Так как она по сути является алиасом для Component, мы можем вешать их на классы и получить тот же эффект. Добавляем модулю business-core зависимость от common, а классу NoteServiceImplementation аннотацию UseCase. Теперь приложение запустится без проблема и мы даже сможем увидеть пустой список заметок по адресу http://localhost:8080/api/note (в json формате, разумеется). Прежде чем отдать наш backend ребятам из отдела UI, давайте проверим, что он работает. То есть давайте напишем e2e тесты. Это новая группа тестов, а мы уже знаем как эти группы создаются. По аналогии с integrationTest в adapters создаём набор e2eTest в configurations: Давайте для начала проверим, что ребята из отдела UI не поперхнутся кофе из-за того, что страница с документацией недоступна: Запускаем тест, наблюдаем как он валится. Мы опять забыли про БД. Testcontainers мы уже подключали в адаптере storage. Давайте попробуем его переиспользовать. Получается нам для этого нужно набору e2eTest модуля :configurations:webapp добавить зависимость от группы integrationTest модуля :adapters:storage. Привычными нам способами Gradle это сделать не позволит. Зато у него есть ещё один штатный плагин специально для этих целей. И называется он java-test-fixtures. После применения его на адаптере хранилища у него появляется возможность добавить ещё один source set - testFixtures. Делаем это и переносим туда SimpleNotePostgreSQLContainer, StorageIntegrationTest и все зависимости testcontainers: Обратите внимание на последнюю строку - именно так указывается зависимость на testFixtures. Тоже самое нужно сделать для набора e2e в :configurations:webapp: Теперь можно расширить SimpleNoteWebApplicationTest тем же StorageIntegrationTest и запустить e2e тесы на выполнение. В этот раз они проходят. Делаем gradlew check, что бы запустить все наши тесты и убедиться, что в этот раз ничего не сломалось. Итак. Получили в распоряжение задачу на старт нового проекта. Взяли чистый лист и начертили на нём структуру. Поставили на места тесты, позаботились об автоматическом обновлении БД и о тестовых данных в ней. Для реализации полноценного проекта ещё предстоит много над чем поработать. Но имея на руках готовую к работе структуру развивать проект на порядок проще. Нам всем знаком процесс внесения изменений в проект. Но вот самое первое изменение - расчертить чистый лист - это всегда вызов. Раз уж Вы дочитали мою первую статью до конца, то, надеюсь, она Вам понравилась, а я заслужил право минусовать комментарии :)