«1. Обзор
В этом руководстве мы подробно рассмотрим тестирование реактивных потоков с помощью StepVerifier и TestPublisher.
Мы будем основывать наше исследование на приложении Spring Reactor, содержащем цепочку операций реактора.
2. Зависимости Maven
Spring Reactor поставляется с несколькими классами для тестирования реактивных потоков.
Мы можем получить их, добавив зависимость реактора-теста:
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
<version>3.2.3.RELEASE</version>
</dependency>
3. StepVerifier
Вообще, реактор-тест имеет два основных применения:
-
создание пошагового теста с StepVerifier создает предопределенные данные с помощью TestPublisher для тестирования нижестоящих операторов
Наиболее распространенный случай тестирования реактивных потоков — это когда у нас есть издатель (Flux или Mono), определенный в нашем коде. Мы хотим знать, как он ведет себя, когда кто-то подписывается.
С помощью API StepVerifier мы можем определить наши ожидания от опубликованных элементов с точки зрения того, какие элементы мы ожидаем и что произойдет, когда наш поток завершится.
Прежде всего, давайте создадим издатель с некоторыми операторами.
Мы будем использовать Flux.just(T elements). Этот метод создаст поток, который испускает заданные элементы, а затем завершается.
Поскольку расширенные операторы выходят за рамки этой статьи, мы просто создадим простой издатель, который выводит только четырехбуквенные имена, отображаемые в верхнем регистре:
Flux<String> source = Flux.just("John", "Monica", "Mark", "Cloe", "Frank", "Casper", "Olivia", "Emily", "Cate")
.filter(name -> name.length() == 4)
.map(String::toUpperCase);
3.1. Пошаговый сценарий
Теперь давайте проверим наш исходный код с помощью StepVerifier, чтобы проверить, что произойдет, когда кто-то подпишется:
StepVerifier
.create(source)
.expectNext("JOHN")
.expectNextMatches(name -> name.startsWith("MA"))
.expectNext("CLOE", "CATE")
.expectComplete()
.verify();
Сначала мы создадим построитель StepVerifier с помощью метода create.
Далее мы оборачиваем наш тестируемый источник Flux. Первый сигнал проверяется с помощью expectNext(T element), но на самом деле мы можем передать любое количество элементов в expectNext.
Мы также можем использовать expectNextMatches и предоставить Predicate\u003cT\u003e для более индивидуального соответствия.
Что касается нашего последнего ожидания, мы ожидаем, что наш поток завершится.
И, наконец, мы используем verify() для запуска нашего теста.
3.2. Исключения в StepVerifier
Теперь давайте объединим нашего издателя Flux с Mono.
У нас будет немедленное завершение этого Mono с ошибкой при подписке на:
Flux<String> error = source.concatWith(
Mono.error(new IllegalArgumentException("Our message"))
);
Теперь, после четырех всех элементов, мы ожидаем, что наш поток завершится с исключением:
StepVerifier
.create(error)
.expectNextCount(4)
.expectErrorMatches(throwable -> throwable instanceof IllegalArgumentException &&
throwable.getMessage().equals("Our message")
).verify();
Мы можем использовать только один метод проверки исключений. Сигнал OnError уведомляет подписчика о том, что издатель закрыт с ошибкой. Поэтому мы не можем добавить больше ожиданий позже.
Если нет необходимости сразу проверять тип и сообщение об исключении, то можно использовать один из специальных методов:
-
expectError() — ожидать любую ошибку \u003e clazz) — ожидать ошибку определенного типа. expectErrorMessage(String errorMessage) — ожидать ошибку, имеющую конкретное сообщение. \u003cThrowable\u003e assertionConsumer) — использовать Throwable для выполнения пользовательского утверждения
3.3. Тестирование издателей, основанных на времени
Иногда наши издатели основаны на времени.
Например, предположим, что в нашем реальном приложении у нас есть однодневная задержка между событиями. Теперь, очевидно, мы не хотим, чтобы наши тесты выполнялись целый день, чтобы проверить ожидаемое поведение с такой задержкой.
Конструктор StepVerifier.withVirtualTime разработан, чтобы избежать длительных тестов.
Мы создаем билдер, вызывая withVirtualTime. Обратите внимание, что этот метод не использует Flux в качестве входных данных. Вместо этого требуется поставщик, который лениво создает экземпляр тестируемого Flux после настройки планировщика.
Чтобы продемонстрировать, как мы можем проверить ожидаемую задержку между событиями, давайте создадим поток с интервалом в одну секунду, который выполняется в течение двух секунд. Если таймер работает правильно, мы должны получить только два элемента:
StepVerifier
.withVirtualTime(() -> Flux.interval(Duration.ofSeconds(1)).take(2))
.expectSubscription()
.expectNoEvent(Duration.ofSeconds(1))
.expectNext(0L)
.thenAwait(Duration.ofSeconds(1))
.expectNext(1L)
.verifyComplete();
Обратите внимание, что нам следует избегать создания экземпляра Flux ранее в коде, а затем возврата поставщиком этой переменной. Вместо этого мы всегда должны создавать экземпляр Flux внутри лямбды.
Есть два основных метода ожидания, которые имеют дело со временем:
-
«thenAwait(Duration duration) — приостанавливает оценку шагов; в течение этого времени могут происходить новые события. последовательность будет проходить с заданной продолжительностью
Обратите внимание, что первым сигналом является событие подписки, поэтому каждому expectNoEvent(Duration duration) должно предшествовать expectSubscription().
3.4. Утверждения после выполнения с помощью StepVerifier
Итак, как мы видели, просто шаг за шагом описать наши ожидания.
Однако иногда нам нужно проверить дополнительное состояние после успешного завершения всего нашего сценария.
Давайте создадим собственный издатель. Он выдаст несколько элементов, затем завершится, приостановится и выпустит еще один элемент, который мы отбросим:
Flux<Integer> source = Flux.<Integer>create(emitter -> {
emitter.next(1);
emitter.next(2);
emitter.next(3);
emitter.complete();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
emitter.next(4);
}).filter(number -> number % 2 == 0);
Мы ожидаем, что он выдаст 2, но отбросит 4, так как мы вызвали emitter.complete первый.
Итак, давайте проверим это поведение с помощью verifyThenAssertThat. Этот метод возвращает StepVerifier.Assertions, к которым мы можем добавить наши утверждения:
@Test
public void droppedElements() {
StepVerifier.create(source)
.expectNext(2)
.expectComplete()
.verifyThenAssertThat()
.hasDropped(4)
.tookLessThan(Duration.ofMillis(1050));
}
4. Создание данных с помощью TestPublisher
Иногда нам могут понадобиться некоторые специальные данные для запуска выбранных сигналов.
Например, у нас может быть очень конкретная ситуация, которую мы хотим протестировать.
В качестве альтернативы мы можем реализовать собственный оператор и проверить, как он себя ведет.
В обоих случаях мы можем использовать TestPublisher\u003cT\u003e, который позволяет нам программно запускать разные сигналы:
-
next(T value) или next(T value, T rest) — отправить один или несколько сигналов подписчики emit(T value) — то же, что и next(T), но вызывает complete() после завершения() — завершает источник с полным сигналом error(Throwable tr) — завершает источник с потоком ошибок( ) — удобный метод переноса TestPublisher в Flux mono() — то же, что и flux(), но перенос в Mono
4.1. Создание TestPublisher
Давайте создадим простой TestPublisher, который выдает несколько сигналов, а затем завершается с исключением:
TestPublisher
.<String>create()
.next("First", "Second", "Third")
.error(new RuntimeException("Message"));
4.2. TestPublisher в действии
Как мы упоминали ранее, иногда нам может понадобиться инициировать точно выбранный сигнал, который точно соответствует конкретной ситуации.
В этом случае особенно важно, чтобы мы полностью владели источником данных. Для этого мы снова можем положиться на TestPublisher.
Во-первых, давайте создадим класс, который использует Flux\u003cString\u003e в качестве параметра конструктора для выполнения операции getUpperCase():
class UppercaseConverter {
private final Flux<String> source;
UppercaseConverter(Flux<String> source) {
this.source = source;
}
Flux<String> getUpperCase() {
return source
.map(String::toUpperCase);
}
}
Предположим, что UppercaseConverter — это наш класс со сложной логикой и операторами, и нам нужно предоставить очень конкретные данные от исходного издателя.
Мы можем легко добиться этого с помощью TestPublisher:
final TestPublisher<String> testPublisher = TestPublisher.create();
UppercaseConverter uppercaseConverter = new UppercaseConverter(testPublisher.flux());
StepVerifier.create(uppercaseConverter.getUpperCase())
.then(() -> testPublisher.emit("aA", "bb", "ccc"))
.expectNext("AA", "BB", "CCC")
.verifyComplete();
В этом примере мы создаем тестовый издатель Flux в параметре конструктора UppercaseConverter. Затем наш TestPublisher выдает три элемента и завершает работу.
4.3. Плохое поведение TestPublisher
С другой стороны, мы можем создать неправильное поведение TestPublisher с помощью фабричного метода createNonCompliant. Нам нужно передать в конструктор одно значение перечисления из TestPublisher.Violation. Эти значения указывают, какие части спецификаций могут быть пропущены нашим издателем.
Давайте взглянем на TestPublisher, который не будет генерировать NullPointerException для нулевого элемента:
TestPublisher
.createNoncompliant(TestPublisher.Violation.ALLOW_NULL)
.emit("1", "2", null, "3");
В дополнение к ALLOW_NULL мы также можем использовать TestPublisher.Violation для:
-
REQUEST_OVERFLOW – разрешает вызов next() без генерации исключения IllegalStateException при недостаточном количестве запросов CLEANUP_ON_TERMINATE — позволяет посылать любой сигнал завершения несколько раз подряд DEFER_CANCELATION — позволяет игнорировать сигналы отмены и продолжать генерацию элементов
5. Заключение
В этой статье мы обсудили различные способы тестирования реактивных потоков из проекта Spring Reactor.
Сначала мы увидели, как использовать StepVerifier для тестирования издателей. Затем мы увидели, как использовать TestPublisher. Точно так же мы видели, как работать с TestPublisher, который ведет себя неправильно.
Как обычно, реализацию всех наших примеров можно найти в проекте Github.