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

А поскольку в чистой теории это было бы скучно, давайте начнем с примера кода!

Услуга займа и инвентаризации

Предположим, у нас есть служба выдачи книг. Люди могут брать книги, когда они есть в инвентаре и на складе. Службы заимствования и инвентаризации — это небольшие микросервисы, связанные между собой через REST API, как это делают все в наши дни. В какой-то момент BookBorrowService использует InventoryClient, небольшую оболочку для вызовов службы инвентаризации через абстракцию Spring WebClient для неблокирующих HTTP-вызовов. Этот клиент инвентаризации будет предметом этой статьи, потому что существует множество различных ошибок, которые необходимо обработать и протестировать.

Клиент инвентаризации

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

Как отмечалось ранее, здесь мы используем WebClient для неблокирующих HTTP-вызовов, совместимых с неблокирующей реактивной цепочкой. Запрос инициируется вызовом get(), за которым следует URI и завершается вызовом retrieve().

В случае успеха ответ преобразуется в Mono<InventoryEntry> в строке 20. Можно добавить обработку ошибок, добавив вызовы в спецификацию ответа, заданную методом retrieve. Например, два вызова onStatus обрабатывают разные коды состояния HTTP, возвращаемые службой инвентаризации, а метод retryWhen позволяет нам определить, в каком случае нам разумно повторить неудачные запросы.

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

Мы добавляем настраиваемые тайм-ауты для начального подключения к сокету, а также тайм-ауты чтения и записи для сокета в базовый HTTP-клиент, чтобы смягчить небольшие сбои на стороне сервера. Кроме того, мы определяем экспоненциальную стратегию повторных попыток отсрочки, которая должна повторить запрос к службе инвентаризации, если произошло ExternalCommunicationException. Это исключение было выброшено нами, когда сервер вернул какой-то код состояния HTTP 5xx:

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

Что нужно протестировать?

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

Изучив код, мы имеем как минимум следующие случаи:

  • Код состояния 200: OK → все в порядке
  • Код состояния 404: NOT_FOUND → мы ожидаем Mono.empty()
  • Код состояния != 404 (например, BAD_REQUEST) → мы ожидаем Mono.error()
  • Код состояния 5xx && максимальное количество повторных попыток не достигнуто → все в порядке
  • Код состояния 5xx && превышено максимальное количество повторных попыток -> мы ожидаем Mono.error()
  • Сервис отвечает медленно с задержкой ниже таймаута чтения -› все нормально
  • Служба отвечает медленно с задержкой, превышающей тайм-аут чтения -> мы ожидаем Mono.error()

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

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

блок() или StepVerifier?

В принципе, есть два общих направления написания тестов для реактивной цепочки:

  • Непосредственно вызывайте методы для тестирования и получайте результат, вызывая block() для результирующего Mono или Flux.
  • Оберните эти вызовы классом StepVerifier, предоставляемым платформой Reactor.

Поскольку у обоих подходов есть смысл, давайте сформулируем первый тест для сравнения этих двух. Я буду использовать MockWebServer, предоставляемый зависимостью OkHttp3, чтобы имитировать серверную часть. Альтернативой может быть макет самого WebClient, что действительно громоздко из-за его плавного API. Тестовая установка выглядит следующим образом:

Метод setUp обеспечивает правильную инициализацию и запуск MockWebServer для тестового класса. В конце выполнения тестового класса метод tearDown останавливает веб-сервер, чтобы освободить свои ресурсы. В методе before мы инициализируем наш тестируемый класс и привязываем URL-адрес веб-клиента к URL-адресу имитируемого веб-сервера.

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

Здесь мы вызываем метод getByName нашего тестируемого класса, чтобы проверить, действительно ли успешные запросы доставляют ожидаемую запись инвентаризации. Как вы можете видеть в строке 9, мы просто ждем результата, вызывая block() для возвращенного Mono. Пока все выглядит очень знакомо.

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

Метод StepVerifier.create инкапсулирует Mono/Flux, возвращаемый нашим методом, и предоставляет API-интерфейс конструктора для формулирования ожиданий или утверждений в отношении реактивного потока. Кроме того, мы можем использовать созданные элементы для проверки, что можно увидеть при вызове assertNext. Цепочка должна быть завершена соответствующим методом verify. Здесь я использовал verifyComplete, потому что я хочу убедиться, что больше никаких элементов не будет выдано, а реактивная цепочка действительно отправила сигнал завершения. На первый взгляд это очень похоже. Так что же отличает эти два подхода сейчас?

И StepVerifier, и block позволяют нам проверить вывод метода. Был ли возвращен правильный результат? Или пустой результат? Метод вызвал исключение? На эти вопросы довольно просто ответить.

Для варианта block ожидаются следующие возвращаемые значения:

  • Успешный результат: несколько InventoryEntry
  • Неизвестный ресурс (из-за 404 — NOT_FOUND): null
  • Неудачное выполнение: выдается исходное исключение

Теперь можно проверить эти результаты, используя структуру утверждений по своему выбору, например. УтвердитьJ:

Для StepVerifier возвращаемые значения остаются в реактивном мире, поэтому у нас будут следующие случаи:

  • Успешный результат: несколько Mono<InventoryEntry>
  • Неизвестный ресурс (из-за ошибки 404 — NOT_FOUND): Mono.empty()
  • Неудачное выполнение: Mono.error() с инкапсулированным исходным исключением

С помощью API-интерфейса Fluent Builder мы можем добавить эти проверки и утверждения в цепочку тестов:

Мы уже видели метод assertNext. Мы могли бы использовать другой метод для проверки, но я хотел использовать здесь AssertJ для утверждения. Другие возможности:

  • expectNext → принимает InventoryEntry для проверки
  • ConsumerNextWith → ожидает Consumer<InventoryEntry>, и здесь вы также можете использовать AssertJ
  • expectNextMatches → получает Predicate<Inventory> для проверки следующего выданного элемента

Для Mono.empty() мы можем использовать ожидание expectNextCount(0), потому что ни один элемент не должен выдаваться, но мы все равно ожидаем сигнал завершения ( → verifyComplete).

Случай исключения проверяется с помощью соответствующих методов, следуя тому же шаблону именования. Я использовал expectErrorSatifies для добавления утверждений AssertJ. Возможные другие решения: expectError, consumeErrorWith или expectErrorMatches. Поскольку сигнал ошибки также является завершающим сигналом, мы можем не использовать verifyComplete в конце, а вместо этого использовать verify для начала проверки этого выполнения.

Важное примечание. При использовании StepVerifier не забывайте о вызове verify в конце. Только это запускает выполнение, иначе тест был бы зеленым и ничего не выполнялось и не проверялось! Как и в обычной реактивной цепочке, ничего не происходит, пока вы не подпишетесь. Это камень преткновения, который легко забывается в начале.

Что мы получили на данный момент по сравнению с блочным вариантом?

  • Оба способны проверять результат реактивной операции.
  • Оба могут быть объединены с каркасами утверждений.
  • Свободный API конструктора StepVerifier довольно выразителен и инкапсулирует внутреннюю работу. Но лучше не забывать о вызове метода verify в конце.
  • StepVerifier в целом ближе к реактивной цепочке и позволяет вам правильно потреблять сигналы.

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

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

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

Но в чем проблема с настройкой теста с использованием block?

Этот тест занимает около 10 секунд из-за 10-секундной задержки повторной попытки при первой неудачной попытке.

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

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

StepVerifier на самом деле дает нам под рукой функцию, которая может проверить это поведение. Мы можем использовать собственный планировщик для управления временем, используя StepVerifier.withVirtualTime вместо StepVerifier.create, чтобы подключить специальный планировщик, который может перематывать время вперед, чтобы избежать длительных тестов:

Метод StepVerifier.withVirtualTime настраивает специализированный планировщик виртуального времени, который заменяет планировщик по умолчанию для этого тестового примера. После этого вы можете добавить ожидания в цепочку, например expectNoEvent, или вы можете просто перемотать вперед во времени через thenAwait. Из-за этого тест занимает всего миллисекунды.

Однако не забудьте добавить expectSubscription перед вызовом expectNoEvent, потому что сама подписка, очевидно, является сигналом, хотя ожидание нас не интересует. В противном случае тест провалится.

Важное примечание: данные Mono или Flux должны генерироваться внутри функции поставщика! Вы не можете создавать экземпляр переменной раньше, например:

Издатель нужно генерировать лениво, иначе виртуальное время может вообще не работать.

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

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

  • утверждения после выполнения для отброшенных элементов
  • контекстные тесты
  • TestPublisher для эмуляции источника или тестирования собственного оператора
  • PublisherProbe для проверки фактического выполнения потока данных

Пожалуйста, обратитесь к Справочной документации Reactor по этим более сложным темам, если они вам понадобятся в какой-то момент.

Спасибо за прочтение! Не стесняйтесь комментировать или сообщение мне, когда у вас есть вопросы или предложения. Вас могут заинтересовать другие посты, опубликованные в Блоге Digital Frontiers, анонсированные в нашем аккаунте Twitter.