Test di flussi reattivi utilizzando StepVerifier e TestPublisher

1. Panoramica

In questo tutorial, daremo un'occhiata da vicino al test dei flussi reattivi con StepVerifier e TestPublisher .

Baseremo la nostra indagine su un'applicazione Spring Reactor contenente una catena di operazioni del reattore.

2. Dipendenze di Maven

Spring Reactor viene fornito con diverse classi per testare i flussi reattivi.

Possiamo ottenerli aggiungendo la dipendenza reattore-test :

 io.projectreactor reactor-test test     3.2.3.RELEASE 

3. StepVerifier

In generale, il test del reattore ha due usi principali:

  • creando un test passo passo con StepVerifier
  • produrre dati predefiniti con TestPublisher per testare gli operatori a valle

Il caso più comune nel testare stream reattivi è quando abbiamo un editore (un Flux o Mono ) definito nel nostro codice. Vogliamo sapere come si comporta quando qualcuno si iscrive.

Con l' API StepVerifier , possiamo definire le nostre aspettative sugli elementi pubblicati in termini di quali elementi ci aspettiamo e cosa succede quando il nostro flusso viene completato .

Prima di tutto, creiamo un editore con alcuni operatori.

Useremo un Flux.just (elementi T). Questo metodo creerà un flusso che emette determinati elementi e quindi completa.

Poiché gli operatori avanzati vanno oltre lo scopo di questo articolo, creeremo solo un semplice editore che restituisce solo nomi di quattro lettere mappati in maiuscolo:

Flux source = Flux.just("John", "Monica", "Mark", "Cloe", "Frank", "Casper", "Olivia", "Emily", "Cate") .filter(name -> name.length() == 4) .map(String::toUpperCase);

3.1. Scenario passo passo

Ora, testiamo la nostra fonte con StepVerifier per testare cosa accadrà quando qualcuno si iscrive :

StepVerifier .create(source) .expectNext("JOHN") .expectNextMatches(name -> name.startsWith("MA")) .expectNext("CLOE", "CATE") .expectComplete() .verify();

Innanzitutto, creiamo un builder StepVerifier con il metodo create .

Successivamente, avvolgiamo la nostra sorgente Flux , che è in fase di test. Il primo segnale viene verificato con waitNext (elemento T), ma in realtà, possiamo passare un numero qualsiasi di elementi a waitNext .

Possiamo anche usare waitNextMatches e fornire un predicato per una corrispondenza più personalizzata.

Per la nostra ultima aspettativa, ci aspettiamo che il nostro flusso venga completato.

Infine, usiamo verify () per attivare il nostro test .

3.2. Eccezioni in StepVerifier

Ora concateniamo il nostro editore Flux con Mono .

Faremo terminare immediatamente questo Mono con un errore quando ci si iscrive a :

Flux error = source.concatWith( Mono.error(new IllegalArgumentException("Our message")) );

Ora, dopo quattro tutti gli elementi, ci aspettiamo che il nostro flusso termini con un'eccezione :

StepVerifier .create(error) .expectNextCount(4) .expectErrorMatches(throwable -> throwable instanceof IllegalArgumentException && throwable.getMessage().equals("Our message") ).verify();

Possiamo utilizzare un solo metodo per verificare le eccezioni. Il segnale OnError notifica al sottoscrittore che l'editore è stato chiuso con uno stato di errore. Pertanto, non possiamo aggiungere ulteriori aspettative in seguito .

Se non è necessario controllare contemporaneamente il tipo e il messaggio dell'eccezione, possiamo utilizzare uno dei metodi dedicati:

  • waitError () - aspettati qualsiasi tipo di errore
  • waitError (Class clazz ) - si aspetta un errore di un tipo specifico
  • waitErrorMessage (String errorMessage) - si aspetta un errore con un messaggio specifico
  • waitErrorMatches (predicato predicato) - si aspetta un errore che corrisponde a un dato predicato
  • waitErrorSatisfies (Consumer assertionConsumer) - consuma un Throwable per eseguire un'asserzione personalizzata

3.3. Test di editori basati sul tempo

A volte i nostri editori sono basati sul tempo.

Ad esempio, supponiamo che nella nostra applicazione nella vita reale ci sia un giorno di ritardo tra gli eventi . Ora, ovviamente, non vogliamo che i nostri test durino un'intera giornata per verificare il comportamento previsto con un tale ritardo.

Il generatore StepVerifier.withVirtualTime è progettato per evitare test di lunga durata.

Creiamo un builder chiamando withVirtualTime . Nota che questo metodo non accetta Fluxcome input. Invece, ci vuole un fornitore , che crea pigramente un'istanza del Flux testato dopo aver configurato lo scheduler.

Per dimostrare come possiamo testare un ritardo atteso tra gli eventi, creiamo un Flux con un intervallo di un secondo che dura due secondi. Se il timer funziona correttamente, dovremmo ottenere solo due elementi:

StepVerifier .withVirtualTime(() -> Flux.interval(Duration.ofSeconds(1)).take(2)) .expectSubscription() .expectNoEvent(Duration.ofSeconds(1)) .expectNext(0L) .thenAwait(Duration.ofSeconds(1)) .expectNext(1L) .verifyComplete();

Si noti che dovremmo evitare di creare un'istanza del Flux nelle prime fasi del codice e quindi che il fornitore restituisca questa variabile. Invece, dovremmo sempre istanziare Flux all'interno del lambda.

Esistono due principali metodi di aspettativa che si occupano del tempo:

  • thenAwait(Duration duration) – pauses the evaluation of the steps; new events may occur during this time
  • expectNoEvent(Duration duration) – fails when any event appears during the duration; the sequence will pass with a given duration

Please notice that the first signal is the subscription event, so every expectNoEvent(Duration duration) should be preceded with expectSubscription().

3.4. Post-Execution Assertions with StepVerifier

So, as we've seen, it's straightforward to describe our expectations step-by-step.

However, sometimes we need to verify additional state after our whole scenario played out successfully.

Let's create a custom publisher. It will emit a few elements, then complete, pause, and emit one more element, which we'll drop:

Flux source = Flux.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);

We expect that it will emit a 2, but drop a 4, since we called emitter.complete first.

So, let's verify this behavior by using verifyThenAssertThat. This method returns StepVerifier.Assertions on which we can add our assertions:

@Test public void droppedElements() { StepVerifier.create(source) .expectNext(2) .expectComplete() .verifyThenAssertThat() .hasDropped(4) .tookLessThan(Duration.ofMillis(1050)); }

4. Producing Data with TestPublisher

Sometimes, we might need some special data in order to trigger the chosen signals.

For instance, we may have a very particular situation that we want to test.

Alternatively, we may choose to implement our own operator and want to test how it behaves.

For both cases, we can use TestPublisher, which allows us to programmatically trigger miscellaneous signals:

  • next(T value) or next(T value, T rest) – send one or more signals to subscribers
  • emit(T value) – same as next(T) but invokes complete() afterwards
  • complete() – terminates a source with the complete signal
  • error(Throwable tr) – terminates a source with an error
  • flux() – convenient method to wrap a TestPublisher into Flux
  • mono() – same us flux() but wraps to a Mono

4.1. Creating a TestPublisher

Let's create a simple TestPublisher that emits a few signals and then terminates with an exception:

TestPublisher .create() .next("First", "Second", "Third") .error(new RuntimeException("Message"));

4.2. TestPublisher in Action

As we mentioned earlier, we may sometimes want to trigger a finely chosen signal that closely matches to a particular situation.

Now, it's especially important in this case that we have complete mastery over the source of the data. To achieve this, we can again rely on TestPublisher.

First, let's create a class that uses Flux as the constructor parameter to perform the operation getUpperCase():

class UppercaseConverter { private final Flux source; UppercaseConverter(Flux source) { this.source = source; } Flux getUpperCase() { return source .map(String::toUpperCase); } }

Suppose that UppercaseConverter is our class with complex logic and operators, and we need to supply very particular data from the source publisher.

We can easily achieve this with TestPublisher:

final TestPublisher testPublisher = TestPublisher.create(); UppercaseConverter uppercaseConverter = new UppercaseConverter(testPublisher.flux()); StepVerifier.create(uppercaseConverter.getUpperCase()) .then(() -> testPublisher.emit("aA", "bb", "ccc")) .expectNext("AA", "BB", "CCC") .verifyComplete();

In this example, we create a test Flux publisher in the UppercaseConverter constructor parameter. Then, our TestPublisher emits three elements and completes.

4.3. Misbehaving TestPublisher

On the other hand, we can create a misbehaving TestPublisher with the createNonCompliant factory method. We need to pass in the constructor one enum value from TestPublisher.Violation. These values specify which parts of specifications our publisher may overlook.

Let's take a look at a TestPublisher that won't throw a NullPointerException for the null element:

TestPublisher .createNoncompliant(TestPublisher.Violation.ALLOW_NULL) .emit("1", "2", null, "3"); 

In addition to ALLOW_NULL, we can also use TestPublisher.Violation to:

  • REQUEST_OVERFLOW – allows calling next() without throwing an IllegalStateException when there's an insufficient number of requests
  • CLEANUP_ON_TERMINATE – allows sending any termination signal several times in a row
  • DEFER_CANCELLATION – allows us to ignore cancellation signals and continue with emitting elements

5. Conclusion

In this article, we discussed various ways of testing reactive streams from the Spring Reactor project.

First, we saw how to use StepVerifier to test publishers. Then, we saw how to use TestPublisher. Similarly, we saw how to operate with a misbehaving TestPublisher.

Come al solito, l'implementazione di tutti i nostri esempi può essere trovata nel progetto Github.