Durch das Testlabyrinth navigieren: Fallstricke beim Testen von NestJS-Modulen mit @nestjs/axios' HttpService

In der sich ständig weiterentwickelnden Welt der Webentwicklung ist es von entscheidender Bedeutung, die Zuverlässigkeit und Funktionalität deiner NestJS-Module zu gewährleisten. Doch beim Umgang mit Modulen, die den HttpService von @nestjs/axios einbinden, kann das Navigieren durch die Testlandschaft zu einer komplexen Herausforderung werden. In diesem Blogbeitrag werden wir uns mit den Herausforderungen befassen, denen Entwickler/innen gegenüberstehen, wenn sie NestJS-Module unter Verwendung von HttpService testen, und die entscheidende Rolle des RxJS TestSchedulers bei der Überwindung dieser Hürden erkunden. Darüber hinaus werden wir die Feinheiten der Integration von HttpService in das Testmodul entwirren und einen umfassenden Leitfaden an die Hand geben, um deine Teststrategien zu stärken und die Robustheit deiner NestJS-Anwendungen zu gewährleisten. Begleite uns, während wir durch die Fallstricke navigieren, bewaffnet mit Erkenntnissen, um die Effizienz deiner Test-Suites zu steigern.

Mann arbeitet mit Netzwerk-Hardware
Foto von ThisisEngineering RAEng auf Unsplash

Table Of Contents

Die REST-Schnittstelle "Universitäten"

Wir werden die freie Universitäten-REST-Schnittstelle als Grundlage für eine NestJS-Beispielanwendung nutzen. Die Antwortdaten auf eine GET-Anfrage http://universities.hipolabs.com/search?country=Luxembourg sehen folgendermaßen ais:

[
    {
        "name": "International University Institute of Luxembourg",
        "alpha_two_code": "LU",
        "state-province": null,
        "domains": [
            "iuil.lu"
        ],
        "country": "Luxembourg",
        "web_pages": [
            "http://www.iuil.lu/"
        ]
    },
    {
        "name": "University of Luxemburg",
        "alpha_two_code": "LU",
        "state-province": null,
        "domains": [
            "uni.lu"
        ],
        "country": "Luxembourg",
        "web_pages": [
            "http://www.uni.lu/"
        ]
    }
]

Wir werden einen Client für diese Schnittstelle mit Hilfe von NestJS schreiben. Der Quelltext für die Anwendung ist unter https://github.com/gflohr/test-nestjs-http-service verfügbar. Alternativ kannst du der folgenden Schritt-für-Schritt-Anleitun folgen, um die Mini-Anwendung selber zusammenzubasteln.

Zum aktuellen Zeitpunkt verwendet die Applikation NestJS 10.3.0 und RxJS 7.8.1. Die Anleitung sollte aber auch für andere Versionen passen.

Wenn du genügend Erfahrung mit Nest hast, wirst du wissen, wie ein solcher Client implementiert wird. In diesem Fall kannst du auch direkt zum Abschnitt Tests schreiben springen.

Grundgerüst für den Client

NestJS hat ein Kommandozeilen-Werkzeug, mit dem sich das Grundgerüst für eine Applikation oder einzelne Komponenten erzeugen lässt. Wir fangen mit der Generierung einer Applikation an.

$ npx nest new --strict universities
$ npx nest new universities
⚡  We will scaffold your app in a few seconds..

? Which package manager would you ❤️  to use? (Use arrow keys)
❯ npm 
  yarn 
  pnpm 
CREATE universities/.eslintrc.js (663 bytes)
...

✔ Installation in progress... ☕

🚀  Successfully created project universities
...

Wir rufen nest new mit der Option --strict auf, um den strikten Modus von TypeScript zu aktivieren.

Die Installation der Abhängigkeiten dauert ein wenig. Wenn die Installation durch ist, können wir das Verzeichnis wechseln, und die Applikation starten.

$ cd universities
$ npm run start

Die Seite http://localhost:3000 sollte jetzte eine Hallo-Welt-Webseite zeigen. Weil wir aber einen Client und keinen Server erzeugen wollen, brechen wir die Anwendung erst einmal mit STRG-C ab.

Als nächstes generieren wir das Kernstück unserer Applikation, das Modul universities und innerhalb dessen den Service universities:

$ npx nest generate module universities
CREATE src/universities/universities.module.ts (89 bytes)
UPDATE src/app.module.ts (340 bytes)
$ npx nest generate service universities
CREATE src/universities/universities.service.spec.ts (502 bytes)
CREATE src/universities/universities.service.ts (96 bytes)
UPDATE src/universities/universities.module.ts (187 bytes)

Implementierung des Clients

Abhängigkeiten installieren

Es gibt einige zusätzliche Abhängigkeiten, die zuerst installiert werden müssen:

$ npm install --save @nestjs/axios @nestjs/config

Konfigurationsdatei

Der nächste Schritt besteht in der Erzeugung einer Mini-Konfiguration.

Dazu erzeugen wir ein Verzeichnis src/config und darin eine neue Datei src/config/configuration.ts:

const DEFAULT_UNIVERSITIES_BASE_URL = 'http://universities.hipolabs.com';

export default () => ({
    universities: {
        baseUrl: process.env.UNIVERSITIES_BASE_URL || DEFAULT_UNIVERSITIES_BASE_URL,
    },
});

Das Interface University

Erzeuge eine neue Datei src/universities/university.interface.ts:

export interface University {
    name: string;
    country: string;
    'state-province': string | null;
    alpha_two_code: string;
    web_pages: string[];
    domains: string[];
}

Das ist einfach der Datentyp, der von der REST-Schnittstelle geschickt wird.

Anpassung des Moduls Universities

Das Modul universities muss die neuen Abhängigkeiten einbinden. Dazu ändern wir src/universities/universities.module.ts auf folgenden Stand:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import { UniversitiesService } from './universities.service';

@Module({
    imports: [ConfigModule, HttpModule],
    providers: [UniversitiesService],
})
export class UniversitiesModule {}

Sowohl ConfigModule als auch HttpModule werden dem Array imports zugefügt.

Implementierung des Services Universities

Als nächstes muss der Service universities in src/universities/universities.service.ts angefasst werden:

import { Injectable, Logger } from '@nestjs/common';
import { Observable, map, of } from 'rxjs';
import { University } from './university.interface';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class UniversitiesService {
    private readonly baseUrl: string;
    private readonly logger = new Logger(UniversitiesService.name);

    constructor(
        private readonly configService: ConfigService,
        private readonly httpService: HttpService,
    ) {
        this.baseUrl = configService.get('universities.baseUrl') as string;
    }

    findByCountry(country: string): Observable<University[]> {
        if (null == country) {
            this.logger.error('no country specified');
            return of([]);
        }
        this.logger.log(`getting universities for ${country}`);
        const url = new URL(this.baseUrl);
        url.pathname = '/search';
        url.search = '?country=' + country;

        const o$ = this.httpService.get<University[]>(url.toString());
        return o$
            .pipe(map(response => response.data));
    }
}

Der Code sollte selbsterklärend sein. Ansonsten, lies bitte die Dokumentation für das NestJS Http-Modul. Eine echte Anwendung sollte natürlich auch etwas Fehlerbehandlung enthalten.

Anpassung des App-Moduls

Weil wir einen REST-Client, keinen Server implementierten, sollten wir auch den Controller, der von nest new generiert wurden, löschen:

$ rm src/app.controller.ts src/app.controller.spec.ts

Nach dem Löschen der Controller-Dateien, kompiliert das Applikations-Modul src/app.module.ts nicht mehr. Wir beheben das und fügen gleichzeitig das http- und config-Modul zu. Die Datei src/app.module.ts sollte danach so aussehen:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import { UniversitiesModule } from './universities/universities.module';
import { AppService } from './app.service';
import { UniversitiesService } from './universities/universities.service';
import configuration from './config/configuration';

@Module({
    imports: [
        ConfigModule.forRoot({
            load: [configuration],
        }),
        UniversitiesModule,
        HttpModule,
    ],
    controllers: [],
    providers: [AppService, UniversitiesService],
})
export class AppModule {}

Anpassung des App-Services

Der App-Service src/app.service.ts muss ebenfalls noch angepackt werden:

import { Injectable } from '@nestjs/common';
import { UniversitiesService } from './universities/universities.service';
import { first } from 'rxjs';

@Injectable()
export class AppService {
    constructor(private universitiesService: UniversitiesService) {}

    getUniversities(country: string): void {
        this.universitiesService
            .findByCountry(country)
            .pipe(first())
            .subscribe(console.log);
    }
}

Der App-Service hat jetzt nur die eine Methode getUniversities(), die mit Hilfe des Service' universities eine Liste von Universitäten anfordert und sie auf die Konsole ausgibt.

Anpassung des Einstiegspunkts src/main.ts

Der letzte Schritt besteht in der Anpassung des Einstiegspunkts der Applikation src/main.ts an die zuvor gemachten Änderungen:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AppService } from './app.service';

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    const appService = app.get(AppService);
    appService.getUniversities(process.argv[2]);
    await app.close();
}
bootstrap();

Ausprobieren der Anwendung

Zeit, die neue Anwendung auszuprobieren:

$ npx ts-node src/main.ts Luxembourg
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [NestFactory] Starting Nest application...
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [InstanceLoader] HttpModule dependencies initialized +12ms
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [InstanceLoader] ConfigModule dependencies initialized +1ms
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [InstanceLoader] UniversitiesModule dependencies initialized +1ms
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [InstanceLoader] AppModule dependencies initialized +0ms
[Nest] 88309  - 01/18/2024, 3:31:57 PM     LOG [UniversitiesService] getting universities for Luxembourg
[
    {
        name: 'International University Institute of Luxembourg',
        alpha_two_code: 'LU',
        'state-province': null,
        domains: [ 'iuil.lu' ],
        country: 'Luxembourg',
        web_pages: [ 'http://www.iuil.lu/' ]
    },
    {
        name: 'University of Luxemburg',
        alpha_two_code: 'LU',
        'state-province': null,
        domains: [ 'uni.lu' ],
        country: 'Luxembourg',
        web_pages: [ 'http://www.uni.lu/' ]
    }
]

Das funktioniert einwandfrei. Übrigens wird immer Luxemburg als Beispiel genommen, weil das Land nur zwei Universitäten hat, von der die Schnittstelle etwas weiß.

Tests schreiben

Unit-Tests

Jetzt ist es Zeit die Tests zu ändern. Tatsächlich kompiliert die Testdatei src/universities.service.spec.ts, die vom Nest-Kommandozeilentool erzeugt wurde, noch nicht einmal mehr:

$ npm run test
 npm run test

> universities@0.0.1 test
> jest

 FAIL  src/universities/universities.service.spec.ts
  UniversitiesService
    ✕ should be defined (11 ms)

  ● UniversitiesService › should be defined

    Nest can't resolve dependencies of the UniversitiesService (?, HttpService). Please make sure that the argument ConfigService at index [0] is available in the RootTestModule context.

    Potential solutions:
    - Is RootTestModule a valid NestJS module?
    - If ConfigService is a provider, is it part of the current RootTestModule?
    - If ConfigService is exported from a separate @Module, is that module imported within RootTestModule?
      @Module({
        imports: [ /* the Module containing ConfigService */ ]
      })

...

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        2.839 s
Ran all test suites.

Bereitstellung der Modul-Abhängigkeiten

Es gibt offensichtlich ein Problem mit dem ConfigModule. Eigentlich existiert das gleiche Problem mit dem HttpModule. Das wird klar, wenn du dir den Konstruktor von UniversityService in src/universities/universities.ts anschaust:

constructor(
    private readonly configService: ConfigService,
    private readonly httpService: HttpService,
) {
    this.baseUrl = configService.get('universities.baseUrl') as string;
}

Der Konstruktor benötigt zwei injizierte Abhängigkeiten, einen ConfigService und einen HttpService. Das passiert für Testmodule allerdings nicht automatisch.

Der Grund dafür ist, dass davon abgeraten wird, "echte" Services im Test-Code zu verwenden. Schliesslich wollen wir unseren UniversitiesService testen, und nicht den ConfigService und HttpService, die beide als Teil von NestJS ausgeliefert werden.

import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { AxiosRequestHeaders } from 'axios';
import { UniversitiesService } from './universities.service';
import { Observer, of } from 'rxjs';
import { University } from './university.interface';

const MOCK_URL = 'http://localhost/whatever';

describe('UniversitiesService', () => {
    let service: UniversitiesService;
    let httpService: HttpService;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                UniversitiesService,
                {
                    provide: HttpService,
                    useValue: {
                        get: jest.fn(),
                    },
                },
                {
                    provide: ConfigService,
                    useValue: {
                        get: () => MOCK_URL,
                    }
                }
            ],
        }).compile();

        service = module.get<UniversitiesService>(UniversitiesService);
        httpService = module.get<HttpService>(HttpService);
    });

    it('should be defined', () => {
        expect(service).toBeDefined();
    });
});

Die interessanten Zeilen sind die Zeilen 19-30. Dort übergeben wir Objekte, die in unseren Service injiziert werden, und konventionellerweise, werden hier einfache Objekte verwendet, die lediglich Mocks für die gerade verwendeten Methoden bereitstellen.

Vom ConfigService wird nur die Methode get() verwendet, die wir mit einer Lambda-Funktion mocken, die immer die gleiche Adresse zurückgibt. Das funktioniert, weil wir wissen, dass die Methode nur verwendet wird, um die konfigurierte Adresse der REST-Schnittstelle zu ermitteln. Welche Adresse hier zurückgegeben wird, ist gleichgültig, weil ohnehin keine echten Requests geschickt werden.

Vom HttpService wird ebenfalls nur eine Methode benutzt, die aus reinem Zufall auch get() heißt. Das liegt einfach daran, dass wir GET-Requests an die REST-Schnittstelle schicken. Würden wir auch POST-, OPTIONS- oder DELETE-Requests schicken, müssten wir entsprechend auch Mocks für die Methoden post(), options() und delete() definieren.

Während wir für die Methode get() des ConfigService eine Lambdafunktion benutzt haben, verwenden wir jetzt jest.fn() für die Pseudo-Implementierung. Der Grund ist, dass wir im eigentlichen Testfalls sowohl die Implementierung mocken als auch einen "Spy" (Spion) verwenden können.

Wo wir gerade dabei sein, weisen wir der Variablen httpService die Instanz der gemockten Implementierung zu, siehe Zeile 32.

Die Ausführung der Tests mit npm run test sollte nun klappen. Wir testen allerdings nur, dass der Service instanziiert werden kann, und nicht, dass er auch tatsächlich funktioniert.

Erzeugung eines Testfalls

Das ändern wir jetzt und schreiben einen Test für die Methode findByCountry(). Der Test wird auf der selben Ebene wie der Test should be defined angelegt (und der Rest der Datei bleibt unverändert):

it('should be defined', () => {
    expect(service).toBeDefined();
});

it('should return universities of Lalaland', () => {
    const data: University[] = [
        {
            name: 'University of Lalaland',
            alpha_two_code: 'LU',
            'state-province': null,
            domains: [ 'uni.ll' ],
            country: 'Lalaland',
            web_pages: [ 'http://www.uni.ll/' ]
        },
        {
            name: 'International Institute of Advanced Misanthropolgy',
            alpha_two_code: 'LL',
            'state-province': null,
            domains: [ 'iiam.ll' ],
            country: 'Lalaland',
            web_pages: [ 'http://www.iiam.ll/' ]
        },
    ];

    const spy = jest
        .spyOn(httpService, 'get')
        .mockReturnValue(of({
            data,
            headers: {},
            config: {
                url: MOCK_URL,
                headers: undefined,
            },
            status: 200,
            statusText: 'OK',
        })
    );

    const observer: Observer<University[]> = {
        next: (universities: University[]) => {
            expect(universities.length).toBe(2);
            expect(universities[0].name).toBe('University of Lalaland');
        },
        error: (error: any) => expect(error).toBeNull,
        complete: () => {
            expect(spy).toHaveBeenCalledTimes(1);
        },
    };

    service.findByCountry('Lalaland').subscribe(observer);
});

Das ist ein Haufen Quelltext, der aber eigentich nicht schwer zu verstehen ist.

In Zeile 6 definieren wir ein Array mit Testdaten, die unsere Mock-Implementierung des HttpService zurückliefern soll.

In Zeile 25 wird ein Jest-Spy für die Methode get() des HttpServices erzeugt, und gleichzeitig der Rückgabewert gemockt. Der Rückgabewert muss ein Objekt vom Typ AxiosResponse sein, dass die Eigenschaften data, headers, config, status und statusText haben muss.

In Zeile 39 definieren wir einen Observer (Beobachter), der unsere Zusicherungen implementiert, und schließlich wird in Zeile 50 das Observable, das von service.findByCountry() zurückgeliefert wird, aktiviert, indem wie es abonnieren (englisch: subscribe).

Stackoverflow ist voll von Antworten, die ähnlichen Code vorschlagen. Der Code hat aber eigentlich Probleme.

Strict Null Checks

Das erste Problem kann in deinem NestJS-Projekt auftreten, es muss aber nicht. Wenn du den Test mit npm run test ausführst, kommt es eventuell zu folgendem Fehler:

src/universities/universities.service.spec.ts:64:21 - error TS2345: Argument of type 'Observable<{ data: University[]; headers: {}; config: { url: string; headers: undefined; }; status: number; statusText: string; }>' is not assignable to parameter of type 'Observable<AxiosResponse<unknown, any>>'.
  Type '{ data: University[]; headers: {}; config: { url: string; headers: undefined; }; status: number; statusText: string; }' is not assignable to type 'AxiosResponse<unknown, any>'.
    The types of 'config.headers' are incompatible between these types.
      Type 'undefined' is not assignable to type 'AxiosRequestHeaders'.
        Type 'undefined' is not assignable to type 'Partial<RawAxiosHeaders & { "Content-Length": AxiosHeaderValue; "Content-Encoding": AxiosHeaderValue; Accept: AxiosHeaderValue; "User-Agent": AxiosHeaderValue; Authorization: AxiosHeaderValue; } & { ...; }>'.

Offensichtlich ist da irgendetwas undefiniert. Wenn wir den Code im Listing Zeile 27 (eigentlich Zeile 64 in der Datei) anschauen, sehen wir, dass die Eigenschaft config.headers der gemockten AxiosResponse undefiniert ist. Manchmal wird auch {} statt undefined verwendet. Die Fehlermeldung ist aber noch immer sehr ähnlich:

  src/universities/universities.service.spec.ts:64:21 - error TS2345: Argument of type 'Observable<{ data: University[]; headers: {}; config: { url: string; headers: {}; }; status: number; statusText: string; }>' is not assignable to parameter of type 'Observable<AxiosResponse<unknown, any>>'.
    Type '{ data: University[]; headers: {}; config: { url: string; headers: {}; }; status: number; statusText: string; }' is not assignable to type 'AxiosResponse<unknown, any>'.
      The types of 'config.headers' are incompatible between these types.
        Type '{}' is not assignable to type 'AxiosRequestHeaders'.
          Type '{}' is missing the following properties from type 'AxiosHeaders': set, get, has, delete, and 23 more.

Das Problem ist also, dass wir ein leeres Objekt übergeben, TypeScript aber eins sehen möchte, dass die Eigenschaften/Methoden set, get, has, delete und noch 23 weitere definiert hat. Allerdings sind wir an diesem Teil der HTTP-Response überhaupt nicht interessiert, so dass das nach einem Haufen Arbeit für nichts aussieht.

Was ist aber eigentlich der Grund für das Problem? Wir erinnern uns, dass wir die Anwendung mit der Option --strict erzeugt haben, mit welcher der strikte Modus von TypeScript aktiviert wird. Deshalb ist die Compiler-Option strictNullChecks in der Top-Level-Datei tsconfig.json auf true gesetzt.

Man könnte die strictNullChecks einfach auf false setzen, und das Problem würde verschwinden. Der Effekt wäre, dass woimmer ein typisiertes Objekt erwartet wird, immer auch null und undefined verwendet werden kann. Aber eigentlich gibt es ja einen guten Grund, diese Typ-Checks zu aktivieren.

Die bessere Lösung ist deshalb ein Typecast. Dazu importieren wir erst einmal AxiosRequestHeaders:

...
import { AxiosRequestHeaders } from 'axios';
...

Und danach ändern wir die fragliche Zeile in:

...
            config: {
                url: MOCK_URL,
                headers: {} as AxiosResponseHeaders,
            },
...

Der Typecast ist in diesem Fall völlig in Ordnung, weil wir die Header für unseren Testfall nicht benötigen.

Der Code kompiliert jetzt, und die Tests sollten wieder grün sein. Das lässt sich mit npm run test ausprobieren.

Probleme mit Asynchronität

Eine goldene Regel des Unit-Testings ist, dass man nie einem Testfall trauen sollte, den man nicht fehlschlagen sehen hat. Deshalb ändern wir jetzt die Zeile, in der wir testen, ob der Spy exakt einmal aufgerufen wurde, vorübergehend ab:

expect(spy).toHaveBeenCalledTimes(42);

Wenn wir jetzt npm run test noch einmal aufrufen, erwartet uns eine kleine Überraschung:

$ npm run test

> universities@0.0.1 test
> jest

 PASS  src/universities/universities.service.spec.ts
  UniversitiesService
    ✓ should be defined (11 ms)
    ✓ should return universities of Lalaland (5 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.856 s, estimated 3 s
Ran all test suites.
/path/to/universities/node_modules/rxjs/dist/cjs/internal/util/reportUnhandledError.js:13
            throw err;
            ^

JestAssertionError: expect(jest.fn()).toHaveBeenCalledTimes(expected)

Expected number of calls: 42
Received number of calls: 1
...
$ echo $?
1

Zuerst wird eine Erfolgsmeldung ausgegeben, aber dann schlägt der Test plötzlich fehl, und es wird eine Ausnahme/Exception gemeldet.

Offensichtlich wird die Zusicherung erst ausgeführt, nachdem die Testsuite durchgelaufen ist. Wie kann das passieren?

Sieht man sich die Fehlermeldung genauer an, sieht man einen Hinweis daraus, was hier schief läuft:

/path/to/universities/node_modules/rxjs/dist/cjs/internal/util/reportUnhandledError.js:13
            throw err;

Es wird also ein nicht behandelter Fehler gemeldet. "Unhandled", also nicht behandelt, bedeutet hier, dass die Zusicherung fehlgeschlagen ist, nachdem die Testsuite terminiert hat.

Was waren noch einmal die wichtigsten drei Gründe, weshalb das Programmieren mit asynchronen Ereignissen kompliziert ist?

3. Sie sind asynchron.
1. Sie sind asynchron.
2. Sie sind asynchron.

Was kann man in diesem Fall tun? Die Lösung des Problems ist der RxJS-TestScheduler. Wenn du dir die Dokumentation dazu durchliest, wirst du dich fragen, was Murmel-Testen mit dem aktuellen Problem zu tun hat. Und die Frage ist berechtigt, weil der größte Teil der Dokumentation absolut nichts dem Problem, mit dem wir uns herumschlagen, zu tun hat. Der interessanteste Satz findet sich ganz oben auf der Seite:

We can test our asynchronous RxJS code synchronously and deterministically by virtualizing time using the TestScheduler.

Wir können unseren asynchronen RxJS-Code synchron und deterministisch testen, indem wir die Zeit mit dem TestScheduler virtualisieren.

Der TestScheduler wurde ursprünglich geschrieben, um RxJS selbst zu testen, und die meisten in der Dokumentation beschriebenen Features sind wahrscheinlich sehr nützlich, wenn man seine eigenen Operatoren schreibt, oder testen muss, dass gewisse Dinge in einer exakt spezifierten Reihenfolge ablaufen. Aber das ist für uns im Moment nicht wirklich wichtig.

Wir wollen nur einfach unseren Testfall in Ordnung brigen. Wir fügen deshalb in der Datei src/universities/universites.service.spec.ts oben ein import-Statement ein:

import { TestScheduler } from 'rxjs/testing';

Und in der describe()-Funktion auf der obersten Ebene instanziieren wir einen TestScheduler:

let service: UniversitiesService;
let httpService: HttpService;
const testScheduler = new TestScheduler(() => {});

Wenn man echte Murmeltests schreibt, sollte man auch eine echte Zusicherungsfunktion übergeben:

let service: UniversitiesService;
let httpService: HttpService;
const testScheduler = new TestScheduler((actual: any, expected: any) => {
    expect(actual).toBe(expected);
});

Aber weil die Funktion in unserem Fall ohnehin nicht aufgerufen wird, können wir auch einfach einen Dummy übergeben.

Schließlich müssen wir noch den Code, der die Observables verwendet, so ändern, dass er von der Methode run() des TestScheduler aufgerufen wird:

testScheduler.run(() => {
    service.findByCountry('Lalaland').subscribe(observer);
});

Das ist alles. Wenn der Unit-Test fehlschlägt, wird dieser Fehlschlag jetzt ordnungsgemäß gemeldet. Und wenn der Test wiederhergestellt wurde (die 42 muss dazu wieder durch 1 ersetzt werden), ist alles wieder grün.

Die finale Version des Tests findet sich hier: https://github.com/gflohr/test-nestjs-http-service/blob/main/src/universities/universities.service.spec.ts.

Ende-zu-Ende Test

Das Scaffolding-Tool von NestJS erzeugt standardmäßig einen Ende-zu-Ende-Test. Der generierte Test funktioniert allerdings nicht, weil er davon ausgeht, dass die Anwendung eine REST-Schnittstelle liefert.

Wir sollten dagegen die Methode getUniversities() des AppService testen. Das ist allerdings nicht so einfach, weil die eigentliche Logik asynchron, innerhalb der Methode next() des Beobachters abläuft. Aber das können wir ändern, indem wir getUniversities() ein Promise zurückgeben lassen. Die neue Version von src/app.service.ts sieht damit so aus:

import { Injectable } from '@nestjs/common';
import { UniversitiesService } from './universities/universities.service';
import { first } from 'rxjs';

@Injectable()
export class AppService {
    constructor(private universitiesService: UniversitiesService) {}

    getUniversities(country: string): Promise<void> {
        return new Promise((resolve, reject) => {
            this.universitiesService
                .findByCountry(country)
                .pipe(first())
                .subscribe({
                    next: console.log,
                    complete: () => resolve(),
                    error: (err) => reject(err),
                });
        });
    }
}

Die Methode next() des Beobachters wird natürlich nur einmal aufgerufen. Wenn das Observable, das von der Methode findByCountry() zurückgeliefert wird, komplettiert, lösen wir das Promise mit resolve() auf. Und der Vollständigkeit halber rufen wir im Fehlerfall reject() auf. und schließlich, der Vollständigkeit und Konsistenz halber, fügen wir dem Aufruf von getUniversities() in src/main.ts noch ein await zu, obwohl es auch ohne funktioniert.

Jetzt, wo die Methode ein Promise zurückliefert, können wir uns im Test-Code von test/app.e2e-spec.ts async/await zunutze machen. Das funktioniert gut, solange die Tests alle durchlaufen, ganz gleich, ob wir den TestScheduler benutzen oder nicht. Aber alles spinnt wieder herum, wenn ein Test fehlschlägt. Die Fehlschläge können dann gesehen werden, aber es wird keine Testzusammenfassung generiert. Das bedeutet also, dass die Technik, die für die Unit-Tests funktioniert, beim Ende-zu-Ende-Test Ärger macht.

Ich bin sicher, dass du mit diesem Ansatz selber herumspielen kannst, wenn du die finale Version der Mini-Anwendung von https://github.com/gflohr/test-nestjs-http-service klonst und auf den Stand mit dem Promise-basierten Ansatz zurückgehst. Ich selber habe nicht viel Zeit in die Erforschung des Problems gesteckt, weil ich denke, dass das Vermischen von Promises mit Observables im Allgemeinen ein Rezept für Ärger ist. Stattdessen habe ich getUniversities() in src/app.service.ts einmal mehr geändert, und gebe jetzt ein Observable statt eines Promise zurück:

import { Injectable } from '@nestjs/common';
import { UniversitiesService } from './universities/universities.service';
import { Observable, first, tap } from 'rxjs';
import { University } from './universities/university.interface';

@Injectable()
export class AppService {
    constructor(private universitiesService: UniversitiesService) {}

    getUniversities(country: string): Observable<University[]> {
        return this.universitiesService
            .findByCountry(country)
            .pipe(
                tap(console.log)
            );
    }
}

Die Konsolenausgabe ist jetzt ein Seiteneffekt innerhalb des tap()-Operators.

Die Änderung zieht natürlich auch noch eine weitere Änderung im Einstiegspunkt in src/main.ts nach sich:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AppService } from './app.service';

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    const appService = app.get(AppService);
    appService.getUniversities(process.argv[2])
        .subscribe({
            next: () => {},
            error: console.error,
            complete: () => app.close(),
        });
}
bootstrap();

Mit diesen Änderungen kann der Ende-zu-Ende-Test in src/app.e2e-spec.ts jetzt den Test-Code aus der run()-Methode des TestSchedulers aufrufen, siehe weiter unten oder im GitHub-Repository, wie die Applikation jetzt Ende-zu-Ende getestet werden kann.

Wenn du eine Verbesserung beisteuern kannst, hinterlasse einfach einen Kommentare oder erstelle einen Pull-Request auf GitHub. Ich wünsche dir für dein nächstes NestJS-Projekt viel Erfolg!

Und jetzt noch einmal die endgültige Version des Ende-zu-Ende-Tests in src/app.e2e-spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { Observer, of } from 'rxjs';
import { AppModule } from './../src/app.module';
import { AppService } from './../src/app.service';
import { UniversitiesService } from './../src/universities/universities.service';
import { University } from './../src/universities/university.interface';
import { TestScheduler } from 'rxjs/testing';

describe('AppController (e2e)', () => {
    let app: INestApplication;
    let appService: AppService;
    const universitiesService = {
        findByCountry: jest.fn(),
    };
    const testScheduler = new TestScheduler(() => {});

    beforeEach(async () => {
        const moduleFixture: TestingModule = await Test.createTestingModule({
            imports: [AppModule],
        })
        .overrideProvider(UniversitiesService)
        .useValue(universitiesService)
        .compile();

        app = moduleFixture.createNestApplication();
        await app.init();
        appService = moduleFixture.get<AppService>(AppService);
    });

    it('should print the universities of Lalaland', async () => {
        const data: University[] = [
            {
                name: 'University of Lalaland',
                alpha_two_code: 'LU',
                'state-province': null,
                domains: [ 'uni.ll' ],
                country: 'Lalaland',
                web_pages: [ 'http://www.uni.ll/' ]
            },
            {
                name: 'International Institute of Advanced Misanthropolgy',
                alpha_two_code: 'LL',
                'state-province': null,
                domains: [ 'iiam.ll' ],
                country: 'Lalaland',
                web_pages: [ 'http://www.iiam.ll/' ]
            },
        ];

        const findByCountrySpy = jest
            .spyOn(universitiesService, 'findByCountry')
            .mockImplementation(() => {
                return of(data);
            });

        const logSpy = jest
            .spyOn(global.console, 'log')
            .mockImplementation(() => {});

        const observer: Observer<University[]> = {
            next: () => {},
            error: (error: any) => expect(error).toBeNull,
            complete: () => {
                expect(findByCountrySpy).toHaveBeenCalledTimes(1);
                expect(logSpy).toHaveBeenCalledTimes(1);
                expect(logSpy).toHaveBeenCalledWith(data);
            },
        };

        testScheduler.run(() => {
            appService.getUniversities('Lalaland').subscribe(observer);
        });
    });
});
Blog-Kommentare von Disqus
Diese Website verwendet Cookies und ähnliche Technologien, um gewisse Funktionalität zu ermöglichen, die Benutzbarkeit zu erhöhen und Inhalt entsprechend ihren Interessen zu liefern. Über die technisch notwendigen Cookies hinaus können abhängig von ihrem Zweck Analyse- und Marketing-Cookies zum Einsatz kommen. Sie können ihre Zustimmung zu den vorher erwähnten Cookies erklären, indem sie auf "Zustimmen und weiter" klicken. Hier können sie Detaileinstellungen vornehmen oder ihre Zustimmung - auch teilweise - mit Wirkung für die Zukunft zurücknehmen. Für weitere Informationen lesen sie bitte unsere Datenschutzerklärung.