Testowanie RxJS – Marble Diagrams

jak testować strumienie za pomocą Marble Diagrams ;)? Na to niebanalne pytanie odpowie nam we wpisie gościnnym, Wojtek Trawiński.

Wojtek to JavaScript developer pracujący na co dzień w jednym z największych wydawnictw oświatowych w Polsce, w którym zajmuje się implementacją aplikacji do nauki matematyki i informatyki z wykorzystaniem frameworka Angular. Pasjonat programowania funkcyjnego, wielki entuzjasta biblioteki RxJS. Autor bloga JavaScript everyday, gdzie porusza różnego rodzaju tematy związane z JS.

 

Testowanie RxJS – Marble Diagrams

Podczas tworzenia aplikacji z wykorzystaniem frameworka Angular spotkałeś(aś) się zapewne z biblioteką RxJS pozwalającą na tworzenie reaktywnego kodu. Bardzo duża liczba operatorów dostarczanych przez bibliotekę pozwala na rozwiązywanie złożonych zagadnień w prosty i przejrzysty sposób. W konsekwencji, znaczna część logiki aplikacji może zostać zaimplementowana z użyciem RxJS, a co za tym idzie pojawia się potrzeba przetestowania takiego kodu.

Kod napisany z wykorzystaniem biblioteki RxJS można testować na dwa sposoby:

  • poprzez subskrypcję do strumienia i porównanie wyemitowanych wartości z oczekiwanymi rezultatami (w przypadku asynchronicznego kodu z pomocą przychodzi funkcja fakeAsync dostępna w Angularze),
  • wykorzystując marble diagrams do zdefiniowania strumieni w postaci ciągu znaków opisujących przebieg zdarzeń na przestrzeni wirtualnego czasu.

W artykule opiszę drugi z wyżej wymienionych sposobów, ponieważ pozwala on na przetestowanie złożonego, asynchronicznego kodu w przystępny sposób. Skorzystam z podejścia zalecanego przez twórców biblioteki RxJS, tzn. testy napiszę w ramach callbacku przekazanego do metody run instancji klasy TestScheduler (szczegóły w dalszej części artykułu).

Na początku krótki wstęp teoretyczny na temat klasy TestScheduler i składni używanej do pisania  marble diagrams. Następnie przedstawię implementację testów dla dwóch własnych operatorów oraz przetestuję klasyczny efekt (element architektury przy zarządzaniu stanem z wykorzystaniem biblioteki NgRx) z użyciem marble diagrams.

TestScheduler

Biblioteka RxJS dostarcza klasę TestScheduler, która umożliwia napisanie testów dla naszego kodu z użyciem marble diagrams. Każdy test musimy rozpocząć od utworzenie instancji tej klasy podając w konstruktorze callback, który pozwala na zdefiniowanie w jaki sposób mają być porównywane wartości emitowane z testowanego strumienia z oczekiwanymi rezultatami.

Testy przedstawione w dalszej części artykułu wykonane zostały w Jasmine stąd moja definicja wykorzystuje toEqual matcher.

Instancja klasy TestScheduler posiada metodę run, która przyjmuje jako argument callback, który zostanie wywołany z obiektem helpers zawierającym kilka pomocniczych funkcji umożliwiających napisanie testu.

W ramach tego callbacku, z wykorzystaniem dostarczonych pomocniczych funkcji (hot, cold, expectObservable) możemy zdefiniować ciało naszego testu.

W testach wykorzystam funkcje pomocnicze hot i cold dostarczane przez obiekt helpers do zdefiniowania strumieni:

  • hot(marbleDiagram: string, values?: object, error?: any) – tworzy hot observable,
  • cold(marbleDiagram: string, values?: object, error?: any) – tworzy cold observable,

gdzie marbleDiagram to definicja zachowania strumienia w wirtualnym czasie (określenie kiedy emituje wartości, kiedy wystąpi błąd lub zakończenie strumienia), values to obiekt mapujący znak alfanumeryczny (oznaczający next notification) na konkretną wartość, a error jak sama nazwa wskazuje pozwala na zdefiniowanie obiektu błędu.

Porównanie wynikowego strumienia z wartością oczekiwaną możliwe jest dzięki funkcji pomocniczej expectObservable:

  • expectObservable(actual: Observable<T>).toBe(marbleDiagram: string, values?: object, error?: any),

gdzie actual to wynikowy strumień, który chcemy porównać z oczekiwanym observable, zdefiniowanym w ramach toBe matcher.

Składnia marble diagram

W odniesieniu do instancji klasy TestScheduler, marble diagram to ciąg znaków o specjalnej składni opisujący zdarzenia występujące na przestrzeni wirtualnego czasu. Jednostką czasu jest tzw. frame, który w domyślnej konfiguracji odpowiada 1ms wirtualnego czasu.

Podstawowe elementu składni występującej w marble diagrams to:

  • ‚ ‚ – biały znak, który jest ignorowany i może pomóc w pionowym wyrównaniu,
  • ‚-‚ – reprezentuje upływ wirtualnego czasu odpowiadający pojedynczemu frame (domyślnie 1 ms),
  • 10ms – dowolna liczba z jednostką czasu (ms|s|m) oznacza odpowiadający upływ wirtualnego czasu,
  • ‚|’ – zakończenie strumienia (complete notification),
  • ‚#’ – w strumieniu wystąpił błąd (error notification),
  • [a-z0-9] – alfanumeryczny znak oznacza emisję wartości przez strumień (next notification),
  • () – pozwalają na grupowanie wartości tak aby pokazać że ich emisja następuje w tej samej chwili czasowej, jak np. w observable utworzonym przy użyciu funkcji from.

To wszystko co musisz wiedzieć o składni marble diagrams, aby rozpocząć przygodę z testowaniem kodu RxJS w tym podejściu.

Testujemy operatory

Mam nadzieję, że nadal jesteś ze mną i po solidnej dawce teorii jesteś gotowy do stawienia czoła praktycznym przykładom.

Zacznijmy od przetestowania prostego operatora pozwalającego na filtrowanie liczb parzystych.

Pierwsze co musimy zrobić to stworzyć instancję klasy TestScheduler zgodnie z tym co zostało opisane w poprzednim paragrafie. Następnie możemy przystąpić do pisania testu w ramach callbacku przekazywanego do metody run obiektu schedulera.

Dla każdego z testów polecam tworzenie trzech strumieni:

  • source$ – definicja źródłowego strumienia,
  • result$ – strumień z wartościami z source$ z zaaplikowanym operatorem z funkcji filterEvenNumbers,
  • expected$ – oczekiwany strumień z uwzględnieniem działania użytego operatora.

W ramach pierwszego testu sprawdzimy czy parzysta liczba zostanie wyemitowana przez wynikowy strumień.

Źródłowy strumień zdefiniujemy następująco:

  • zaczekaj 1ms,
  • wyemituj next notification (litera a, której odpowiada wartość 6),
  • zaczekaj 2ms,
  • wyemituj complete notification.

W tym przypadku oczekiwany marble diagram będzie identyczny jak ten dla źródłowego strumienia (tak działa testowany przez nas operator). Dla rozróżnienia, wyemitowaną wartość symbolizuje litera z, której przypisana jest liczba 6. Uwaga, mapowanie symboli do konkretnych wartości dla strumienia expected$ wykonujemy w ramach ‘matchera’ toBe podając słownik jako drugi argument.

Drugi przypadek testowy sprawdzi czy wartość nieparzysta zostanie zatrzymana dzięki operatorowi i nie pojawi się w wynikowym strumieniu. Różnica w porównaniu do pierwszego testu jest taka, że w strumieniu expected$ w chwili czasowej odpowiadającej wyemitowaniu przez source$ liczby nieparzystej pojawia się znak ‚-‚ oznaczający upływ 1ms wirtualnego czasu. W wynikowym strumieniu nie spodziewamy się pojawienia żadnej wartości jednak musimy odnotować emisję w źródłowym strumieniu po prostu jako upływ pojedynczego czasowego frame’a, ponieważ każda emisja next notification trwa 1ms wirtualnego czasu.

Przejdźmy teraz do przetestowania bardziej złożonego operatora, tak aby dostrzec korzyści płynące z podejścia korzystającego z marble diagrams.

Przetestujemy operator, który czeka aż po wyemitowaniu wartości ze źródłowego strumienia upłynie określony okres czasu (debounce dla danych wejściowych) oraz ignoruje wartość jeżeli jest taka sama jak ostatnia wyemitowana. W przypadku tego operatora sprawdzimy również dwa przypadki. Zacznijmy od sprawdzenia czy w wynikowym strumieniu nie pojawi się wartość, jeżeli po jej emisji ze źródłowego strumienia nie upłynął wymagany czas bez pojawienia się kolejnej wartości, czyli w skrócie czy otrzymamy tylko najnowszą notyfikację. W drugim teście sprawdzimy czy wartość zostanie zignorowana jeżeli jest taka sama jak poprzednia wyemitowana w ramach next notification.

W pierwszym teście musimy odnotować w expected$ emisję wartości a ze źródłowego strumienia i odstęp 1ms do wyemitowania wartości b, która znajdzie się w wynikowym strumieniu. Robimy to poprzez zaznaczenie upływu 2ms (‚-‚ + 1ms). Następnie w wyniku emisji wartości b ze strumienia source$ odnotujemy czas równy debounceTime (500ms). Po upływie tego czasu w wynikowym strumieniu pojawi się wartość z. Czas pozostały do emisji complete notification obliczymy następująco:

  • w source$ do wyemitowania complete notification upłynie: 1ms + 1ms (emisja a) + 1ms + 1ms (emisja b) + 2s = 2004ms,
  • w expected$ do chwili emisji wartości z włącznie upłynie: 1ms + 1ms (znak ‚-‚) + 1ms + 500ms + 1ms (emisja z) = 504ms. Zatem poprawne będzie założenie, że w wynikowym strumieniu emisja complete notification nastąpi po upływie 2004 – 504 = 1500ms.

W drugim teście postępujemy analogicznie z tą różnicą, że druga wartość ze źródłowego strumienia zostanie zignorowana dzięki obecności operatora distinctUntilChanged.

Testujemy NgRx Effect

Korzystając z biblioteki NgRx do zarządzania stanem aplikacji pojawia się konieczność przetestowania efektów. W związku z tym, że efekt to strumień do którego zasubskrybowany jest Store (dla {dispatch: true}), to możemy wykorzystać zdobytą wiedzę o marble diagrams do przetestowania tego elementu naszej aplikacji. Efekt który przetestuję to klasyczne zastosowanie tej biblioteki.

W odpowiedzi na akcję LoadCars wykonuję w efekcie zapytanie http i w przypadku uzyskania poprawnej odpowiedzi z backendu wysyłam akcję LoadCarsSuccess, a w przypadku niepowodzenie przechwytuję błąd przy użyciu operatora catchError i dostarczam tzw. fallback value w postaci akcji LoadCarsFailure. Musimy przetestować dwa przypadki, tzn. poprawną odpowiedź z backendu i wystąpienie błędu.

Tak jak w przypadku wcześniejszych testów, przed każdym z nich musimy utworzyć instancję klasy TestScheduler.

Przy pomocy marble diagram definiujemy strumień z akcjami action$ :

  • zaczekaj 5ms,
  • wyemituj next notification z wartością loadCarsAction.

Następnie określamy co zwróci wywołanie metody loadCars na szpiegu tej metody. Korzystamy tutaj z funkcji pomocniczej cold i definiujemy zwracany strumień następująco:

  • zaczekaj 1s,
  • wyemituj next notification z wartością mockCars,
  • wyemituj complete notification.

Dla przypadku testowego, podczas którego sprawdzamy zachowanie efektu dla wystąpienia błędu podczas zapytania http, strumień będzie wyglądał następująco:

  • zaczekaj 1s,
  • wyemituj error notification z wartością error.

 Marble diagram dla strumienia expected$ zdefiniowany jest następująco:

  • zaczekaj 5ms (tyle czasu upłynie zanim action$ wyemituje loadCarsAction),
  • zaczekaj 1s (to czas który upłynie zanim otrzymamy odpowiedź z backendu),
  • wyemituj next notification z wartością loadCarsSuccessAction lub loadCarsFailureAction w zależności od przypadku testowego.

Warto zwrócić uwagę że na oficjalnej stronie ngrx.io znajduje się przykład, który korzysta z paczki jasmine-marbles to tworzenia testów z wykorzystaniem marble diagrams. Z drugiej strony dla RxJS od wersji 6 zalecanym podejściem jest jawne użycie TestSchedulera i zdefiniowanie testów w ramach callbacku do metody run (tak jak we wszystkich przykładach w tym artykule).

 Wadą podejścia z TestScheduler w porównaniu do jasmine-marbles, jest dostęp do funkcji pomocniczych (hot, cold) tylko w ramach callbacku przez co nie możemy określić strumienia actions$ wewnątrz beforeEach, tylko powtarzamy kod w każdym teście. Z drugiej strony, definicja testów w ramach callbacku metody run pozwala na skorzystanie z nowej składni dla określenia upływu czasu. Możemy korzystać z notacji z ‚-‚, jak również podać liczbę z odpowiednią jednostką. W przypadku jasmine-marbles upływ czasu można określać tylko z użyciem znaku ‚-‚ co prowadzi do skomplikowanego zapisuje jeżeli musimy zaznaczyć upływ długiego czasu np. 500ms w debounceTime. Ponadto, jeżeli w efektach korzystamy z operatorów bazujących na czasie (np. debounceTime) to w przypadku używania z jasmine-marbles musimy zdefiniować efekty jako funkcje i na potrzeby testu jawnie podmieniać domyślny scheduler na instancję TestSchedulera. Jeżeli korzystamy z zalecanego podejścia z testami w ramach callbacku metody run to dla operatorów, które domyślnie korzystają a AsyncSchedulera (delay, debounceTime) nastąpi niejawnie podmiana na TestSchedulera.

Podsumowanie

Mam nadzieję, że mój artykuł przybliżył Tobie tematykę testowania kodu wykorzystującego bibliotekę RxJS z użyciem marble diagrams. Składnia wykorzystywana do definiowania marble diagrams nie jest skomplikowana jednak wymaga chwili czasu, aby biegle poruszać się w tym temacie dlatego zachęcam do dalszej samodzielnej nauki tak aby nabrać wprawy w definiowaniu strumieni. Marble diagrams pozwalają nie tylko na testowanie kodu RxJS, ale również są doskonałym sposobem na sprawdzenie swojej wiedzy na temat działania poszczególnych operatorów RxJS. Zachęcam do napisania źródłowego strumienia z pewnymi wartościami a następnie zaaplikowanie tzw. higher-order operators (concatMap, mergeMap, switchMap, exhaustMap) i wykonanie testów sprawdzających czy w poprawny sposób potrafisz przewidzieć jak na przestrzeni czasu będzie wyglądał wynikowy strumień.

2 Comments

  1. Wojciech Trawiński

    Nazwy zmiennych odnoszących się do strumieni danych (observables) w RxJS zgodnie z konwencją kończą się znakiem dolara na końcu ‚$’.
    Jest to konwencja, której warto się trzymać, ponieważ łatwo w IDE wyszukać wszystkie miejsca gdzie mamy zmienne odnoszące się do strumieni danych.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *