Zurück

Playing with Marbles - Elegantes Testen von RxJS

von Marco Sieben

Früher war einfach alles leichter. Die Kugel Eis kostete nur 50 Pfennig, öffentliches Geläster war Sache des Dorftratsches und Funktionen immer schön synchron. All das hat sich geändert und damit müssen wir uns nun eben arrangieren.
Heute wollen wir uns mit der dritten genannten Herausforderung beschäftigen: Asynchrone Funktionen, hier im Kontext von RxJS. Ganz speziell wollen wir uns angucken, wie man für solch ein Konstrukt einen guten und nachvollziehbaren Test schreiben kann, denn das ist oftmals eine der größten Herausforderungen bei der reaktiven Programmierung.

Aber warum sind Funktionen, die Observables zurückgeben, so schwer zu testen? Im synchronen Fall ist es ganz einfach: Wir haben einen bestimmten Input und erwarten (mit entsprechend gemockten Abhängigkeiten) einen bestimmten Output. Genau so können wir unseren Test auch schreiben: Mocks definieren, Input reinwerfen, Output validieren. Easy.

Bei Observables stellt sich die Sache nicht mehr ganz so einfach dar. Einerseits bekommen wir nicht einen konkreten Wert als Ergebnis, sondern einen (reaktiven) Stream, der ggf. auch mehrmals Daten ausspucken kann. Und andererseits kommen diese Daten nicht immer sofort aus dem Stream gepurzelt, sondern ggf. verzögert. Wir müssen also erst einige Zeit warten, bis wir die Ergebnisse validieren können. Doch wie lange? Und sollte nicht vielleicht auch diese Wartezeit getestet werden?

Unser Szenario

Um das Ganze genauer zu betrachten, stellen wir uns folgendes Szenario vor: Wir haben einen Webshop, der mehrere Sprachen unterstützt. Auf der Produktdetailseite wollen wir für das jeweilige Produkt (identifiziert durch seinen Produktcode, den wir aus der URL extrahieren) die Details in der gerade aktiven Sprache vom Server abfragen und anzeigen.
Um das zu erreichen haben wir zwei Observables, die uns jeweils die gerade gewählte Sprache sowie den aktuellen Produktcode aus der URL liefern - Observable deshalb, weil beide sich dynamisch ändern können, wenn zum Beispiel der Benutzer die Sprache wechselt oder auf den Link zu einem anderen Produkt klickt. Außerdem haben wir einen Adapter, mit dem wir die Produktdetails vom Backend abfragen können.

import { Observable } from 'rxjs' 
 
export type Product = { 
    code: string 
    description: string 
} 
 
export type ProductAdapter = { 
    fetchProductDetails: (productCode: string, language: string) => Observable<Product>
}

Schlussendlich hätten wir gerne eine Methode, die mithilfe dieser Services die aktuellen Produktdetails liefert - natürlich auch als Observable. Jedes Mal, wenn sich die Sprache oder der Produktcode ändert, möchten wir sofort die neuen Produktdetails für diese Parameter erhalten.

Betrachten wir das Ganze im Code, geschachtelt in eine Klasse:

import { combineLatest, Observable, switchMap } from 'rxjs'
import { Product, ProductAdapter } from './adapter'

export class ProductDetailService {
    constructor(
        private currentProductCode$: Observable<string>,
        private activeLanguage$: Observable<string>,
        private productAdapter: ProductAdapter,
    ) {
    }

    getProductDetails(): Observable<Product> {
        return combineLatest([
            this.currentProductCode$,
            this.activeLanguage$,
        ]).pipe(
            switchMap(([productCode, language]) => this.productAdapter.fetchProductDetails(productCode, language)),
        )
    }
}

Hierfür wollen wir nun verschiedene Tests schreiben, welche sowohl die konkreten Werte als auch die zeitliche Abfolge testen. Nur, wie tun wir das?

Marble Diagramme

Bevor wir mit der konkreten Testimplementierung anfangen, wollen wir uns die einzelnen Observables, mit denen wir es hier zu tun haben, mal genauer angucken und anschaulicher machen.
Dazu können wir Marble-Diagramme verwenden. Wenn wir ein Diagramm für activeLanguage$ erstellen, könnte das zum Beispiel so aussehen:

Zu lesen ist es so: Der durchgezogene Pfeil ist die Zeitachse. Je weiter rechts, desto später passiert etwas. Die Kugeln darauf (die Marbles) beschreiben jeweils einen Wert, der von diesem Observable emittiert wird - und zwar genau zu dem Zeitpunkt, zu dem die Kugel auf der Zeitachse eingezeichnet ist. In diesem Beispiel heißt es also: Erst vergeht etwas Zeit, dann wird "de" emittiert, einige Zeit später "en" und anschließend, nach etwas kürzerer Wartezeit, "fr".

Legen wir ein Diagramm für currentProductCode$ darunter, zum Beispiel dieses, dann ist das so zu lesen, dass beide Diagramme zur gleichen Zeit beginnen und gleich schnell auf der Zeitachse voranschreiten.

Insgesamt wird also erst "de" auf activeLanguage$ emittiert, dann "123" auf currentProductCode$, dann "en" auf activeLanguage$, dann "fr" auf activeLanguage$ und zum Schluss "456" auf currentProduct$.

Zu guter Letzt können wir noch ein Marble-Diagramm für den HTTP-Request erstellen:

Hier sehen wir einen vertikalen Strich auf der Zeitachse. Das bedeutet, dass das Observable zu diesem Zeitpunkt completet. Denn anders als die unendlichen Observables für die Sprache und den Produktcode, emittiert das Observable für Fetch genau einmal (nämlich dann, wenn die Antwort vom Backend kommt) und completet dann sofort. Dass Emittieren und Completen zum gleichen Zeitpunkt geschehen, erkennt man daran, dass sie an der gleichen Stelle auf der Zeitachse eingezeichnet sind. Außerdem muss man sich bei diesem Observable bewusst sein, dass es nicht gleichzeitig mit den anderen loszulaufen beginnt, sondern erst dann, sobald der http-Request getriggert wird - und zwar jedes Mal aufs Neue (es ist ein sogenanntes kaltes Observable).

Grafischer Testaufbau

Wie könnte jetzt also ein Test aussehen?
Wir wollen, dass ab dem Zeitpunkt, an dem wir sowohl einen Code als auch eine Sprache erhalten haben, jedes Mal ein neuer Request ans BE gesendet wird, sobald sich einer der beiden Werte ändert. Sobald dieser Request beantwortet wurde, wollen wir das Ergebnis erhalten. Wenn allerdings bereits während eines noch laufenden Requests wieder ein neuer Code (oder eine neue Sprache) emittiert wurden, dann wollen wir den dann veralteten Request gar nicht weiterverfolgen und uns direkt um den neuen kümmern. Wie sieht das also grafisch aus?

Betrachten wir das mit unseren Marble-Diagrammen von oben:

Wann erwarten wir, dass ein HTTP-Request getriggert wird? Immer dann, wenn activeLanguage$ oder currentProduct$ emittieren (und beide mindestens bereits einmal einen Wert emittiert haben):

An jedem dieser Punkte wird also ein Request getriggert. Legen wir jeweils die Diagramme für den Request darunter:

Wir sehen, dass sich der zweite und der dritte Request überlagern (der dritte wird gestartet, bevor der zweite fertig ist). Daher wollen wir das Ergebnis des zweiten gar nicht mehr haben, sondern warten direkt auf das dritte. Wie sollte also am Ende unser Ergebnisobservable aussehen?

Wenn wir einen Test schreiben könnten, der genau dieses Verhalten testet, dann hätten wir also all die folgenden Dinge getestet:

  • Das Timing stimmt
  • activeLanguage$ und currentProduct$ starten einen neuen Request, wenn sie emittieren
  • Die Ergebnisse von fetchProductDetails werden auch weiter durch das Observable geleitet

Technische Umsetzung

Wie setzen wir diesen Test jetzt technisch um?
Glücklicherweise gibt es genau dafür eine hilfreiche Bibliothek. Wir verwenden hier “jest-marbles”, eine Variante für jasmine existiert aber auch.

Hier haben wir erstmal das grundlegende Testsetup:

import { ProductDetailService } from './product-detail.service'

describe('ProductDetailService', () => {
    describe('getProductDetails', () => {
        let serviceUnderTest: ProductDetailService

        beforeEach(() => {
            serviceUnderTest = new ProductDetailService(___, ___, ___)
        })
    })
})

Für die Konstruktorparameter müssen wir uns jetzt geeignete Mocks basteln, die genau das tun, was wir im Abschnitt davor grafisch dargestellt haben. Nichts leichter als das:

beforeEach(() => {
    const mockActiveLanguage$ = hot('    ---d-------e--f-------', {
        d: 'de',
        e: 'en',
        f: 'fr',
    })

    const mockCurrentProductCode$ = hot('-----1-------------2--', {
        1: '123',
        2: '456',
    })

    serviceUnderTest = new ProductDetailService(mockCurrentProductCode$, mockActiveLanguage$, ___)
})

Durch hot geben wir an, dass wir ein hot Observable anlegen, also eines, das direkt zu Beginn der Testausführung startet. Ein Bindestrich symbolisiert eine Zeiteinheit (10 Frames), in der nichts geschieht, ein anderes Zeichen eine Zeiteinheit (10 Frames), in der dieses Observable etwas emittiert. Was genau es zu diesem Zeitpunkt emittiert, geben wir durch das Objekt an, welches danach kommt. Ein “d” in mockActiveLanguage$ steht also eigentlich für den String 'de', ein “e” für 'en' und “f” für 'fr'. Analog dazu steht “1” in mockCurrentProductCode$ für '123' und “2” für '456'. Leerzeichen werden ignoriert und können dazu verwendet werden, verschiedene Marble-Diagramme gemeinsam auszurichten. Wir sehen hier also sofort, wann mockActiveLanguage$ und mockCurrentProductCode$ relativ zueinander einen Wert emittieren.

Die Darstellung ähnelt den grafischen Marbles: Man stelle sich die “-” als durchgezogenen Zeitstrahl vor und die anderen Zeichen darauf als Kugeln.

Nun müssen wir noch den ProductAdapter mocken. Hier mocken wir ihn so, dass er jedes Mal, wenn er für einen Produktcode productCode und eine Sprache language aufgerufen wird, das Objekt {code: productCode, description: 'description in language ' + language} zurückgibt - natürlich als Observable mit dem Timing wie oben grafisch dargestellt:

const mockProductAdapter: ProductAdapter = {
    fetchProductDetails: (productCode: string, language: string) => cold('---(X|)', {
        X: {code: productCode, description: `description in language ${language}`},
    }),
}

Hier verwenden wir cold, weil wir ein kaltes Observable haben, das bei jeder neuen Subscription erneut von vorne losläuft. Außerdem haben wir hier “(X|)” im Marble-String, was einfach bedeutet, dass in der vierten Zeiteinheit sowohl der Wert für “X” aus dem folgenden Objekt emittiert wird, als auch, dass das Observable gleichzeitig completet (dafür steht das “|”).

Damit haben wir unser Setup vervollständigt und unsere Marbles eins zu eins nachgebaut. Fehlt nur noch der konkrete Test auf das Ergebnis. Zur einfacheren Nachvollziehbarkeit schreiben wir die Observables mit ihren Timings nochmal als Kommentar darüber, damit man leichter sieht, wann welche Werte erwartet werden. Das ist natürlich nicht zwingend notwendig, kann aber oft beim Verständnis helfen.

it('fetches product details each time language or code change', () => {
    // ---d-------e--f-------     - language
    // -----1-------------2--     - productCode
    //      ---X                  │
    //            ---X            ├ fetch (completion ignored for simplicity)
    //               ---X         │
    //                    ---X    │

    expect(serviceUnderTest.getProductDetails()).toBeObservable(
        cold('--------1--------2----3', {
            1: {code: '123', description: 'description in language de'},
            2: {code: '123', description: 'description in language fr'},
            3: {code: '456', description: 'description in language fr'},
        })
    )
})

Der vollständige Test sieht nun also so aus:

import { ProductDetailService } from './product-detail.service'
import { cold, hot } from 'jest-marbles'
import { ProductAdapter } from './adapter'

describe('ProductDetailService', () => {
    describe('getProductDetails', () => {
        let serviceUnderTest: ProductDetailService

        beforeEach(() => {
            const mockActiveLanguage$ = hot('    ---d-------e--f-------', {
                d: 'de',
                e: 'en',
                f: 'fr',
            })

            const mockCurrentProductCode$ = hot('-----1-------------2--', {
                1: '123',
                2: '456',
            })

            const mockProductAdapter: ProductAdapter = {
                fetchProductDetails: (productCode: string, language: 
string) => cold('---(X|)', {
                    X: {code: productCode, description: `description in language ${language}`},
                }),
            }

            serviceUnderTest = new ProductDetailService(mockCurrentProductCode$, mockActiveLanguage$, mockProductAdapter)
        })

        it('fetches product details each time language or code change', () => {
            // ---d-------e--f-------     - language
            // -----1-------------2--     - productCode
            //      ---X                  │
            //            ---X            ├ fetch (completion ignored for simplicity)
            //               ---X         │
            //                    ---X    │

            expect(serviceUnderTest.getProductDetails()).toBeObservable(
                cold('--------1--------2----3', {
                    1: {code: '123', description: 'description in language de'},
                    2: {code: '123', description: 'description in language fr'},
                    3: {code: '456', description: 'description in language fr'},
                })
            )
        })
    })
})

Und das war es schon, unser Test ist fertig. Ändert man einzelne Werte (setzt z.B. im erwarteten Ergebnis die letzte Description auf “description in language de”) oder ändert das Timing bei einzelnen Marbles, dann fällt der Test hin - mit einer Fehlermeldung, die genau angibt, welcher Wert zu welcher Zeit erwartet wurde und welcher Wert zu welcher Zeit stattdessen geliefert wurde.

Wir mussten uns nicht mit einem done()-Callback, fakeAsync() oder eigenen new Subject()-Tricks behelfen, sondern konnten unsere Erwartungshaltung an das Verhalten direkt von den Diagrammen in den Test übertragen.

Ausblick

Natürlich gibt es noch weit mehr, was man mit Observables testen kann. Vielleicht möchte ich zu einem bestimmten Zeitpunkt im Test eine Methode auf meiner Klasse aufrufen. Oder ich möchte einen Aufruf auf einem anderen Service validieren. Auch das lässt sich mit Marble-Tests umsetzen, sprengt aber den Rahmen dieses Beitrags.

Außerdem sei noch erwähnt, dass Operatoren, die das Timing eines Observables ändern, wie debounceTime, delay, interval und ähnliche mit den nativen jest-marbles-Tests leider nicht funktionieren und erst gemockt werden müssen. Vielleicht folgt hier irgendwann ein Beitrag, wie man mit diesen Problemen umgehen kann - bis dahin haben wir aber hoffentlich genug Stoff geliefert, um es selbst ausprobieren zu wollen und die eigenen Tests zu optimieren.

 

Zurück