Тестируем интеграцию с очередями сообщений правильно

от автора

Наверняка в вашем проекте используется очередь сообщений (не важно kafka, pulsar или какой-нибудь зайчик). Основной проблемой является подробное тестирование работы вашей системы. Рассмотрим варианты решения и посмотрим, что там у автора в рукаве.

Уличные лекции

Уличные лекции

Как решается сейчас

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

Серьезным сеньором, тестирующим очереди сообщений по полной, является так любимый либералами совок — баба Зина из магазина с ее коронной фразой:

Обоснование, почему нужен суперкомпьютер для тестирования интеграций

Обоснование, почему нужен суперкомпьютер для тестирования интеграций

База зина дурного не скажет, поэтому поднимет кластер, сконфигурует как-нибудь и будет утверждать, что интеграция рабочая.

Однако у такого подхода есть множество проблем, а именно:

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

  2. Для тестов такой подход не идиоматичен, если для одного сообщения еще можно смириться, то в случае сложного дерева (из-за ветвлений и создания побочных сообщений) утверждать, что вы протестировали все что нужно — нельзя, даже если почему-то покрытие тестами 100%;

  3. Конфигурация при тестировании отличается от того, что в проде, утверждать, что работать будет так же на 100% нельзя;

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

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

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

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

Решение

В проекте mireapay для работы с очередями сообщений специально разработана библиотека message-queue . Ее особенностью является тот факт, что она позволяет не только менять очереди сообщений (например Pulsar на Kafka) приложения без переписывания всего и вся, но так же эмулировать тестовую среду настолько, насколько это возможно, предоставляя дополнительные функции, которых у очередей сообщений быть не может. Не забываем, что вы все равно не поднимите свой кластер с нужной конфигурацией, партициями и прочим — иначе ваши тесты будут как золотой унитаз по стоимости их запуска.

Данная библиотека базируется на двух интерфейсах:

  1. EventConsumer — реализация данного интерфейса позволяет слушать топик и получать сообщения по одному. Пакетного консьюмера нет, автору он не требовался — читатель может реализовать его самостоятельно;

  2. EventProducer — для каждого топика будет создан бин с данным интерфейсом, подключаем его к вашей компоненте и отправляем сообщения в топик. Если отправка не удалась — ловите исключение.

Часто бывает необходимо разделить одно и то же событие на разные топики, для этого создана аннотация MessageQueueId, вешается на ваш консьюмер. По умолчанию у всех консьюмеров идентификатор — default, но вы можете задать свой собственный. Для продьюсеров нужно использовать полное имя бина, например defaultSimpleEventProducer, richSimpleEventProducer. Регистрация топиков (для создания генератором бинов) производится в конфигурации проекта. Рассмотрим на примере сервиса контрактов:

event:   provider: "pulsar"   consumer:     - event-class: "com.lastrix.mps.node.model.event.contract.external.ExternalContractEvent"   producer:     - event-class: "com.lastrix.mps.node.model.event.contract.external.ExternalContractEvent"       message-queue-id: "out"       topic-group: "out" 

Сервис слушает очередь сообщений для события ExternalContractEvent из топика по умолчанию, но пишет в топик с группой out, группа используется при генерации имени топика, в время как message-queue-id нужен для генерации бинов и разделения их. Т.к. события оказываются в разных топиках, то наш сервис не будет читать свои же сообщения и накручивать количество обработанных сообщений в секунду. Да, баба Зина, тебе не удастся всем говорить, что твой сервис обрабатывает 300к сообщений в секунду, потому что 299999 из них ты отпинываешь обратно, как обработанные.

Теперь, что бы тестировать такую интеграцию нам нужна конфигурация тестов:

event:   provider: "test"   consumer:     - event-class: "com.lastrix.mps.node.model.event.contract.external.ExternalContractEvent"   producer:     - event-class: "com.lastrix.mps.node.model.event.contract.external.ExternalContractEvent"       message-queue-id: "out" 

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

event:   provider: "test"   consumer:     - event-class: "com.lastrix.mps.node.model.event.contract.lifecycle.ContractLifecycleEvent"   producer:     - event-class: "com.lastrix.mps.node.model.event.contract.lifecycle.ContractLifecycleEvent"       # this will allow us to manually control event processing       message-queue-id: "test" 

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

Теперь мы можем приступить к написанию тестов. Для упрощения работы рекомендуется разделить код, декларации при помощи ООП:

Абстрактный класс позволит нам задекларировать топики, пример:

    // все очереди сообщений одним списком, TestMessageQueue - управляет     // как продюсером, так и консьюмером     @Autowired     List<TestMessageQueue<?>> messageQueues;      @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")     @Autowired     TestMessageQueue<ExternalContractEvent> defaultExternalContractEventMessageQueue;     // обратите внимание на префикс, он определяется идентификатором, а не группой     @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")     @Autowired     TestMessageQueue<ExternalContractEvent> outExternalContractEventMessageQueue; 

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

Рассмотрим один из методов:

    protected void assertEventStatus(Contract contract, ContractStatus status, NodeId expectedNodeId) {         var event = testContractLifecycleEventMessageQueue.poll();         assertNotNull(event);         assertTrue(event instanceof FactCreatedContractLifecycleEvent);         var factEvent = (FactCreatedContractLifecycleEvent) event;         assertEquals(contract.getId(), factEvent.getContractId());         var fact = defaultContractFactMapper.toModel(factEvent.getContractFact());         assertEquals(expectedNodeId, fact.getNodeId());         assertEquals(StatusContractFactDetails.TYPE, fact.getDetails().getType());         var details = (StatusContractFactDetails) fact.getDetails();         assertEquals(status, details.getStatus());          assertAck(defaultContractLifecycleEventMessageQueue, event);     } 

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

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

    @Test     void test() {         var nonLocalWalletId = Instancio.create(QWalletId.class);         var contract = createContract(localWalletId, contractExternalId, generatePaymentTemplates(nonLocalWalletId));           pushContractCreatedEvent(contract);         assertSlaveReceiveContractCreated(contract);          assertCurrentStatus(contract.getId(), ContractStatus.INIT);          responseSlaveInitialized(contract, nonLocalWalletId.nodeId(), List.of(                 statusFact(contract.getId(), nonLocalWalletId.nodeId(), ContractStatus.INIT),                 statusFact(contract.getId(), nonLocalWalletId.nodeId(), ContractStatus.ACTIVE)         ));          assertEventStatus(contract, ContractStatus.ACTIVE);         assertEventLocalStatus(contract, ContractStatus.ACTIVE);         assertExternalEventStatusSent(contract, ContractStatus.ACTIVE);          assertCreatedHistoryEvent(contract);  //        assertApprovalRequested(contract, localWalletId, getPaymentTemplates(contract));  //        approve(contract, localWalletId, ApprovalType.WITHDRAW);         assertAutoApproved(contract, localWalletId, ApprovalType.WITHDRAW);          var fromWithdrawTransactionEvent = expectCreateTransactionEvent(contract, localWalletId, ApprovalType.WITHDRAW);          respondCreateTransactionSuccess(fromWithdrawTransactionEvent);          assertPaymentHoldEvent(contract);         var details = assertExternalPaymentHoldEvent(contract);          respondExternalPaymentHold(contract, nonLocalWalletId, buildDepositDetails(nonLocalWalletId, details));         assertPaymentHoldEvent(contract);          respondExternalEvent(contract, statusFact(contract.getId(), nonLocalWalletId.nodeId(), ContractStatus.COMPLETING));         assertEventStatus(contract, ContractStatus.COMPLETING, nonLocalWalletId.nodeId());         assertEventStatus(contract, ContractStatus.COMPLETING);         assertExternalEventStatusSent(contract, ContractStatus.COMPLETING);         assertEventLocalStatus(contract, ContractStatus.COMPLETING);          assertTransactionConfirmed(fromWithdrawTransactionEvent.getPaymentInfo());          respondExternalEvent(contract, statusFact(contract.getId(), nonLocalWalletId.nodeId(), ContractStatus.COMPLETED));         assertEventStatus(contract, ContractStatus.COMPLETED, nonLocalWalletId.nodeId());          assertEventStatus(contract, ContractStatus.COMPLETED);         assertExternalEventStatusSent(contract, ContractStatus.COMPLETED);         assertEventLocalStatus(contract, ContractStatus.COMPLETED);          assertContractStatusNotification(contract, localWalletId, ContractStatus.COMPLETED);          assertCompleteHistoryEvent(contract);          assertMessageQueuesAreEmpty();     }  

Данный тест проверяет успешный перевод с кошелька на кошелек, когда в обработке учавствуют два узла. Подобные тесты можно писать пачками и времени они почти не требуют на реализацию. Если бы автор писал данный тест как баба Зина, то он бы занял более 2 000 строк!!!

Заключение

В данной работе предложен вариант тестирования интеграций с очередями сообщений на основе специальной библиотеки и сценарного тестирования.

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

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

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

Для работодателей

Не забудь взять себе Java-кота в команду! Мышей не ловит, зато пишет Java-код, а еще проектирует немножко!

https://hh.ru/resume/b33504daff020c31070039ed1f77794a774336

И не забываем


ссылка на оригинал статьи https://habr.com/ru/articles/848936/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *