I
n this blog post, we will delve into the challenges developers face when testing NestJS modules utilizing
HttpService
from
@nestjs/axios
and explore the crucial role played by the RxJS
TestScheduler
in overcoming these hurdles. Additionally, we will unravel the intricacies of injecting
HttpService
into the test module, providing you with a comprehensive guide to fortify your testing strategies and ensure the robustness of your NestJS applications.
We will use the free
Universities
API as an example for our NestJS application. The response payload to the
GET
request
http://universities.hipolabs.com/search?country=Luxembourg
looks like this:
[
"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/"
]
We will now write a client for that API using NestJS . The source code for the application is available at https://github.com/gflohr/test-nestjs-http-service but you can also follow the step-by-step instructions below for creating that mini application yourself.
At the time of this writing, the application is using NestJS 10.3.0 and RxJS version 7.8.1 but the instructions should also be valid for other versions.
If you are an experienced Nest developer, you will know how to implement such a client. In that case you may want to jump directly to the section Writing Tests .
$ 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
CREATE universities/.eslintrc.js (663 bytes)
✔ Installation in progress... ☕
🚀 Successfully created project universities
We pass the option
--strict
to
nest new
to enable script mode in TypeScript.
$ cd universities
$ npm run start
Pointing your browser to
http://localhost:3000
should now show a hello world webpage. But we are targeting a client, not a server, and terminate the application with
CTRL-C
.
Next we generate the heart of our application, the
universities
module and inside of it the
universities
service:
$ 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)
We have some additional dependencies that have to be installed first:
$ npm install --save @nestjs/axios @nestjs/config
The next step is to create a stub configuration.
Create a directory
src/config
and inside a new file
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,
});
Create a new file
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[];
}
This is the data type sent back by the university API.
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 {}
Both
ConfigModule
and
HttpModule
are added to the
imports
array.
We now have to modify the universities service
src/universities/universities.service.ts
:
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));
}
The code should be self-explanatory. If not, please read the
documenation for the
NestJS Http module
. A real application should also implement some kind of error handling but this is left as an exercise to the reader.
$ rm src/app.controller.ts src/app.controller.spec.ts
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 {}
We also have to change the app service
src/app.service.ts
:
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);
}
src/main.ts
The last step is to adapt the entry file of the application
src/main.ts
to reflect the changes made:
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();
It is time to try out the new application:
$ 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/' ]
$ npm run test
npm run test
> [email protected] test
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.
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
this.baseUrl = configService.get('universities.baseUrl') as string;
}
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();
});
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);
});
That is a lot of code but it is actually not so hard to understand.
Stackoverflow is full of responses that contain similar code but it actually has issues.
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; } & { ...; }>'.
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.
...
import { AxiosRequestHeaders } from 'axios';
...
And then change the offending line to this:
...
config: {
url: MOCK_URL,
headers: {} as AxiosResponseHeaders,
...
The typecast is okay here because we do not need the headers for our test at all.
The code now compiles, and the test should be green again. Try it out with
npm run test
.
expect(spy).toHaveBeenCalledTimes(42);
Run
npm run test
again, and you can see a little surprise.
$ npm run test
> [email protected] test
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 $?
At first, success is reported but then the test fails and an exception is reported.
Obviously the assertion is executed after the test suite completed. How can that happen?
If you look at the error message closely again, you can see a hint what has gone wrong:
/path/to/universities/node_modules/rxjs/dist/cjs/internal/util/reportUnhandledError.js:13
throw err;
Know the top 3 reasons why programming with asynchronous events is complicated?
1) They are asynchronous.
2) They are asynchronous.
What can be done about that? The solution to the problem is the
RxJS
TestScheduler
. When you read its documentation you may wonder what marble testing has to do with the current problem. And you are right. Most of the documentation is really irrelevant for the problem we are facing. The most interesting sentence is at the top of the page:
We can test our asynchronous RxJS code synchronously and deterministically by virtualizing time using the TestScheduler.
The
TestScheduler
was initially written for unit testing RxJS itself and most of the features described in its documentation are probably mostly useful when you want to test your own operators or have to test that certain things happen in a specific order. All this is not really important for us.
Let's just fix our test case. We start by adding an import at the top of the file
src/universities/universites.service.spec.ts
:
import { TestScheduler } from 'rxjs/testing';
Inside our top-level
describe()
function, we instantiate a
TestScheduler
:
let service: UniversitiesService;
let httpService: HttpService;
const testScheduler = new TestScheduler(() => {});
If you were to do marble testing, you should pass a real assertion function:
let service: UniversitiesService;
let httpService: HttpService;
const testScheduler = new TestScheduler((actual: any, expected: any) => {
expect(actual).toBe(expected);
});
But since the function will no be called in our use case you can just pass a dummy instead.
Finally, change the code that is running the observable to be invoked from the
run()
method of the
TestScheduler
:
testScheduler.run(() => {
service.findByCountry('Lalaland').subscribe(observer);
});
That is all. If the unit test fails, failure is now reported properly. If you fix the test case again (replace 42 with 1 again), everything is green again.
You can find the final version of the test here: https://github.com/gflohr/test-nestjs-http-service/blob/main/src/universities/universities.service.spec.ts .
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),
}
I am sure that you can play with that approach yourself by just cloning the final version of the mini app from
https://github.com/gflohr/test-nestjs-http-service
and reverting to the
Promise
based approach. I did not investigate too much into the issues because I think that mixing promises with observables is in general a recipe for trouble. I therefore modified
getUniversities()
in
src/app.service.ts
once more to return an
Observable
instead of a
Promise
:
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)
}
The console logging is now done as a side effect inside the
tap()
operator.
That change required, of course, a modification of the entry file
src/main.ts
:
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();
With these changes in place, the end-to-end test in
src/app.e2e-spec.ts
could now invoke the test code from the
run()
method of the
TestScheduler
. See below or in the
GitHub repository
for a way to test the application end-to-end.
Feel free to leave a comment or a pull request on GitHub if you can contribute improvements. I wish you succes with your next NestJS project!
Following is the final version of the end-to-end test 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);
About Me
I am a developer with rich experience in C, C++, Perl,
JavaScript/TypeScript, R, Assembler, Java, Ruby, Python, and other
languages galore. My focus is on security topics especially for web
applications, internationalisation (i18n), accessibility (a11y),
and electronic invoices. Apart from that I am interested in everything
around food, running, gardening, DIY, speedcubing, ...
Navigation
Start
Projects
About Me
Legal Disclosure
Privacy Policy
Cookie Settings
Categories
Recent Posts
This website uses cookies and similar technologies to provide certain features, enhance
the user experience and deliver content that is relevant to your interests.
Depending on their purpose, analysis and marketing cookies may be used in
addition to technically necessary cookies. By clicking on "Agree and
continue", you declare your consent to the use of the aforementioned cookies.
you can make detailed settings or revoke your consent (in part if necessary)
with effect for the future. For further information, please refer to our
Privacy Policy.