Cucumber JVM — не только BDD

    Всем привет! Про Cucumber есть много статей на Хабре и в интернете, но хочется вставить свои пять копеек.



    Многие используют Cucumber, и я сам нахожу его довольно замечательной библиотекой. Мое личное мнение – каждому Java разработчику полезно быть знакомым с этой библиотекой наравне с JUnit фреймворком.


    Если ваши знания о Cucumber равны нулю, на Хабре есть отличные статьи, с которых лучше начать знакомство с этим инструментом



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


    Многие руководства по Cucumber начинают с рассказов о BDD и его связки с Cucumber. Поэтому есть стойкая ассоциация, что Cucumber можно использовать только, если ваша команда использует BDD. Это не так. Cucumber можно вполне успешно применять и для написания обычных интеграционных тестов.


    Практика: интеграционный тест для matching engine


    Я работаю в Технологическом Центре Дойче Банка на проекте, который разрабатывает софт для инвестиционного подразделения, и в качестве примера для статьи решил найти какую-нибудь open source библиотеку для управления торговыми заявками (ака matching engine), с уже готовыми интеграционными тестами, которые мы перепишем на Cucumber.


    An order matching system – центральный компонент в электронных торговых площадках, которая управляет состоянием клиентских заявок на покупку/продажу чего-либо.


    В результате поисков на Github мне выдался вот такой проект в топе. У него есть интеграционные тесты, которые написаны на JUnit.


    Начинаем разбирать первый тест basicFullCycleTest.


    Два клиента ставят ордера на биржу на покупку и продажу, часть ордеров встречные (цена покупки/продажи пересекается).


    Размер метода где-то 100 строчек, которые нужно прочитать, чтобы понять, что конкретно тестирует данный сценарий.


    Теперь перепишем это на Cucumber.


    Для начало добавляем Cucumber зависимости в Maven. Я использовал самую свежую на сегодня версию 5.4.2.


    <dependency>
       <groupId>io.cucumber</groupId>
       <artifactId>cucumber-java</artifactId>
       <version>${cucumber.version}</version>
       <scope>test</scope>
    </dependency>
    
    <dependency>
       <groupId>io.cucumber</groupId>
       <artifactId>cucumber-junit</artifactId>
       <version>${cucumber.version}</version>
       <scope>test</scope>
    </dependency>
    
    <dependency>
       <groupId>io.cucumber</groupId>
       <artifactId>cucumber-picocontainer</artifactId>
       <version>${cucumber.version}</version>
       <scope>test</scope>
    </dependency>

    Смотрим на JUnit интеграционный тест:


    было


    try (final ExchangeTestContainer container = new ExchangeTestContainer()) {
        container.initBasicSymbols();
        container.initBasicUsers();

    На каждый тест-кейс в JUnit создается некий промежуточный объект между тестовым сценарием и matching engine. Для Cucumber мы создаем класс, где будем описывать наши тестовые шаги и создадим этот тестовый объект для использования его в наших шагах.


    стало


    import io.cucumber.java.After;
    import io.cucumber.java.Before;
    
    @Slf4j
    public class OrderStepdefs {
    
       private final ExchangeTestContainer container;
    
       private List<MatcherTradeEvent> matcherEvents;
       private Map<Long, ApiPlaceOrder> orders = new HashMap<>();
    
       public OrderStepdefs(ExchangeTestContainer container) {
           this.container = container;
       }
    
       @Before
       public void before() throws Exception{
           log.info("before");
           container.initBasicSymbols();
           container.initBasicUsers();
       }
    
       @After
       public void after(){
           if(container != null){
               container.close();
           }
       }

    Как это работает


    Cucumber перед каждым сценарием (подчеркиваю, сценарием, а не для всего feature файла, где может быть описано несколько сценариев) создает новый экземпляр класса, реализующий тестовые шаги. Если вы используете dependency injection, это также создает экземпляры объектов, которые вы хотите использовать, и вставит их во все реализации. Для каждого сценария используется новый экземпляр определения шагов и всех его зависимостей.


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


    Исследуем дальше тест на JUnit:


    // ### 1. first user places limit orders
    final ApiPlaceOrder order101 = ApiPlaceOrder.builder().uid(UID_1).id(101).price(1600)
        .size(7).action(ASK).orderType(GTC).symbol(symbolSpec.symbolId).build();
    
    log.debug("PLACE 101: {}", order101);
    container.submitCommandSync(order101, cmd -> {
       assertThat(cmd.resultCode, is(CommandResultCode.SUCCESS));
       assertThat(cmd.orderId, is(101L));
       assertThat(cmd.uid, is(UID_1));
       assertThat(cmd.price, is(1600L));
       assertThat(cmd.size, is(7L));
       assertThat(cmd.action, is(ASK));
       assertThat(cmd.orderType, is(GTC));
       assertThat(cmd.symbol, is(symbolSpec.symbolId));
       assertNull(cmd.matcherEvent);
    });

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


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


    final int reserve102 = symbolSpec.type == SymbolType.CURRENCY_EXCHANGE_PAIR ? 1561 : 0;
    
    final ApiPlaceOrder order102 = ApiPlaceOrder.builder().uid(UID_1).id(102).price(1550)
       .reservePrice(reserve102).size(4).action(OrderAction.BID).orderType(GTC)
        .symbol(symbolSpec.symbolId).build();
    
    log.debug("PLACE 102: {}", order102);
    
    container.submitCommandSync(order102, cmd -> {
       assertThat(cmd.resultCode, is(CommandResultCode.SUCCESS));
       assertNull(cmd.matcherEvent);
    });

    Следующим шагом в тесте проверяется, как в контейнере выглядит текущий стакан ордеров для данного инструмента на конкретный момент.


    final L2MarketDataHelper l2helper = new L2MarketDataHelper()
        .addAsk(1600, 7).addBid(1550, 4);
    
    assertEquals(l2helper.build(), container.requestCurrentOrderBook(symbolSpec.symbolId));

    Теперь пример того, как я это переписал в Cucumber


    Scenario Outline: basic full cycle test
    
    When A client 1440001 places an ASK order 101 at 1600@7 (type: GTC, symbol: <symbol>)
    And A client 1440001 places an BID order 102 at 1550@4 (type: GTC, symbol: <symbol>, reservePrice: 1561)
    Then An "<symbol>" order book is:
      |  bid | price  | ask  |
      |      | 1600  |  7    |
      |  4   | 1550  |       |
    And No trade events

    Так как у нас один и тот же тестовый сценарий выполняется дважды, но с разными инструментами в JUnit реализации, в Cucumber для этого я использую Scenario Outline.


    Благодаря этому, я могу внутри сценария использовать переменную <symbol>, и мой сценарий будет выполнен дважды, для двух разных инструментов.


    Examples:
    | symbol        |
    | EUR_USD   |
    | ETH_XBT      |

    Теперь посмотрим на реализацию этих шагов в Glue-слое


    @When(value = "A client {long} places an {word} order {long} at {long}@{long} \\(type: {word}, symbol: {symbol})")
    public void aClientPlacesAnOrderAtTypeGTCSymbolEUR_USD(long clientId, String side, long orderId, long price, long size, String orderType, CoreSymbolSpecification symbol) throws InterruptedException {
       aClientPassAnOrder(clientId, side, orderId, price, size, orderType, symbol, 0);
    }
    
    @When(value = "A client {long} places an {word} order {long} at {long}@{long} \\(type: {word}, symbol: {symbol}, reservePrice: {long})")
    public void aClientPlacesAnOrderAtTypeGTCSymbolEUR_USD(long clientId, String side, long orderId, long price, long size, String orderType, CoreSymbolSpecification symbol, long reservePrice) throws InterruptedException {
       aClientPassAnOrder(clientId, side, orderId, price, size, orderType, symbol, reservePrice);
    }
    
    private void aClientPassAnOrder(long clientId, String side, long orderId, long price, long size, String orderType, CoreSymbolSpecification symbol, long reservePrice) throws InterruptedException {
    
       ApiPlaceOrder.ApiPlaceOrderBuilder builder = ApiPlaceOrder.builder().uid(clientId)
          .id(orderId)   .price(price).size(size).action(OrderAction.valueOf(side))
          .orderType(OrderType.valueOf(orderType)).symbol(symbol.symbolId);
    
       if(reservePrice > 0){
           builder.reservePrice(reservePrice);
       }
    
       final ApiPlaceOrder order = builder.build();
    
       orders.put(orderId, order);
    
       log.debug("PLACE : {}", order);
    
       container.submitCommandSync(order, cmd -> {
           assertThat(cmd.resultCode, is(CommandResultCode.SUCCESS));
           assertThat(cmd.orderId, is(orderId));
           assertThat(cmd.uid, is(clientId));
           assertThat(cmd.price, is(price));
           assertThat(cmd.size, is(size));
           assertThat(cmd.action, is(OrderAction.valueOf(side)));
           assertThat(cmd.orderType, is(OrderType.valueOf(orderType)));
           assertThat(cmd.symbol, is(symbol.symbolId));
    
           OrderStepdefs.this.matcherEvents = cmd.extractEvents();
       });
    }

    Отличия теперь в том, что мы не проверяем здесь matcher events, как в JUnit, а выделяем эту проверку в отдельный шаг.


    @And("No trade events")
    public void noTradeEvents() {
       assertEquals(0, matcherEvents.size());
    }

    Реализацию проверки стакана ордеров я сделал в новом классе, чтобы показать, как реализации шагов можно (и нужно, когда их у вас становиться много) разносить по нескольким классам и как здорово в Cucumber работает dependency injection.


    В данном шаге мы будем работать с таблицами.


    В последней версии Cucumber можно сразу распарсить таблицу и сконвертировать в какой-то ваш объект. Это позволяет избежать некрасивых обращений по индексу к элементам списка.


    Однако здесь я не буду приводить пример (надеюсь, меня простят за небольшую лень :-), да и текст статьи выходит довольно длинным) — об этой возможности хорошо рассказано тут.


    Обратите внимание на container в классе. Это будет ссылка на тот же самый экземпляр, что и в OrderStepdefs. И все это делает Cucumber библиотека без каких-либо усилий с вашей стороны. Cucumber создаст экземпляр ExchangeTestContainer, после этого создаст экземпляры OrderStepdefs и OrderBookStepdefs и передаст им ссылку на созданный перед этим экземпляр ExchangeTestContainer.


    public class OrderBookStepdefs {
    
       private final ExchangeTestContainer container;
    
       public OrderBookStepdefs(ExchangeTestContainer container) {
           this.container = container;
       }
    
       @Then("An {symbol} order book is:")
       public void an_order_book_is(CoreSymbolSpecification symbol, List<List<String>> dataTable) {
    
           //skip a header if it presents
           if(dataTable.get(0).get(0) != null && dataTable.get(0).get(0).trim().equals("bid")){
               dataTable = dataTable.subList(1, dataTable.size());
           }
    
           //format | bid | price | ask |
           final L2MarketDataHelper l2helper = new L2MarketDataHelper();
           for(List<String> row : dataTable){
               int price = Integer.parseInt(row.get(1));
    
               String bid = row.get(0);
               if(bid != null && bid.length() > 0){
                   l2helper.addBid(price, Integer.parseInt(bid));
               } else {
                   l2helper.addAsk(price, Integer.parseInt(row.get(2)));
               }
           }
           assertEquals(l2helper.build(), container.requestCurrentOrderBook(symbol.symbolId));
       }

    Конечный результат тестового сценария на Cucumber выглядит так:


    Feature: An exchange accepts bid\ask orders, manage and publish order book and match cross orders
    
     Scenario Outline: basic full cycle test
    
       When A client 1440001 places an ASK order 101 at 1600@7 (type: GTC, symbol: <symbol>)
       And A client 1440001 places an BID order 102 at 1550@4 (type: GTC, symbol: <symbol>, reservePrice: 1561)
       Then An "<symbol>" order book is:
         | bid | price  | ask |
         |     | 1600   |  7  |
         |  4  | 1550   |     |
       And No trade events
    
       When A client 1440002 places an BID order 201 at 1700@2 (type: IOC, symbol: <symbol>, reservePrice: 1800)
       Then The order 101 is partially matched. LastPx: 1600, LastQty: 2
       And An "<symbol>" order book is:
         |     |  1600   |  5  |
         |  4  |  1550   |     |
    
       When A client 1440002 places an BID order 202 at 1583@4 (type: GTC, symbol: <symbol>, reservePrice: 1583)
       Then An "<symbol>" order book is:
         |     |  1600   |  5  |
         |  4  |  1583   |     |
         |  4  |  1550   |     |
       And No trade events
    
       When A client 1440001 moves a price to 1580 of the order 101
       Then The order 202 is fully matched. LastPx: 1583, LastQty: 4
       And An "<symbol>" order book is:
         |     |  1580  |  1  |
         |  4  |  1550  |     |
    
       Examples:
       | symbol     |
       | EUR_USD    |
       | ETH_XBT    |

    Я опустил какие-то моменты реализации — например, определение пользовательского типа {symbol}. Полную реализацию можно посмотреть у меня на github или если автор exchange-core библиотеки примет мой Pull Request, то в основном репозитории этой библиотеки.


    Выводы


    На мой субъективный взгляд, тесты на Сucumber выглядят намного более читабельными, чем при реализации в JUnit. Другая возможность заключается в том, что это облегчает возможность вовлечения QA команды в работу над написанием новых сценариев.


    Cucumber дает дополнительный уровень абстракции к вашим тестам на JUnit. Этот слой абстракции выделяет суть вашего сценария и отделяет его от реализации.


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


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


    В данном примере у этой библиотеки кроме прямого API, есть REST API. При желании не должно составить большого труда написать другую реализацию glue-слоя для REST API. При этом сами Cucumber сценарии остаются теми же и должны будут также проходить.


    Еще одно большое преимущество Cucumber для меня — это встроенная поддержка в современных IDE, таких как JetBrains Idea. Это сильно сказывается на комфорте и скорости разработки новых сценариев.


    Вот небольшой список того, что умеет Idea из первого, что приходит на ум:


    • Ctrl + B — позволяет перейти из текста сценария в реализацию шага в Java и наоборот
    • Возможности по рефакторингу шагов, когда вы хотите поправить regexp. Idea сама поможет внести правки во все сценарии, которые используют данный шаг
    • Подсветка синтаксиса — она сразу подсказывает, что не может найти реализацию данного шага. Тут вы можете поправить опечатку или сгенерировать новый метод, который реализует этот шаг.
    • Запуск сценариев

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


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


    Выбор всегда за вами.


    Спасибо, что дочитали до конца.


    Список ресурсов

    Технологический Центр Дойче Банка
    Компания

    Комментарии 3

      +1
      Использовать Cucumber в таком виде как используете вы является одним из самых распространенных антипаттернов, но, как и любой другой антипаттерн в определенных условиях он имеет право на жизнь.
      Структура, которую задаёт Cucumber, может неплохо помочь в организации кода, однако тоже самое можно сделать и без него.

      В Cucumber 5 TypeRegistry является deprecated, на много проще определить свои преобразователи с помощью аннотаций @ParameterType и @DataTableType.
      Спасибо за ссылки на мои статьи, несколько месяц подумываю написать статью про нововведения Cucumber 5, но как-то не доходят руки.
        0
        Спасибо за ссылки на мои статьи, несколько месяц подумываю написать статью про нововведения Cucumber 5, но как-то не доходят руки.

        Я бы почитал, а то как раз на легаси проекте с BDD, и хоть его уже и перевели на 5-ю версию, но было бы неплохо иметь по рукой статью с описанием новых возможностей, чтобы понимать, что мы можем улучшить.
          0
          Данная статья как раз мной и написано, т.к. по мне Cucumber прекрасная библиотека, но очень много статей в основном о ней рассказывают через призму BDD и анти-патерны которые вы имеет виду, они относятся к BDD на мой взгляд.

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

          На счет, TypeRegistry. Конечный PR который я смержил, он не много отличается от кода из статьи. Чтобы не раздувать статья, решил не выносить весь код. Кому будет интересно, могут найти полный пример на github.

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

        Самое читаемое