Fallstricke beim Testen von NestJS-Modulen mit HttpService
In diesem Blogbeitrag werden wir uns mit den Herausforderungen befassen, denen Entwickler/innen gegenüberstehen, wenn sie NestJS-Module unter Verwendung von HttpService
aus @nestjs/axios
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.
Table Of Contents
src/main.ts
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 HttpService
s 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?
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 TestScheduler
s 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);
});
});
});
Leave a comment