In einem TypeScript Projekt auf node.js Basis setze ich rxjs ein, um Ereignisse zu versenden und zu empfangen. An mehreren Stellen im Code wird die debounce() Funktion von rxjs eingesetzt, um Gruppen schnell aufeinander folgender Ereignisse zusammenzufassen. Dies stellt das Erstellen von UnitTests mittels Jasmine vor erhöhte Anforderungen.

rxjs verwendet intern einen Scheduler, der auf die in JavaScript integrierte Uhr zurückgreift. Es wird reger Gebrauch von der setTimeout() Funktion gemacht. Um nicht bei jedem Test auf das Ablaufen der Timeouts warten zu müssen, stellt Jasmine Funktionen zum Mocken der Uhr zur Verfügung.

Uhr mittels Jasmine mocken

Um die integrierte Uhr durch eine gemockte Uhr aus Jasmine zu ersetzen, muss diese zunächst installiert werden.

jasmine.clock().install();

Nach diesem Aufruf ist die Uhrzeit fixiert und kann manuell weitergestellt werden.

jasmine.clock().tick(100);

Dieser Aufruf dreht die Uhrzeit um 100ms weiter. Zum Abschluss muss noch die gemockte Uhr wieder deinstalliert werden, so dass die echte Uhr wieder die Arbeit übernimmt.

jasmine.clock().uninstall();

Mittels dieser drei Befehle, kann man im Test den Verlauf der Zeit kontrollieren. Code mit rxjs kann somit effizient getestet werden. Es bietet sich an, in größeren Test-Modulen die gemockte Uhr im Rahmen von beforeEach() und afterEach() bzw. beforeAll() und afterAll() Blöcken zu installieren und zu deinstallieren.

Ein unerwartetes Problem

Mit zunehmender Menge an Testfällen, ist es immer häufiger zu Abbrüchen der Test-Suite gekommen. Es wurde ein Error: executing a cancelled action in den Tiefen des rxjs Schedulings geworfen.

Uncaught exception: Error: executing a cancelled action
    /.../node_modules/rxjs/src/internal/scheduler/AsyncAction.ts:87:14
        public execute(state: T, delay: number): any {
        if (this.closed) {
            return new Error('executing a cancelled action');
                    ~
        }
    /.../node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts:39:27
        do {
            if ((error = action.execute(action.state, action.delay))) {
                                ~
            break;
            }
    internal/timers.js:555:17
    jasmine-spec-reporter: unable to open 'internal/timers.js'
    Error: ENOENT: no such file or directory, open 'internal/timers.js'
    internal/timers.js:498:7
    jasmine-spec-reporter: unable to open 'internal/timers.js'
    Error: ENOENT: no such file or directory, open 'internal/timers.js'

Dieser Fehler trat sporadisch in verschiedenen Tests auf. Dabei waren auch Tests betroffen, die überhaupt keinen rxjs Code angesteuert haben. Die Vermutung ist, dass Events aus vorhergegangenen Tests im Scheduler verblieben sind und nach einer gewissen Zeit zu der Exception geführt haben. Da Jasmine aber bereits einen anderen zufälligen Testfall ausgeführt hat, war eine Zuordnung des Auslösers nicht mehr möglich.

Die Lösung

Das beschriebene Verhalten kann verhindert werden, indem man die gemockte Uhr zu Beginn der gesamten Test-Suite installiert und sie erst am Ende wieder deinstalliert. Zusätzlich soll die Uhrzeit nach jedem Test um einen Betrag weitergestellt werden, so dass alle im Test gesetzten Timeouts abgelaufen sind.

Jasmine bietet die Möglichkeit sogenannte helper zu laden. Dies sind .js oder .ts Dateien, die zu Beginn der Test-Suite ausgeführt werden. Zunächst muss in der jasmine.json angegeben werden, wo die helper zu finden sind. Im folgenden Beispiel ist in Zeile 4 zu beachten.

{
  "spec_dir": "spec",
  "spec_files": ["**/*[sS]pec.js"],
  "helpers": ["support/helpers/*.js"],
  "stopSpecOnExpectationFailure": false,
  "random": true
}

In einer helper.ts im konfigurierten Verzeichnis wird folgender Code hinterlegt.

beforeAll(() => {
  jasmine.clock().install();
});

afterAll(() => {
  jasmine.clock().uninstall();
});

afterEach(() => {
  jasmine.clock().tick(500);
});

Die gemockte Uhr bleibt somit über den gesamten Lauf der Test-Suite installiert und wird nach jedem Test um 500ms weitergestellt. Da in meinem Fall das debounce() mit 250ms durchgeführt wird, sollten somit alle Timeouts des jeweiligen Tests abgelaufen sein. Sämtliche jasmine.clock().install() und jasmine.clock().uninstall() Aufrufe können nun aus before und after Blöcken in den einzelnen Test-Dateien entfernt werden. Nach diesem Umbau der Tests ist der beschriebene Fehler nicht mehr aufgetreten und die Tests werden wieder zuverlässig ausgeführt.

Zusammenfassung

Es wurde gezeigt, wie man rxjs nutzenden Code mittels Jasmine testen kann. Dabei werden instabile Tests durch passenden Einsatz der jasmine.clock() Funktionen vermieden.