Каждый из нас знакомился с новомодными библиотеками, фреймворками, инструментами по 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-тесты, интеграционные тесты и прочие прелести. Опустим процесс выбора и его причины, сосредоточимся только на реализации.

Часть первая. Высасываем пример из пальца

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

  1. Система должна сохранять заметки и возвращать их список по запросу.

Условимся, что UI - не наша задача. Наша задача - предоставить api для него.

Часть вторая. Строим структуру проекта

Getting Started по Gradle предписывает нам воспользоваться IDE для создания проекта или установить Gradle локально, что бы воспользоваться им в терминале. Не позволю себе наглость советовать Вам, что из этого выбрать, а лишь продемонстрирую экзотический способ

docker run -it --rm -v %cd%:/usr/workdir -w /usr/workdir gradle:latest gradle init

Так уж вышло, что мне больше нравится делать это в терминале. Но для разового запуска устанавливать локальный Gradle не хочется. Поэтому я "придумал" запускать его в Docker. Тем более, что он нам обязательно понадобится дальше. Бонусом идёт то, что Gradle в проекте после инициализации будет самой свежей версии.

Выбираем тип проекта basic, Groovy DSL, и имя проекта - в моём случае это simple-note. Дальше, разумеется, Gradle нужно запускать через wrapper - gradlew.bat, который появился после инициализации в корне проекта - в текущей папке.

Теперь у нас в руках есть чистый лист, который нам нужно расчертить - описать структуру проекта. И тут имеются в виду не продиктованные соглашениями папочки вроде main, resources и прочее. Дело в том, что мы выбрали подход на основе принципов гексагональной архитектуры. А это значит, что у нас будет много модулей и зависимостей между ними. Про Hexagonal Architecture можно погуглить, но если в двух словах, то нам нужно ядро - модуль с минимально возможным количеством зависимостей, имплементирующим бизнес-логику, и несколько модулей-адаптеров, зависящих от ядра и имплементирующих декларированные в нём потребности и возможности его взаимодействия с окружением.

У нас в корне проекта есть файл build.gradle. Он содержит только комментарий со ссылкой на документацию, если вы делали как я. Это корневой файл сборки и всем модули будут его подмодулями, поэтому давайте сразу обозначим в нём общие настройки для всех них:

plugins {
    id "io.spring.dependency-management" version "1.0.11.RELEASE"
}

subprojects {
    group = "simple.note"

    apply plugin: "java"
    apply plugin: "io.spring.dependency-management"

    dependencyManagement {
        imports {
            mavenBom("org.springframework.boot:spring-boot-dependencies:2.7.1")
        }
    }

    compileJava {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

Применение плагина io.spring.dependency-management обеспечит нам одинаковую версию spring boot во всех зависящих от него модулях. Ну и Java 17.

Добавим модуль business-core, то самое ядро с бизнес-логикой. как Вы помните у него минимум зависимостей, поэтому его build.gradle будет пустым.

Условимся, что пакет simple.note будет корнем для всего кода приложения и создадим в модуле business-core пакет simple.note.core. Затем создадим в нём описание доменной сущности - заметки.

package simple.note.core.domain;

import java.time.LocalDateTime;

public record Note(
    Long id,
    String text,
    Integer size,
    LocalDateTime created
) { }

Я использую record потому, что мне нравится эта конструкция. Только ради неё стоит "соскочить" с Java 8. Обратите внимание, так же, что я добавил сущность в пакет domain, для порядка.

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

package simple.note.core.port.in;

import simple.note.core.domain.Note;

import java.util.List;

public interface NoteService {
    Note create(String text);
    List<Note> getAll();
}

Опять же, в гексагональной архитектуре есть порты, поэтому пакет port. В данном случае порт входящий, поэтому in. Для порядка.

Для реализации сервиса нам понадобится взаимодействие с хранилищем данных. Мы пока не знаем какое оно будет, но нам пока и не надо. Объявим ещё один порт - исходящий. В пакете port.out.

package simple.note.core.port.out;

import simple.note.core.domain.Note;

import java.util.List;

public interface NoteStorage {
    long create(Note note);
    Note get(long id);
    List<Note> getAll();
}

Теперь мы можем реализовать наш бизнес-сервис в пакете service

package simple.note.core.service;

import simple.note.core.domain.Note;
import simple.note.core.port.in.NoteService;
import simple.note.core.port.out.NoteStorage;

import java.time.LocalDateTime;
import java.util.List;

public class NoteServiceImplementation implements NoteService {

    private final NoteStorage noteStorage;

    public NoteServiceImplementation(NoteStorage noteStorage) {
        this.noteStorage = noteStorage;
    }

    @Override
    public Note create(String text) {
        var noteId = noteStorage.create(
                new Note(
                        null,
                        text,
                        text.length(),
                        LocalDateTime.now()
                )
        );

        return noteStorage.get(noteId);
    }

    @Override
    public List<Note> getAll() {
        return noteStorage.getAll();
    }
}

Наибольший интерес тут представляет метод create. На вход он принимает текст, однако при сохранении в хранилище заполняется ещё размер заметки. После сохранения получаем идентификатор объекта в хранилище и уже используя этот идентификатор, получаем его от туда целиком. Чем не кейс для unit теста?

Часть вторая. Unit тесты

Да, строить структуру проекта мы ещё не закончили, но про это будет вся статья. А сейчас время для unit тестов. Мало, кто из дочитавших до сюда, получил какое-то откровение, я думаю. Пока что всё на уровне "Hello, world!". Должен предупредить, что в этой части для вас тоже вряд ли будет что-то новое.

Однако, продолжим. Unit тесты распространяются на все модули, поэтому опять идём в корневой gradle.build и добавляем:

...
subprojects {
    ...
    repositories {
        mavenCentral()
    }
    ...
    dependencies {
        testImplementation "org.junit.jupiter:junit-jupiter:5.8.2"
        testImplementation "org.mockito:mockito-core:4.6.1"
    }

    test {
        useJUnitPlatform()
    }
}

То есть, для всех модулей мы добавляем одинаковые зависимости на JUnit и Mockito. Уверен, Вы знаете, что это такое. Если нет - можно погуглить.

А так выглядит тест на нашу реализацию бизнес-сервиса:

package simple.note.core.service;

import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import simple.note.core.domain.Note;
import simple.note.core.port.out.NoteStorage;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class NoteServiceImplementationTest {

    @Test
    void saveNote_calculateTextSize_getByStorageId() {
        var text = "17 character text";
        var noteId = 12345L;

        var noteStorage = mock(NoteStorage.class);
        var noteCaptor = ArgumentCaptor.forClass(Note.class);
        when(noteStorage.create(noteCaptor.capture())).thenReturn(noteId);
        var noteIdCaptor = ArgumentCaptor.forClass(Long.class);
        when(noteStorage.get(noteIdCaptor.capture())).thenReturn(null);

        var noteService = new NoteServiceImplementation(noteStorage);

        noteService.create(text);

        assertNotNull(noteCaptor.getValue());
        assertEquals(text, noteCaptor.getValue().text());
        assertEquals(17, noteCaptor.getValue().size());

        assertEquals(noteId, noteIdCaptor.getValue());
    }
}

Запускаем в корне проекта gradlew test, наблюдаем зелёный BUILD SUCCESSFUL и идём дальше.

Часть третья. Реализация хранилища

Теперь нужно обеспечить бизнес-сервису реализацию исходящего порта для хранения наших заметок. Для этого добавим модуль adapters. Он будет пустой. Мы будем использовать его в качестве агрегата для модулей адаптеров, первым из которых станет storage. Добавляем.

Так как базовая конфигурация сборки у нас уже готова, в build.gradle модуля :adapters:storage указываем только кое-какие зависимости:

dependencies {
    implementation project(":business-core")
    implementation "org.springframework.boot:spring-boot-starter-data-jpa"

    runtimeOnly "org.postgresql:postgresql"
}

Вот тут у нас наглядный пример того, что адаптеры зависят от ядра, а не наоборот. А так же появляется первый компонент Spring boot. Мы будем использовать JPA для взаимодействия с БД PostgreSQL. Поэтому создаём entity-класс:

package simple.note.adapters.storage.entity;

import lombok.*;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "note")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NoteEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "text")
    private String text;

    @Column(name = "size")
    private Integer size;

    @Column(name = "created")
    private LocalDateTime created;
}

Так как JPA не умеет работать с записями (record), то я добавил lombok, что бы не писать руками шаблонный код, который я не люблю больше, чем lombok. Добавим и репозиторий заодно:

package simple.note.adapters.storage.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import simple.note.adapters.storage.entity.NoteEntity;

public interface NoteEntityRepository extends JpaRepository<NoteEntity, Long> {
}

Обращаем внимание на пакеты, в которых расположен этот код. И идём реализовывать порт:

package simple.note.adapters.storage;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import simple.note.adapters.storage.entity.NoteEntity;
import simple.note.adapters.storage.repository.NoteEntityRepository;
import simple.note.core.domain.Note;
import simple.note.core.port.out.NoteStorage;

import java.util.List;

@Component
public class NoteStorageImplementation implements NoteStorage {

    private final NoteEntityRepository repository;

    public NoteStorageImplementation(NoteEntityRepository repository) {
        this.repository = repository;
    }

    @Override
    @Transactional
    public long create(Note note) {
        var entity = repository.save(NoteEntity.builder()
                .text(note.text())
                .size(note.size())
                .created(note.created())
                .build());

        return entity.getId();
    }

    @Override
    public Note get(long id) {
        return map(repository.getReferenceById(id));
    }

    @Override
    public List<Note> getAll() {
        return repository.findAll()
                .stream()
                .map(this::map)
                .toList();
    }

    private Note map(NoteEntity entity) {
        return new Note(
                entity.getId(),
                entity.getText(),
                entity.getSize(),
                entity.getCreated()
        );
    }
}

Не забудем аннотировать класс как Component, ведь мы уже имеем дело со Spring Boot.

Часть четвёртая. Интеграционные тесты

Нашу реализацию порта хранилища тестировать unit тестами особого смысла не имеет, поэтому давайте разбираться с интеграционными. Обычно в гайдах по интеграционным тестам их пишут в стандартном наборе test (который рядом с main). Однако в test у нас уже unit тесты, и такой подход приведёт к путанице, в которой различать unit и интеграционные тесты будет не просто. Мне приходилось видеть решение в виде соглашения об именовании, согласно которому классы с unit тестами должны заканчиваться на *Test, а классы с интеграционными на *Tests. В конце концов, почему бы и нет? Ведь вся экосистема Java строится на фундаменте соглашения об именовании. Но в таком случае в набор test свалится куча зависимостей, которые в руках человека сотрут грань между типами тестов. А мы собрались тут не для этого, поэтому давайте так не делать. И да, мы условились не спорить о том, почему это нужно, а разобраться как это сделать. Давайте сделаем по gradle'вски.

Для этого нам понадобится штатный JVM Test Suite Plugin. По первым двум абзацам его описания в документации можно сделать вывод, что создан он как раз под нашу задачу - группировку тестов. Когда я наткнулся на этот плагин, то был удивлён, что ранее мне не приходилось видеть его применения в реальных проектах. Я видел разные велосипеды, но только не то решение, которое Gradle предлагает из коробки. Хотя, конечно, у меня в этом не так много опыта.

Взаимодействие нашего приложения с окружающим миром идёт через адаптеры, поэтому давайте вернёмся к build.gradle модуля adapters, который до сих пор у нас оставался пустым и добавим в него следующее:

subprojects {
    testing {
        suites {
            integrationTest(JvmTestSuite) {
                dependencies {
                    implementation project
                    implementation "org.springframework.boot:spring-boot-starter-test"ЭЭЭ
                }

                targets {
                    all {
                        testTask.configure {
                            shouldRunAfter(test)
                        }
                    }
                }

                useJUnitJupiter()
            }
        }
    }

    configurations {
        integrationTestImplementation.extendsFrom implementation
        integrationTestRuntimeOnly.extendsFrom runtimeOnly
    }

    tasks.named("check") {
        dependsOn (testing.suites.integrationTest)
    }
}

Этим самым во все подмодули 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 обвешиваются группой аннотаций, и что бы не обвешивать каждый класс, давайте сделаем для этого абстрактный, который тестовые классы будут расширять:

package simple.note.adapters.storage;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@EnableAutoConfiguration
@EntityScan(basePackages = { "simple.note.adapters.storage.entity" })
public abstract class IntegrationTest { }

Здесь есть всё, что нужно для запуска интеграционного теста, в отдельности про каждый атрибут расписывать, пожалуй, не буду - об этом кричат все гайды. Да и погуглить можно. А нам пришло время сделать тест:

package simple.note.adapters.storage;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import simple.note.core.domain.Note;
import simple.note.core.port.out.NoteStorage;

import java.time.LocalDateTime;

import static org.junit.jupiter.api.Assertions.*;

@ContextConfiguration(classes = {
        NoteStorageImplementation.class
})
public class NoteStorageImplementationTest extends IntegrationTest {
    @Autowired
    private NoteStorage noteStorage;

    @Test
    void saveNote() {
        var text = "note text";
        var size = 9;
        var created = LocalDateTime.now();

        var noteId = noteStorage.create(new Note(null, text, size, created));
        var note = noteStorage.get(noteId);

        assertNotNull(note);
        assertAll("note",
                () -> assertEquals(noteId, note.id()),
                () -> assertEquals(text, note.text()),
                () -> assertEquals(size, note.size()),
                () -> assertEquals(created, note.created())
        );
    }
}

Здесь мы проверяем, что реализация порта хранилища способна сохранить наш объект и сохранить именно так как мы его попросили. Для этого мы получаем в наш тестовый класс реализацию NoteStorage через аннотацию Autowired, а сам класс аннотируем ContextConfiguration и обозначаем, что нам тут нужен класс с реализацией.

Запускаем тесты командой gradlew integrationTest в корне нашего проекта и видим, что сборка валится. Самые внимательные давно догадались, что так этим всё и закончится потому, что у нас нет никакой БД, интеграцию с которой мы собрались тестировать. И тут на сцену выходит...

Часть пятая. Testcontainers

В третьей части про реализацию хранилища уже было обозначено, что в нашем проекте в качестве БД будет использоваться PostgreSQL. Давайте подключим её для наших интеграционных тестов. Сначала добавляем необходимые зависимости в build.gradle модуля :adapters:storage:

    integrationTestImplementation "org.testcontainers:postgresql:1.17.2"
    integrationTestImplementation "org.testcontainers:junit-jupiter:1.17.2"
    integrationTestImplementation "org.testcontainers:postgresql:1.17.2"

А так же создадим в ресурсах этого модуля файл application.properties со следующим содержимым:

spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}

Теперь нам нужен класс, который будет предоставлять нам экземпляр контейнера БД для наших тестов:

package simple.note.adapters.storage;

import org.testcontainers.containers.PostgreSQLContainer;

public class SimpleNotePostgreSQLContainer extends PostgreSQLContainer<SimpleNotePostgreSQLContainer> {
    private static final String IMAGE_VERSION = String.format(
            "%s:%s",
            PostgreSQLContainer.IMAGE,
            PostgreSQLContainer.DEFAULT_TAG
    );
    private static SimpleNotePostgreSQLContainer container;

    private SimpleNotePostgreSQLContainer() {
        super(IMAGE_VERSION);
    }

    public static SimpleNotePostgreSQLContainer getInstance() {
        if (container == null) {
            container = new SimpleNotePostgreSQLContainer();
        }

        return container;
    }

    @Override
    public void start() {
        super.start();
        System.setProperty("DB_URL", container.getJdbcUrl());
        System.setProperty("DB_USERNAME", container.getUsername());
        System.setProperty("DB_PASSWORD", container.getPassword());
    }
}

Это класс с приватным конструктором и публичным методом для получения экземпляра контейнера. Такая конструкция выдаст нам один контейнер на весь прогон. В методе start устанавливаются значения системным переменным, которые обозначены в только что созданном файле application.properties. Благодаря этому тестируемый сервис будет знать о том, как подключиться к БД в запущенном контейнере. Объявленная в классе статическая константа IMAGE_VERSION в моём примере несёт исключительно демонстрационный характер. Формат строки, которую мы передаём в конструктор базового класса имеет вид "image:tag". Здесь мы используем обычное имя образа с дефолтным тэгом, хотя сам я обычно использую банальный "postgres:14".

На случай, если читатель впервые слышит про Testcontainers и ещё не успел загуглить, то стоит отменить, что эта штука работает через Docker, и если по какой-то причине Вы его ещё не установили, то сейчас самое время. Это как раз тот момент, о котором я писал выше, утверждая, что Docker нам ещё понадобится.

Следуем гайдам по Testconteqners и добавляем ещё одну абстракцию:

package simple.note.adapters.storage;

import org.junit.ClassRule;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
public abstract class StorageIntegrationTest {
    @ClassRule
    @Container
    public static SimpleNotePostgreSQLContainer dbContainer = SimpleNotePostgreSQLContainer.getInstance();
}

И расширим этой абстракцией предыдущую - IntegrationTest. Не забудем проверить, что Docker у нас запущен и снова выполняем gradlew integrationTest. И снова видим, что сборка провалилась - тест не прошёл.

Часть шестая. Liquibase

Всё верно, мы запустили БД, однако там нет ничего, включая таблицу, в которой должны храниться наши заметки. Возвращаемся в main нашего адаптера storage и создаём там в ресурсах changelog для liquibase. Я не буду заострять на этом внимание, просто покажу changeSet для создания таблицы note, вдруг читатель не знает, что changeSet можно писать как XML, а не SQL:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.4.xsd">
    <changeSet id="create_table_note" author="elusive avenger">
        <createTable tableName="note">
            <column name="id" type="bigint" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="text" type="clob" />
            <column name="size" type="bigint" />
            <column name="created" type="datetime" />
        </createTable>
    </changeSet>
</databaseChangeLog>

Что бы liquibase автоматически применял наши миграции для БД на старте приложения, добавим в зависимости адаптера storage следующее:

implementation "org.liquibase:liquibase-core"

А в application.properties группы integrationTest следующее:

spring.liquibase.change-log=classpath:changelog.xml

Здесь changelog.xml - это, разумеется, наш файл со списком миграций БД. Запускаем gradlew integrationTest и видим, что теперь наше хранилище данных работает - тест проходит.

Однако наш тест проверяет запись, которую сам и создал. А как будет работать адаптер с данными, которые уже есть в БД? Давайте проверим:

    @Test
    void readExistsNote() {
        var noteId = 100L;

        var note = noteStorage.get(noteId);

        assertNotNull(note);
        assertAll("note",
                () -> assertEquals(noteId, note.id()));
    }

Запустим этот тест и увидим, что он ожидаемо проваливается. Разумеется, ведь у нас нет в БД записи с идентификатором 100.

Часть седьмая. Тестовые данные

Данные для интеграционных тестов будет добавлять тем же liquibase. Для этого уже в ресурсах группы integrationTest модуля :adapters:storage добавляем свой changeset, который будет добавлять в базу данные, используемые тестами (назовём этот файл 0000.add_row_to_note.xml в папке test-data в ресурсах integrationTest модуля :adapters:storage):

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.4.xsd">
    <changeSet id="add_row_to_note" author="elusive avenger">
        <insert tableName="note">
            <column name="id" value="100" />
            <column name="text" value="text" />
        </insert>
    </changeSet>
</databaseChangeLog>

А вот теперь я не могу воздержаться от демонстрации своей структуры файлов для liquibase. В ресурсы группу integrationTest, кроме файла, содержимое которого представлено выше, я добавил ещё один changeset.storage.intergation-test.xml со следующим содержимым:

<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.4.xsd">

    <include file="changelog.xml" relativeToChangelogFile="true"/>

    <include file="test-data/0000.add_row_to_note.xml" relativeToChangelogFile="true"/>

</databaseChangeLog>

Тут мы сначала просим liquibase применить миграции к нашей БД, а затем применить changeset с тестовыми данными. Ещё нам нужно поменять значение параметра spring.liquibase.change-log в application.properties в ресурсах набора IntegrationTest адаптера storage:

spring.liquibase.change-log=changeset.storage.integration-test.xml

Запускаем gradlew integrationTest - тесты проходят.

Часть с liquibase получилась немного запутана. Если Вам не удалось понять что где лежит, то можно заглянуть в репозиторий и разобраться. А я постараюсь ответить на вопрос, почему я не использовал context для выделения тестовых данных от общей массы миграций. Дело в том, что при использовании контекста, все файлы changeset должны лежать в одной куче. Если это будет так, то при сборке приложения для вывода в условный production, в неё попадут все файлы для liquibase, включая файлы с тестовыми данными. Разумеется, тестовые данные в продуктивной среде игнорируются благодаря настройкам фильтра контекста, однако само существование их в сборке допускает вероятность нежелательного сценария. Вспоминаем закон Мёрфи - "Если что-нибудь может пойти не так, оно пойдёт не так". Подход, выбранный нами для нашего проекта исключает такой сценарий - если в сборке нет тестовых данных, то они не смогут попасть в прод.

На этом, пожалуй, оставим адаптер хранилища. Кажется, он работает как надо.

Часть восьмая. REST API

В первой части мы договорились предоставить API для ребят из отдела UI, что бы они могли с ним поработать между митингами, пока пьют кофе.

Добавляем в проект новый адаптер - модуль :adapters:rest-api, а содержимое его build.gradle делаем таким:

dependencies {
    implementation project(":business-core")
    implementation "org.springframework.boot:spring-boot-starter-web"    
    implementation "org.springdoc:springdoc-openapi-ui:1.6.9"
}

Здесь нам нужна зависимость от ядра нашего приложения, что бы пользоваться бизнес логикой, а так же spring-boot-starter-web что бы реализовать задуманное. Зависимость springdoc-openapi-ui добавим в качестве бонуса, что бы не писать документацию по API самостоятельно, а ребятам из отдела UI отдать ссылку на автосгенерированную (по которой они, кстати, смогут сгенерировать клиентский код, пока наливают кофе).

Добавляем контроллер:

package simple.note.adapters.rest.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import simple.note.adapters.rest.model.CreateNoteView;
import simple.note.adapters.rest.model.NoteView;
import simple.note.core.domain.Note;
import simple.note.core.port.in.NoteService;

import java.util.List;

@RestController
@RequestMapping("/api/note")
@Tag(name = "Заметки")
public class NoteController {
    private final NoteService noteService;

    public NoteController(NoteService noteService) {
        this.noteService = noteService;
    }

    @PostMapping
    @Operation(summary = "Создать заметку")
    public ResponseEntity<NoteView> create(@RequestBody CreateNoteView request) {
        return ResponseEntity.ok(map(noteService.create(request.text())));
    }

    @GetMapping
    @Operation(summary = "Список заметок")
    public ResponseEntity<List<NoteView>> getAll() {
        return ResponseEntity.ok(noteService.getAll().stream().map(this::map).toList());
    }

    private NoteView map(Note note) {
        return new NoteView(note.id(), note.text(), note.size(), note.created());
    }
}

Обращаем внимание на пакет, с котором контроллер находится. Для порядка.

Часть девятая. Запускаем приложение

Нам осталось заставить всё это работать вместе. У нас есть пара адаптеров, которые ничего друг про друга не знают. И не должны. Теперь нам нужно "очертить" внешний контур нашего приложения спроектированного по принципам гексагональной архитектуры. Для этого мы создадим модуль, который будет иметь в зависимостях все наши адаптеры и при этом запускаться будет как обычное web приложение Spring boot. Назовём тип таких модулей словом "конфигурация", а сам модуль webapp. Забегая вперёд скажу, что модулей конфигурации у нас может быть несколько с разной геометрией внешнего контура, то есть с разным набором адаптеров. Это могут быть, например, консольные приложения, потребители сообщений из очереди, приложения, предоставляющие api по QraphQL или всё это вместе одновременно - всё зависит от функций нашего бизнес-ядра, количества адаптеров к нему и потребности в декомпозиции отдельных экземпляров нашего приложения. Поэтому объединим их в одном модуле - configurations, аналогично адаптерам. Build.gradle нашей конфигурации будет выглядеть вот так:

dependencies {
    implementation project(":adapters:storage")
    implementation project(":adapters:rest-api")

    implementation "org.springframework.boot:spring-boot-starter-web"
}

Наглядно видно, что он зависит от наших адаптеров и не зависит от бизнеса. А так же, видно, что мы будем использовать spring-boot-starter-web для запуска. Создаём класс приложения:

package simple.note;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SimpleNoteWebApplication {
    public static void main(String[] args) {
        SpringApplication.run(SimpleNoteWebApplication.class, args);
    }
}

Обратите внимание, что класс приложения лежит в корневом пакете simple.note. Это позволяет Spring'у сканировать все компоненты находящиеся ниже по иерархии, а они все именно там и находятся. Класс выглядит вполне канонически, не хватает только канонического application.properties. Исправляемся:

spring.datasource.url=jdbc:postgresql://localhost:5432/simple_note
spring.datasource.username=simple_note_user
spring.datasource.password=simple_note_password
spring.datasource.driver-class-name=org.postgresql.Driver
spring.liquibase.change-log=changelog.xml

Обращаем внимание на то, что в application.properties нашей конфигурации нужно поместить все необходимые параметры, включая change-log для liquibase, что бы применить новые изменения для БД, которая, кстати, нашему приложению нужна для работы. В этот раз мы про неё не забудем:

docker run -it --rm -e POSTGRES_DB=simple_note -e POSTGRES_USER=simple_note_user -e POSTGRES_PASSWORD=simple_note_password postgres

Эта команда запустит контейнер с экземпляром PostreSQL, в котором будут нужная нам БД и логин/пароль для подключения. Теперь можно запускать приложение. И убедиться, что оно не запустится.

Дело в том, что наш rest контроллер из восьмой части использует порт NoteService из самой первой части. А класс с его реализацией не аннотирован как Component, поэтому в контекст Spring он не попадает. Очевидным решением было бы добавить нужный атрибут и забыть про это недоразумение. Однако для этого нам понадобится добавить в модуль business-core зависимость от spring-context, чего в ядре нашего приложения мы всеми силами стараемся избежать. Поэтому пойдём другим путём. Мы создадим ещё один модуль - common. И добавим зависимость ему. А внутри common создадим свою аннотацию:

package simple.note.common;

import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Component;

import java.lang.annotation.*;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface UseCase {
    @AliasFor(annotation = Component.class)
    String value() default "";
}

Так как она по сути является алиасом для Component, мы можем вешать их на классы и получить тот же эффект. Добавляем модулю business-core зависимость от common, а классу NoteServiceImplementation аннотацию UseCase. Теперь приложение запустится без проблема и мы даже сможем увидеть пустой список заметок по адресу http://localhost:8080/api/note (в json формате, разумеется).

Часть десятая. Test Fixtures

Прежде чем отдать наш backend ребятам из отдела UI, давайте проверим, что он работает. То есть давайте напишем e2e тесты. Это новая группа тестов, а мы уже знаем как эти группы создаются. По аналогии с integrationTest в adapters создаём набор e2eTest в configurations:

subprojects {
    testing {
        suites {
            e2eTest(JvmTestSuite) {
                dependencies {
                    implementation project
                    implementation "org.springframework.boot:spring-boot-starter-test"
                }

                targets {
                    all {
                        testTask.configure {
                            shouldRunAfter(test)
                        }
                    }
                }

                useJUnitJupiter()
            }
        }
    }

    configurations {
        e2eTestImplementation.extendsFrom implementation
        e2eTestRuntimeOnly.extendsFrom runtimeOnly
    }

    tasks.named("check") {
        dependsOn (testing.suites.e2eTest)
    }
}

Давайте для начала проверим, что ребята из отдела UI не поперхнутся кофе из-за того, что страница с документацией недоступна:

package simple.note;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

public class SwaggerDocsAvailableTest extends SimpleNoteWebApplicationTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    void successResponse() throws Exception {
        mockMvc.perform(get("/swagger-ui/index.html"))
                .andExpect(status().isOk());
    }
}

Запускаем тест, наблюдаем как он валится. Мы опять забыли про БД. Testcontainers мы уже подключали в адаптере storage. Давайте попробуем его переиспользовать. Получается нам для этого нужно набору e2eTest модуля :configurations:webapp добавить зависимость от группы integrationTest модуля :adapters:storage. Привычными нам способами Gradle это сделать не позволит. Зато у него есть ещё один штатный плагин специально для этих целей. И называется он java-test-fixtures. После применения его на адаптере хранилища у него появляется возможность добавить ещё один source set - testFixtures. Делаем это и переносим туда SimpleNotePostgreSQLContainer, StorageIntegrationTest и все зависимости testcontainers:

plugins {
    id "java-test-fixtures"
}

dependencies {
    ...
    testFixturesImplementation "org.testcontainers:postgresql:1.17.2"
    testFixturesImplementation "org.testcontainers:junit-jupiter:1.17.2"
    testFixturesImplementation "org.testcontainers:postgresql:1.17.2"

    integrationTestImplementation(testFixtures(project))
}

Обратите внимание на последнюю строку - именно так указывается зависимость на testFixtures. Тоже самое нужно сделать для набора e2e в :configurations:webapp:

e2eTestImplementation(testFixtures(project(":adapters:storage")))

Теперь можно расширить SimpleNoteWebApplicationTest тем же StorageIntegrationTest и запустить e2e тесы на выполнение. В этот раз они проходят. Делаем gradlew check, что бы запустить все наши тесты и убедиться, что в этот раз ничего не сломалось.

Эпилог

Итак. Получили в распоряжение задачу на старт нового проекта. Взяли чистый лист и начертили на нём структуру. Поставили на места тесты, позаботились об автоматическом обновлении БД и о тестовых данных в ней. Для реализации полноценного проекта ещё предстоит много над чем поработать. Но имея на руках готовую к работе структуру развивать проект на порядок проще. Нам всем знаком процесс внесения изменений в проект. Но вот самое первое изменение - расчертить чистый лист - это всегда вызов.

Раз уж Вы дочитали мою первую статью до конца, то, надеюсь, она Вам понравилась, а я заслужил право минусовать комментарии :)

Комментарии (12)


  1. dididididi
    20.07.2022 07:37
    +6

    В начале, какая-то история про заказчика, потом helloworld идёт, которая делается до завтрака.

    Единственная фишечка - какая-то гексагональная штука, про которую сказано гуглить. Которая даёт какие то геморрои, с которыми героически справляются потом.

    Всегда интересовало, зачем вы тестите save(entity), get(энтити)Проверяете работает ли спринг жпа?

    Идитe дальше, протести жаву! User =new User() ; assertFalse(user==null)


    1. ggo
      20.07.2022 09:37

      Всегда интересовало, зачем вы тестите save(entity), get(энтити)

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


    1. toolateitsme
      20.07.2022 10:17
      +1

      как же я плюсую дико, я такой настроился, запасся минералочкой, думал, вдруг сейчас пойдет какая нибудь интересная статейка, где описывается кастомная логика деплоя артефактов, какие то хитрые собственные plugin extension, хитрые зависимости тасок и их workflow, а на деле оказался почти gradle-demo с оф сайта :(


      1. grossws
        20.07.2022 23:11

        Ещё и использующий нерекомендуемые в современном gradle конструкции в изобилии, e2eTest добавляется везде кроме финальной сборки (которая ещё в :)


  1. bugy
    20.07.2022 09:28
    +2

    Честео говоря, я в этой воде из хеллоу ворлда на spring и gradle, не нашел, где же та самая нетривиальная конфигурация.

    На кого эта статья? Гайд для тех кто хочет поднять свой первый сервис на спринге? Ну так и пишете это в заголовке. Хотя лучше вообще не пишете, таких гайдов сотни

    Или эта статья для тех кто хочет узнать новые тонкости гредла? Тогда минимум 95% явно не про тонкости и ее нужно почистить от воды


  1. loltrol
    20.07.2022 12:00

    Так и не увидел нетривиальную конфигурацию на gradle. Но в любом случай, на gradle это еще +- можно оформить по человечески. Давайте лучше нетривиальную конфигурацию на maven. О Боже, как я люблю программировать на xml...


    1. mrsantak
      20.07.2022 12:43
      +3

      Если вам приходится программировать в билд конфигурациях, то у вас проблема вне зависимости от того, используете вы xml, gradle, kotlin или что-либо еще. Если у вас для билда нужна сложная логика, то её нужно вытаскивать в плагины к билд системы, а не пихать её в билд конфигурацию.

      В этом плане использование xml в мавен даже полезно - ибо сложный xml конфиг достаточно быстро вызывает отторжение и желание с этим что-то сделать. А в каком-нибудь gradle люди начинают строчить билд логику на груви и осознают что что-то пошло не так сильно позже.


      1. bugy
        20.07.2022 13:01

        Можете пожалуйста развернуть мысль? Билд логика не должна быть в билд туле?

        Или вы про то, что не нужно делать всё в одном файле? Даже если ваша кастомная билд логика на 2 строки?

        Вот мой опыт с мейвеном прямо обратен. Вместо того, чтобы делать плагины и совмещать конфиг с логикой, люди пишут кастомные скрипты поверх (например на баше/питоне) или вообще в pipeline script закидывают эту логику. Потому что мейвеновые плагины тормозные и неудобные.


        1. mrsantak
          20.07.2022 14:03
          +6

          Можете пожалуйста развернуть мысль? Билд логика не должна быть в билд туле?

          Билд логика должна быть в билд туле, а не в билд конфигурации. Например, если вам нужно скомпилировать ваш java проект, то в хорошей билд системе у вас будет декларативный конфиг для соответствующего плагина, а не скрипт вызывающий javac.

          Или вы про то, что не нужно делать всё в одном файле? Даже если ваша кастомная билд логика на 2 строки?

          Я про то, что билд конфиг должен быть декларативным, а не императивным. Императивные элементы должны быть вынесены в отдельные сущности, а не смешиваться с декларативными.

          Вот мой опыт с мейвеном прямо обратен. Вместо того, чтобы делать плагины и совмещать конфиг с логикой, люди пишут кастомные скрипты поверх (например на баше/питоне) или вообще в pipeline script закидывают эту логику. Потому что мейвеновые плагины тормозные и неудобные.

          Это потому что люди не хотят разбираться в инструментах которые используют. Ничто не запрещает прям в репу с проектом положить java сорцы плагина к мавену и тут же его использовать в pom.xml соседних модулей. Но люди почему-то предпочитают вхерачить какой-нибудь ant скрипт внутрь pom.xml, а потом плачутся, что злой мавен заставляет их на xml программировать. И это еще по-божески, я вот встречал ситуацию когда люди не осилили multi module мавен проекты и в итоге навелосипедили python скрипт, который в нужном порядке дергал mvn install -f для мавен модулей в репе.

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

          У мавена много серьезных недостатков, но "программирование на xml" это недостаток не мавена, а людей которые не умеют им пользоваться.


      1. loltrol
        20.07.2022 18:15
        +2

        Маленькие таски отлично живут в билд-конфигурации, сложные - в buildSrc. Выносить функцию в три строчки(например с кастомной логикой генерации версии) в отдельный плагин или кодовую базу - вы еще тот извращенец.


        1. mrsantak
          20.07.2022 18:35

          Зато мне не приходится программировать на xml.


  1. aleksandy
    21.07.2022 07:03

    Очевидным решением было бы добавить нужный атрибут и забыть про это недоразумение.

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

    Однако для этого нам понадобится добавить в модуль business-core зависимость от spring-context, чего в ядре нашего приложения мы всеми силами стараемся избежать. Поэтому пойдём другим путём. Мы создадим ещё один модуль - common. И добавим зависимость ему.

    Добавляем модулю business-core зависимость от common.

    И в итоге ядро транзитивно зависит от spring-context. (facepalm)