Практическое руководство по внедрению Spring Boot

Гексагональная архитектура стала популярным архитектурным паттерном, помогающим отделить бизнес-логику от инфраструктуры. Такое разделение позволяет откладывать принятие решений о технологиях или легко заменять их. Кроме того, это позволяет тестировать бизнес-логику в изоляции от внешних систем.

В этой статье мы рассмотрим, как реализовать гексагональную архитектуру в Spring Boot приложении. Мы разделим бизнес-логику и инфраструктуру на отдельные модули и посмотрим, как можно изолированно реализовать и протестировать эти модули.

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

Сценарии использования и бизнес-логика

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

Мы начнем с примера приложения, позволяющего клиенту заказать кофе. У нас есть несколько правил, связанных с оформлением и подготовкой заказа:

  • Клиент может заказать кофе и выбрать тип кофе, молоко, объем и формат — на месте или навынос.

  • Клиент может добавить к заказу дополнительные позиции перед оплатой.

  • Клиент может отменить заказ до его оплаты.

  • После оплаты заказа внесение изменений не допускается.

  • Клиент может оплатить заказ кредитной картой.

  • После оплаты заказа покупатель может получить чек.

  • После оплаты заказа бариста может приступить к его приготовлению.

  • По окончании работы бариста может отметить заказ как готовый.

  • Когда заказ готов, клиент может забрать заказ, и он помечается как завершенный.

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

Выделим двух основных участников: клиента, делающего заказ, и бариста, готовящего заказ. Зная, что порты в гексагональной архитектуре естественным образом подходят для описания сценариев использования приложения, мы вводим два первичных порта: OrderingCoffee и PreparingCoffee. С другой стороны приложения нам необходима пара вторичных портов для хранения заказов и платежей.

Сценарии использования кофейни
Сценарии использования кофейни

Порты OrderingCoffee и PreparingCoffee должны выполнять наши требования, связанные с оформлением заказа и приготовлением кофе.

public interface OrderingCoffee {
Order placeOrder(Order order);
Order updateOrder(UUID orderId, Order order);
void cancelOrder(UUID orderId);
Payment payOrder(UUID orderId, CreditCard creditCard);
Receipt readReceipt(UUID orderId);
Order takeOrder(UUID orderId);
}
public interface PreparingCoffee {
Order startPreparingOrder(UUID orderId);
Order finishPreparingOrder(UUID orderId);
}

Аналогично, вторичные порты можно назвать просто Orders и Payments, и их задача — хранить и получать заказы и платежи.

public interface Orders {
Order findOrderById(UUID orderId) throws OrderNotFound;
Order save(Order order);
void deleteById(UUID orderId);
}
public interface Payments {
Payment findPaymentByOrderId(UUID orderId);
Payment save(Payment payment);
}

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

public class Order {
private UUID id = UUID.randomUUID();
private final Location location;
private final List<LineItem> items;
private Status status = Status.PAYMENT_EXPECTED;
// ...

}
public record LineItem(Drink drink, Milk milk, Size size, int quantity) { }
public enum Status {
PAYMENT_EXPECTED,
PAID,
PREPARING,
READY,
TAKEN
}

Существуют также классы для платежей, кредитных карт и чеков. В данном примере мы не добавляли к ним никакого поведения, поэтому они представляют собой просто Java.

public record Payment(UUID orderId, CreditCard creditCard, LocalDate paid) { }
public record CreditCard(
String cardHolderName,
String cardNumber,
Month expiryMonth,
Year expiryYear) { }
public record Receipt(BigDecimal amount, LocalDate paid) { }

Далее необходимо реализовать сценарии использования внутри приложения. Мы создадим класс CoffeeShop, который реализует первичный порт OrderingCoffee. Этот класс также будет вызывать вторичные порты Ordersи Payments.

Сценарий использования функции заказа кофе
Сценарий использования функции заказа кофе

Реализация довольно проста. Приведем пример оплаты заказа, для которого необходимы оба вторичных порта.

public class CoffeeShop implements OrderingCoffee {  
    private final Orders orders;  
    private final Payments payments;

    // ...

    @Override  
    public Payment payOrder(UUID orderId, CreditCard creditCard) {  
        var order = orders.findOrderById(orderId);  
  
        orders.save(order.markPaid());  
  
        return payments.save(new Payment(orderId, creditCard, LocalDate.now()));  
    }

Роль класса CoffeeShop заключается в оркестровке операций над сущностями и хранилищами. Он реализует первичные порты как сущности и использует вторичные порты как хранилища.

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

public class Order {  
    // ...

    public Order markPaid() {
        if (status != Status.PAYMENT_EXPECTED) {
            throw new IllegalStateException("Order is already paid");
        }
        status = Status.PAID;
        return this;
    }
}

Теперь для второго сценария использования мы реализуем класс сервиса CoffeeMachine, который реализует первичный порт PreparingCoffee.

Сценарий использования «Приготовление кофе»
Сценарий использования «Приготовление кофе»

Можно бы реализовать эту функциональность в одном классе, но здесь мы решили разделить реализацию различных случаев в отдельных классах.

public class CoffeeMachine implements PreparingCoffee {  
    private final Orders orders;  
  
    @Override  
    public Order startPreparingOrder(UUID orderId) {  
        var order = orders.findOrderById(orderId);  
  
        return orders.save(order.markBeingPrepared());  
    }  

    // ...
}

Поскольку мы хотим отложить принятие решений о технологиях, мы можем создать стабы для имеющихся у нас вторичных портов. Наиболее простой способ сделать это для репозиториев — хранить сущности в map.

public class InMemoryOrders implements Orders {
    private final Map<UUID, Order> entities = new HashMap<>();

    @Override
    public Order findOrderById(UUID orderId) {
        var order = entities.get(orderId);
        if (order == null) {
            throw new OrderNotFound();
        }
        return order;
    }  

    @Override
    public Order save(Order order) {
        entities.put(order.getId(), order);
        return order;
    }

    @Override
    public void deleteById(UUID orderId) {
        entities.remove(orderId);
    }
}

Это позволяет реализовать бизнес-логику, не задумываясь о деталях персистентности на данном этапе. Фактически, при итеративной работе мы могли бы поставлять первую версию приложения с этими in-memory стабами.

Замечу, что нам не нужны интерфейсы для первичных портов! Наличие таких интерфейсов облегчает визуализацию роли первичных портов, но они не нужны. Интерфейсы для вторичных портов нужны нам только для того, чтобы инвертировать зависимости в них.

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

Приемочные тесты

Также можно использовать эти in-memory стабы для тестирования и обеспечить молниеносную скорость выполнения тестов. Начать можно с написания приемочных тестов для сценариев использования. Именно с этого мы и начнем, работая по методу BDD.

Приемочное тестирование приложения в гексагональной архитектуре
Приемочное тестирование приложения в гексагональной архитектуре

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

Приведем примеры.

class AcceptanceTests {  
    private Orders orders;  
    private Payments payments;  
    private OrderingCoffee customer;  
    private PreparingCoffee barista;  
  
    @BeforeEach  
    void setup() {  
        orders = new InMemoryOrders();  
        payments = new InMemoryPayments();  
        customer = new CoffeeShop(orders, payments);  
        barista = new CoffeeMachine(orders);  
    }  

    // ...
  
    @Test  
    void customerCanPayTheOrder() {  
        var existingOrder = orders.save(anOrder());  
        var creditCard = aCreditCard();  
  
        var payment = customer.payOrder(existingOrder.getId(), creditCard);  
  
        assertThat(payment.getOrderId()).isEqualTo(existingOrder.getId());  
        assertThat(payment.getCreditCard()).isEqualTo(creditCard);  
    }  

    @Test  
    void baristaCanStartPreparingTheOrderWhenItIsPaid() {  
        var existingOrder = orders.save(aPaidOrder());  
  
        var orderInPreparation = barista.startPreparingOrder(existingOrder.getId());  
  
        assertThat(orderInPreparation.getStatus()).isEqualTo(Status.PREPARING);  
    }  

    // ...
}

Зачастую приемочные тесты выполняются для всего приложения через конечные точки REST и вплоть до базы данных. Такие тесты сложны в написании и медленны. Это не обязательно должно быть так!

Приемочные тесты гарантируют, что мы создаем то, что нужно. Они должны отражать бизнес-требования и не должны касаться технических проблем. Цели конечного пользователя не имеют никакого отношения к выбору технологий. Вполне возможно реализовать их с помощью тестов, которые похожи на «общительные» юнит-тесты.

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

Юнит-тесты

Мы также можем решить, что существует некоторая логика, которая требует изолированного тестирования. Например, расчет стоимости заказа. Вместо того, чтобы тестировать все через сценарии использования, можно написать более узконаправленные тесты.

Вот пример того, как можно рассчитать стоимость заказа.

public class Order {
    // ...
    
    public BigDecimal getCost() {
        return items.stream()
                .map(LineItem::getCost)
                .reduce(BigDecimal::add)
                .orElse(BigDecimal.ZERO);
    }
}

Каждый элемент заказа знает, как рассчитать свою стоимость. Для простоты предположим, что каждый маленький напиток стоит 4,0, а большой — 5,0.

public record LineItem(Drink drink, int quantity, Milk milk, Size size) {  
    BigDecimal getCost() {  
        var price = BigDecimal.valueOf(4.0);  
        if (size == Size.LARGE) {  
            price = price.add(BigDecimal.ONE);  
        }  
        return price.multiply(BigDecimal.valueOf(quantity));  
    }  
}

Чтобы проверить эту логику, можно протестировать класс Order непосредственно с помощью юнит-тестов.

Юнит-тестирование бизнес-логики
Юнит-тестирование бизнес-логики

Приведем пример юнит-теста, который создает несколько заказов, а затем проверяет правильность расчета стоимости заказа.

public class OrderCostTest {

    private static Stream<Arguments> drinkCosts() {
        return Stream.of(
                arguments(1, Size.SMALL, BigDecimal.valueOf(4.0)),
                arguments(1, Size.LARGE, BigDecimal.valueOf(5.0)),
                arguments(2, Size.SMALL, BigDecimal.valueOf(8.0))
        );
    }

    @ParameterizedTest(name = "{0} drinks of size {1} cost {2}")
    @MethodSource("drinkCosts")  
    void orderCostIsBasedOnQuantityAndSize(
            int quantity, Size size, BigDecimal expectedCost) {

        var order = new Order(Location.TAKE_AWAY, List.of(
                new OrderItem(Drink.LATTE, quantity, Milk.WHOLE, size)
        ));

        assertThat(order.getCost()).isEqualTo(expectedCost);
    }

    @Test
    void orderCostIsSumOfLineItemCosts() {
        var order = new Order(Location.TAKE_AWAY, List.of(
                new OrderItem(Drink.LATTE, 1, Milk.SKIMMED, Size.LARGE),
                new OrderItem(Drink.ESPRESSO, 1, Milk.SOY, Size.SMALL)
        ));
        assertThat(order.getCost()).isEqualTo(BigDecimal.valueOf(9.0));
    }
}

Обратите внимание, что мы решили назвать тестовый класс OrderCostTest, а не просто OrderTest. Это подтверждает идею о том, что нужно тестировать поведение, а не классы или методы.

Использовать ли более узконаправленные юнит-тесты — это вопрос выбора в каждом конкретном случае. Мы можем избежать комбинаторного взрыва тестов на более высоком уровне абстракции, написав вместо этого несколько более узконаправленных тестов.

Первичные адаптеры

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

Рассмотрим пример, в котором реализована конечная точка REST, принимающая заказ. Реализация контроллера является максимально тонкой, сопоставляет входящий запрос с чем-то, понятным приложению, и вызывает основной порт приложения.

@Controller  
@RequiredArgsConstructor  
public class OrderController {  
    private final OrderingCoffee orderingCoffee;  
  
    @PostMapping("/order")  
    ResponseEntity<OrderResponse> createOrder(
            @RequestBody OrderRequest request,
            UriComponentsBuilder uriComponentsBuilder) {

        var order = orderingCoffee.placeOrder(request.toDomain());  
        var location = uriComponentsBuilder.path("/order/{id}")
                .buildAndExpand(order.getId())
                .toUri();  
        return ResponseEntity.created(location).body(OrderResponse.fromDomain(order));  
    }

    // ...
}

Здесь мы поместили код, выполняющий отображение между доменным объектом и объектом-ответом в создание самого ответа.

public record OrderResponse(
        Location location,
        List<OrderItemResponse> items,
        BigDecimal cost
) {
    public static OrderResponse fromDomain(Order order) {
        return new OrderResponse(
                order.getLocation(),
                order.getItems().stream().map(OrderItemResponse::fromDomain).toList(),
                order.getCost()
        );
    }
}

Обратите внимание, что мы решили использовать отдельные объекты OrderRequest и OrderResponse. Это связано с тем, что модели записи и чтения не обязательно должны быть одинаковыми и могут иметь уникальные свойства.

Отображение моделей в контроллере заказа
Отображение моделей в контроллере заказа

Строго говоря, подобная стратегия отображения не предотвращает утечку доменной логики в первичные адаптеры. Если бы мы хотели добиться полной изоляции, мы бы определили что-то вроде модели PlacingOrdersCommand для интерфейса PlacingOrders и запретили бы прямой доступ к Order из адаптеров. Такое решение потребует еще большего количества кода отображения и не является оправданным для всех приложений.

Выбор конфигурации приложения

Наличия готовых первичных адаптеров недостаточно. Если теперь попытаться запустить приложение, то оно не сможет работать из-за отсутствия доменных бинов. Таким образом, мы должны дать Spring понять, как подключить наши доменные классы CoffeeShop и CoffeeMachine, которые выполняют всю работу.

Типичный способ сделать это в приложении Spring Boot — аннотировать классы CoffeeShop и CoffeeMachine аннотацией @Service. Однако, если мы хотим, чтобы детали фреймворка не попадали в приложение, мы не можем этого сделать.

Как же нам поступить в этом случае? Можно создать несколько конфигурационных классов и добавить конфигурации бинов для необходимых классов сервисов вручную. Однако в большой кодовой базе это было бы довольно утомительно.

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

@Retention(RetentionPolicy.RUNTIME)  
@Target(ElementType.TYPE)  
@Inherited  
public @interface UseCase {  
}

Это просто интерфейс-маркер, который также уточняет, что класс реализует тот или иной сценарий использования.

@UseCase  
public class CoffeeShop implements OrderingCoffee {
    // ...
}

Далее добавим конфигурацию, которая сканирует классы, аннотированные этой аннотацией, и добавляет для них бины.

@Configuration
@ComponentScan(
        basePackages = "com.arhohuttunen.coffeeshop.application",
        includeFilters = @ComponentScan.Filter(
                type = FilterType.ANNOTATION, value = UseCase.class
        )
)
public class DomainConfig {
}

Теперь мы можем аннотировать наши доменные классы без аннотаций, специфичных для фреймворка. Spring Boot найдет все классы с пользовательской аннотацией и автоматически создаст для них бины.

Интеграционные тесты для первичных адаптеров

Чтобы протестировать контроллер, нужно сделать тестовый мок первичных портов и затем проверить, правильно ли контроллер взаимодействует с приложением. Хотя такой подход является общепринятым и, безусловно, будет работать, он имеет два существенных недостатка.

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

Для тестирования первичных адаптеров мы будем использовать другой подход, при котором мы внедрим приложение как есть и повторно используем созданные ранее in-memory стабы. Этот подход более устойчив к рефакторингу и позволяет использовать код отображения как часть потока.

Тестирование контроллера заказа с помощью интеграционных тестов
Тестирование контроллера заказа с помощью интеграционных тестов

В тестах нам необходима тестовая конфигурация, содержащая конфигурации бинов для in-memory стабов.

@TestConfiguration
@Import(DomainConfig.class)
public class DomainTestConfig {
    @Bean
    Orders orders() {
        return new InMemoryOrders();
    }
  
    @Bean
    Payments payments() {
        return new InMemoryPayments();
    }
}

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

Если бы это была первая итерация приложения, то нам бы потребовались конфигурации бинов для in-memory стабов внутри приложения, а не только для тестов.

Теперь наши тесты должны импортировать эту тестовую конфигурацию. Для взаимодействия с in-memory стабами можно использовать вторичные порты.

@WebMvcTest  
@Import(DomainTestConfig.class)
public class OrderControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Orders orders;

    // ...

    @Test  
    void updateOrder() throws Exception {
        var order = orders.save(anOrder());
  
        mockMvc.perform(post("/order/{id}", order.getId())
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .content(orderJson))
                .andExpect(status().isOk());
    }
}

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

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

Вторичные адаптеры

Когда точка входа в систему готова, пришло время реализовать вторичные адаптеры. Нам понадобится хранение заказов.

Отображение моделей в адаптере JPA
Отображение моделей в адаптере JPA

В примере мы используем JPA, поскольку с ним знакомо большинство людей. Здесь действует принцип инверсии зависимостей — когда приложение не зависит от адаптера напрямую, а зависит от вторичного порта, который затем реализуется адаптером.

@Component  
@RequiredArgsConstructor  
public class OrdersJpaAdapter implements Orders {  
    private final OrderJpaRepository orderJpaRepository;  
  
    @Override  
    public Order findOrderById(UUID orderId) {  
        return orderJpaRepository.findById(orderId)  
                .map(OrderEntity::toDomain)  
                .orElseThrow();  
    }  

    // ...
}

Адаптер OrdersJpaAdapter берет на себя трансляцию между доменом и сущностями JPA. Это означает, что мы можем создать интерфейс OrderJpaRepository, который вызывается непосредственно из адаптера.

public interface OrderJpaRepository extends JpaRepository<OrderEntity, UUID> { }

Сам OrderEntity содержит аннотации jakarta.persistence для ORM. Многие приложения загрязняют доменную модель такими аннотациями. В данном случае мы имеем чистое разделение этих проблем, но за счет необходимости выполнять отображение между моделями.

@Entity
public class OrderEntity {
    @Id
    private UUID id;

    @Enumerated
    @NotNull
    private Location location;
    
    @Enumerated  
    @NotNull
    private Status status;
    
    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    private List<OrderItemEntity> items;

    public Order toDomain() {
        return new Order(
                id,
                location,
                items.stream().map(LineItemEntity::toDomain).toList(),
                status
        );  
    }

    // ...
}

Здесь мы также поместили код отображения между доменом и сущностью JPA в сам класс OrderEntity. Можно было бы также выделить это в отдельный класс, но мы стараемся не усложнять ситуацию.

Работа с транзакциями

Теперь, когда мы добавили персистентность в нашу систему, возникает один хороший вопрос: куда поместить транзакции?

Логично было бы обернуть сценарий использования в транзакцию. Традиционный способ сделать это — аннотировать метод, реализующий сценарий использования, аннотацией @Transactional. Хотя это очень практично, если мы действительно хотим, чтобы фреймворки не попадали в ядро приложения, мы можем сделать лучше.

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

Во-первых, нам нужно что-то, что выполняет фрагмент кода внутри транзакции.

public class TransactionalUseCaseExecutor {
    @Transactional
    <T> T executeInTransaction(Supplier<T> execution) {
        return execution.get();
    }
}

Далее мы создадим аспект, который найдет точку соединения для метода и выполнит этот метод с помощью созданного нами ранее исполнителя. Это позволит эффективно выполнить метод внутри транзакции.

@Aspect
@RequiredArgsConstructor
public class TransactionalUseCaseAspect {

    private final TransactionalUseCaseExecutor transactionalUseCaseExecutor;

    @Pointcut("@within(useCase)")
    void inUseCase(UseCase useCase) {

    }
  
    @Around("inUseCase(useCase)")
    Object useCase(ProceedingJoinPoint proceedingJoinPoint, UseCase useCase) {
        return transactionalUseCaseExecutor.executeInTransaction(() -> proceed(proceedingJoinPoint));
    }

    @SneakyThrows
    Object proceed(ProceedingJointPoint proceedingJoinPoint) {
        return proceedingJointPoint.proceed();
    }
}

По сути, код находит все классы, аннотированные @UseCase, и применяет TransactionalUseCaseAspect к методам этого класса. Это придает еще одно полезное качество созданной нами аннотации @UseCase.

Наконец, нам нужна конфигурация для включения аспекта.

@Configuration
@EnableAspectJAutoProxy
public class UseCaseTransactionConfiguration {
    @Bean  
    TransactionalUseCaseAspect transactionalUseCaseAspect(
            TransactionalUseCaseExecutor transactionalUseCaseExecutor
    ) {
        return new TransactionalUseCaseAspect(transactionalUseCaseExecutor);
    }

    @Bean
    TransactionalUseCaseExecutor transactionalUseCaseExecutor() {
        return new TransactionalUseCaseExecutor();
    }
}

Теперь наши сценарии использования становятся транзакционными автоматически, когда мы аннотируем класс с помощью аннотации @UseCase. Это один из примеров того, как можно добавить поведение, не являющееся центральным для бизнес-логики, не загромождая код этой функциональностью.

Интеграционные тесты для вторичных адаптеров

Одним из способов тестирования вторичных адаптеров является повторное использование тест-кейсов приемочного тестирования и настройка тестов на использование того вторичного адаптера, который необходимо протестировать, вместо использования in-memory стаба. Это можно сделать, например, с помощью хитроумного использования тестовых интерфейсов JUnit 5 и методов по умолчанию.

Хотя такой подход вполне приемлем, в данном примере мы предпочли тестировать вторичные адаптеры отдельно. Тестирование вторичных адаптеров в изоляции не приносит тех проблем, которые мы испытываем при тестировании первичных адаптеров в изоляции.

Во-первых, с точки зрения приложения не должно иметь значения, какой адаптер находится за вторичным портом, и мы уже протестировали его с помощью in-memory стаба. Во-вторых, мы тестируем код отображения во вторичном адаптере по отношению к контейнеру базы данных, и нам не нужно проверять никаких взаимодействий.

Тестирование вторичных адаптеров с помощью интеграционных тестов
Тестирование вторичных адаптеров с помощью интеграционных тестов

Тесты обращаются к адаптерам только через вторичные порты, что поможет минимизировать связь тестов с реализацией. Мы также тестируем хранилища JPA не напрямую, а через адаптер. Это позволит отработать код отображения, который сопоставляет сущности домена с сущностями хранилища.

@DataJpaTest
@ComponentScan("com.arhohuttunen.coffeeshop.adapter.out.persistence")
public class OrdersJpaAdapterTest {
    @Autowired
    private Orders orders;

    @Autowired
    private OrderJpaRepository orderJpaRepository;

    @Test
    void creatingOrderReturnsPersistedOrder() {
        var order = new Order(Location.TAKE_AWAY, List.of(
                new OrderItem(Drink.LATTE, 1, Milk.WHOLE, Size.SMALL))
        );

        var persistedOrder = orders.save(order);

        assertThat(persistedOrder.getLocation()).isEqualTo(Location.TAKE_AWAY);
        assertThat(persistedOrder.getItems()).containsExactly(
                new OrderItem(Drink.LATTE, 1, Milk.WHOLE, Size.SMALL)
        );
    }
}

Поскольку аннотация @DataJpaTest конфигурирует бины только для некоторых компонентов, связанных с JPA, таких как наши репозитории, также следует просканировать классы адаптеров с помощью @ComponentScan.

В данном примере мы используем базу данных H2, но в готовом к продакшену приложении будем использовать что-то другое, например, PostgreSQL. В этом случае лучше тестировать адаптер персистентности на реальной базе данных, например, с помощью Testcontainers.

Сквозные тесты

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

Сквозное тестирование системы кофейни
Сквозное тестирование системы кофейни

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

@SpringBootTest
@AutoConfigureMockMvc
class CoffeeShopApplicationTests {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private CoffeeMachine coffeeMachine;

    @Test
    void processNewOrder() throws Exception {
        var orderId = placeOrder();
        payOrder(orderId);
        prepareOrder(orderId);
        readReceipt(orderId);
        takeOrder(orderId);
    }

    @Test
    void cancelOrderBeforePayment() throws Exception {
        var orderId = createOrder();
        cancelOrder(orderId);
    }

    private UUID placeOrder() throws Exception {  
        var location = mockMvc.perform(post("/order")  
                        .contentType(MediaType.APPLICATION_JSON_VALUE)  
                        .content("""  
                        {
                            "location": "IN_STORE",
                            "items": [{
                                "drink": "LATTE",
                                "quantity": 1,
                                "milk": "WHOLE",
                                "size": "LARGE"
                            }]
                        }
                        """))
                .andExpect(status().isCreated())
                .andReturn()
                .getResponse()
                .getHeader(HttpHeaders.LOCATION);

        return location == null ? null :
                UUID.fromString(location.substring(location.lastIndexOf("/") + 1));
    }

    // ...
}

Для каждого вызова конечной точки мы создали вспомогательные методы (helper method), соответствующие шагам нашего сценария использования. Тесты затрагивают каждую конечную точку как минимум один раз, но не делают чрезмерных проверок. На каждом шаге проверяется только успешность выполнения запроса.

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

Структурирование приложения

В нашем приложении в файле settings.gradle имеется всего два модуля Gradle.

include 'coffeeshop-application'
include 'coffeeshop-infrastructure'

Разница между этими модулями заключается в том, что модуль coffeeshop-application содержит всю бизнес-логику и сценарии использования приложения и вообще не зависит от Spring Boot. Фактически, единственными зависимостями для него являются JUnit 5 и AssertJ.

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
    testImplementation 'org.assertj:assertj-core:3.24.2'
}

Здесь мы можем внедрить приемочные тесты как «общительные» (sociable) юнит-тесты для бизнес-логики. Мы также можем добавить несколько одиноких (solitary) юнит-тестов для изолированного тестирования отдельных частей логики.

Второй модуль, называемый coffeeshop-infrastructure, реализует все адаптеры и добавляет все конфигурации, необходимые для работы приложения Spring Boot. Отличием этого модуля является то, что он содержит все зависимости Spring Boot, а также добавляет в качестве зависимости coffeeshop-application.

dependencies {
    implementation project(':coffeeshop-application')
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // ...
}

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

Подобная настройка модулей и зависимостей позволяет обеспечить соблюдение правил архитектуры на уровне модулей Gradle.

Внутри модули структурированы с помощью структуры пакетов, которая различает первичные и вторичные порты и адаптеры.

├── coffeeshop-application
|   └── application
|       ├── in
|       ├── order
|       ├── out
|       └── payment
└── coffeeshop-infrastructure
    ├── adapter
    |   ├── in
    |   |   └── rest
    |   └── out
    |       └── persistence
    └── config

Заметим, что гексагональная архитектура не навязывает внутреннее строение приложения. Показанный в примере подход является лишь одним из вариантов.

Это того стоит?

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

Спускаться в кроличью нору, желая использовать шаблон, — не самая лучшая идея. Должна быть веская причина для использования чего-либо.

Вот некоторые моменты, которые могут выступать против использования гексагональной архитектуры:

  • Аннотирование сущностей с помощью аннотаций персистентности внутри приложения, чтобы избежать отображения между моделями.

  • Использование Spring Data JPA в целом. Сила (и слабость) JPA в том, что нам не нужны отдельные модели.

  • Использование предоставляемых Spring реализаций таких фичей, как события приложения внутри бизнес-логики.

Если мы действительно хотим строгого отделения бизнес-логики от инфраструктуры, то нам не нужно использовать JPA для персистентности. На самом деле мы должны поставить под сомнение этот выбор и использовать что-то другое, потому что это не обязательно лучший выбор.

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

Резюме

В этой статье мы провели разделение бизнес-логики и инфраструктуры в собственных модулях Gradle в приложении Spring Boot. Мы реализовали некоторые паттерны, которые позволили нам сохранить код фреймворка за пределами приложения.

Мы также посмотрели, как тестировать приложение изолированно, без зависимостей от фреймворка. Эти тесты мы дополнили интеграционными тестами на уровне модулей инфраструктуры.

Код примера для этой статьи можно найти на GitHub.


Еще больше эффективных инструментов и подходов тестирования можно изучить на курсах OTUS под руководством экспертов в IT. А всех заинтересованных приглашаем на открытые уроки, которые пройдут в ближайшее время:

Настройка связки Gatling с Grafana, InfluxDB, 4 декабря

Shift left performance testing: инструменты Gatling и k6, 13 декабря

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


  1. BasicWolf
    29.11.2023 20:36

    Благодарю за перевод замечательной статьи!