Tester une API REST NestJS / TypeORM
Introduction
Vous êtes un débutant en tests et en NestJS ? Cet article est pour vous !
Mettre en place l’environnement de dev local:
- Installer Node.js LTS :
nvm install --lts
- Installer NestJS CLI :
npm i -g @nestjs/cli
- Créer un nouveau projet NestJS :
nest new back
et choisirnpm
comme package manager - [Optionnel] Pour accélérer les builds, nous pouvons utiliser SWC (Speedy Web Compiler), une plateforme Rust approximativement 20x plus rapide que le compiler TypeScipr par défaut:
npm i --save-dev @swc/cli @swc/core
Then, add the following to your
package.json
scripts:"scripts": { "start:dev": "npm run start -- -b swc --watch" }
L’option --watch
permet de faire du hot-reload : Modifier un fichier relancera les test.
Structure du projet
Voici un bref apperçu des fichiers principaux :
app.controller.ts
: Un controller basique avec une seule routeapp.controller.spec.ts
: Les tests unitaires du controllerapp.module.ts
: Le module racine de l’applicationapp.service.ts
: Un service basique avec une seule méthodemain.ts
: Le point d’entrée de l’application qui utilise la core functionNestFactory
pour créer l’instance de l’application Nest
Démarrage et exécution des tests
Un nouveau projet NestJS embarque Jest comme librairie de test par défaut, vous n’avez pas besoin d’installer quoi que ce soit d’autre pour commencer, sauf cas d’usage spécifique.
Avant de toucher à quoi que ce soit, nous pouvons lancer le test auto-généré afin de nous assurer que tout fonctionne correctement :
npm run test
Vous devriez obtenir le résultat suivant, indiquant que le test a été passé avec succès :
back@0.0.1 test
> jest
PASS src/app.controller.spec.ts
AppController
root
✓ should return "Hello World!" (6 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.975 s
En parallèle, vous pouvez égallement lancer l’application avec la commande :
npm run start:dev
En ouvrant l’url http://localhost:3000/
sur votre navigateur, vous devriez vous le message Hello World!
s’afficher.
Cas simple (pas de mocking)
Nous allons commencer par créer un service avec des fonctionalités CRUD basiques pour gérer une exposition dans un musé puis écrire quelques tests pour ceux-ci.
Créons d’abord un module nommé exhibitions
:
nest g module exhibitions
Puis ajoutons un service exhibitions:
nest g service exhibitions
Ces deux commandes vont créer un dossier nommé exhibitions
contenant 3 fichiers :
src/
|- exhibitions/
|- exhibitions.modules.ts
|- exhibitions.service.spec.ts
|- exhibitions.service.ts
Assurez vous de lancer les tests en local avec la commande suivante :
npm run test:watch
Ainsi, les changements que vous ferez relancerons les tests.
Anatomie d’un test
Ouvez le ficher exhibitions.service.spec.ts
. Il devrait ressembler à ceci:
import { Test, TestingModule } from '@nestjs/testing';
import { ExhibitionsService } from './exhibitions.service';
describe('ExhibitionsService', () => {
let exhibitionsService: ExhibitionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ExhibitionsService],
}).compile();
exhibitionsService = module.get<ExhibitionsService>(ExhibitionsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
Le fichier commence par un bloc describe
:
describe('ExhibitionsService', () => {
// ...
}
On y regroupe les tets liés au même objet, ici, le ExhibitionsService
.
Ensuite, nous avons le hook beforeEach
:
describe('ExhibitionsService', () => {
let exhibitionsService: ExhibitionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ExhibitionsService],
}).compile();
exhibitionsService = module.get<ExhibitionsService>(ExhibitionsService);
});
// ...
}
Un hook beforeEach
permet de prendre en charge tout ce qui doit être effectué avant l’exécution de chaque test.
Dans cet exemple, il utilise la class native NestJS Test
pour créer un environnement d’exécution NestJS isolé afin que vous puissiez disposer de tous les comportements de NestJS comme l’injection de dépendance.
Cet environnement d’exécution est limité à ce que vous définissez quand vous utilisez la classe Test
. Ici, juste le ExhibitionsService
. Nous donnant ainsi accès à toutes ses méthodes.
Enfin, nous avons un bloc it
, représentant un test appellé should be defined
:
describe('ExhibitionsService', () => {
// ...
it('should be defined', () => {
expect(service).toBeDefined();
});
});
Il contient une assertion utilisant la fonction expect
de Jest. Cette fonction aura toujours une autre méthode chainée qui sert à vérifier que la condition est remplie. Dans cet exemple il s’agit de la méthode toBeDefined()
. Ce test s’assure donc que ExhibitionsService
est définit.
Tester de nouvelles méthodes (sans mocking)
Ajoutons de nouvelles méthides dans exhibitions.service.ts
:
import { Injectable } from '@nestjs/common';
type Exhibition = {
id: number
name: string
}
@Injectable()
export class ExhibitionsService {
exhibitions: object[] = [];
createExhibition(exhibition: Exhibition) {
this.exhibitions.push(exhibition);
return exhibition;
}
getExhibitions() {
return this.exhibitions;
}
updateExhibition(id: number, exhibition: Exhibition) {
const exhibitionToUpdate = this.exhibitions[id];
if (!exhibitionToUpdate) {
throw new Error(`This Exhibition does not exists`);
}
this.exhibitions[id] = exhibition;
}
deleteExhibition(id: number) {
const exhibitionToDelete = this.exhibitions[id];
if (!exhibitionToDelete) {
throw new Error(`This Exhibition does not exists`);
}
const deleteExhibition = this.exhibitions.splice(id, 1);
return deleteExhibition;
}
}
Pour garder cet exemple simple, vous remarquerez que nous gérons le state en mémoire. Nous verrons plus loin comment le gérer via une base de donnée et ce que cela implique pour nos tests.
Considérons le fait de tester createExhibition()
. Pour savoir quoi tester, Nous devons nous poser les questions suivantes :
- Quel est le comportement attendu ?
- Y-a-t-il plusieurs résultats possibles ?
En ce qui concerne createExhibition()
, nous pouvons répondre :
- Quand une exposition valide est créée, elle est ajoutée au
state
- Quand une exposition valide est créée, la méthode retourne l’exposition
Dans le fichier exhibitions.service.spec.ts
, ajoutons notre premier test :
import { Test, TestingModule } from '@nestjs/testing';
import { ExhibitionsService } from './exhibitions.service';
describe('ExhibitionsService', () => {
let exhibitionsService: ExhibitionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ExhibitionsService],
}).compile();
exhibitionsService = module.get<ExhibitionsService>(ExhibitionsService);
});
describe('createExhibition', () => {
it('should create an exhibition', () => {
// Arrange
exhibitionsService.exhibitions = [];
const payload = {
id: 1,
name: 'Exposition 1',
}
// Act
const exhibition = exhibitionsService.createExhibition(payload);
// Assert
expect(exhibition).toBe(payload);
expect(exhibitionsService.exhibitions).toHaveLength(1);
});
})
});
Décomposons les étapes :
- Arrange : Nous réalisons un peu de mise en place avant le test en définissant le payload dans une variable
- Act : Nous appellons la méthode
createExhibition
- Assert : Nous définissons le retour attendu
Comme exercice pratique, vous pouvez écire les tests des méthodes suivantes avant de passer à la prochaine étape.
Tester avec des mocks
Dans l’exemple précédent, nous n’avons utilisé aucune dépendance (rien n’a été passé dans le constructeur), ce qui simplifit les tests mais n’est pas très réaliste. Nous allons maintenant ajouter des dépendances à nos services et controlleurs.
Pour l’exemple, nous allons ajouter deux services comme dépendance à ExhibitionsService
: HttpService
et ConfigService
.
On commance par installer les modules en question avec la commande suivante :
npm i @nestjs/axios --save
Afin d’utiliser le module HTTP dans notre service, nous devons le rendre disponible à l’injection de dépendence en l’important dans le module, exhibitions.module.ts
:
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { ExhibitionsService } from './exhibitions.service';
@Module({
imports: [HttpModule],
providers: [ExhibitionsService]
})
export class ExhibitionsModule {}
Nous pouvons maintenant utiliser le HttpModule
dans le service en le passant au constructeur.
Nous ajoutons égallement une méthode getEventNameById
qui retourne une liste d’événements dans une ville.
// exhibitions.service.ts
import {
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
type Exhibition = {
id: number
name: string
}
@Injectable()
export class ExhibitionsService {
constructor(
private readonly httpService: HttpService,
) {}
// ...
async getEventNameById(uuid: number) {
const { data } = await this.httpService.axiosRef({
url: `/api/explore/v2.1/catalog/datasets/evenements-publics-openagenda/records?limit=20&refine=uid%3A${uuid}`,
method: 'GET',
});
if (!data || !data.results || !data.results[0].originagenda_title) {
throw new InternalServerErrorException();
}
return data.results[0];
}
}
Ajouter ces dépendances crée quelques difficultés pour nos tests unitaires. Leur périmètre est maintenant étendu au comportement de ces dépendances égallement. Celles-ci peuvent aussi être asynchrones (comme utiliser le service HttpService
pour faire une requête HTTP), ce qui peut affecter la performance de nos tests unitaites.
La solution est d’utiliser des “test doubles”. Dans nos tests unitaire, nous pouvons remplacer des dépendences spécifiques par des “doublures” de façon à ce qu’à l’execution du test il utilise la doublure au lieu de la vrais dépendence.
Voici tout d’abord quelques tests unitaires que nous allons ajouter à la médhode getEventNameById()
:
- Un id valide retourne les détails de l’événement
- Si la réponse de l’API n’est pas celle qui était attendue, alors on retourne une
InternalServerErrorException
Avant d’aller plus loins, si vous jouez vos tests exhistants, vous deviez voir une erreur du type Nest can't resolve dependencies of the ExhibitionsService
:
FAIL src/exhibitions/exhibitions.service.spec.ts
● ExhibitionsService › createExhibition › should create an exhibition
Nest can't resolve dependencies of the ExhibitionsService (?). Please make sure that the argument HttpService at index [0] is available in the RootTestModule context.
Potential solutions:
- Is RootTestModule a valid NestJS module?
- If HttpService is a provider, is it part of the current RootTestModule?
- If HttpService is exported from a separate @Module, is that module imported within RootTestModule?
@Module({
imports: [ /* the Module containing HttpService */ ]
})
Vous voyez cette erreur car Jest tente de jouer les tests mais il manque à l’instance de test une dépendance, le HttpService
.
Nous devons donc fournir cette dépendance au module de test de façon à ce que lorsque les tests sont joués, la dépendance soit passée au module de test et puisse être utilisé au sein de ExhibitionsService
.
C’est là l’un des plus grand bénéfice de l’injection de dépendance. Vous pouvez facilement échanger les dépendances avec une alternative plus appropriée dans le contexte d’un test. Par exemple, nous pourriez passer le vrais service HttpService qui effectura une vrais requête HTTP ou nous pourrions passer un “test double” de HttpService
qui prétendra le faire.
A présent, nous allons commencer par implémenter les tests en utilisant la vrais dépendance, puis nous mettrons à jour pour mocker les requêtes HTTP.
Pour corriger l’erreur de dépendance manquante dans exhibitions.service.spec.ts
, nous devons juste ajouter HttpModule
comme import au module de test :
import { HttpModule } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { ExhibitionsService } from './exhibitions.service';
describe('ExhibitionsService', () => {
let exhibitionsService: ExhibitionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [HttpModule],
providers: [ExhibitionsService],
}).compile();
exhibitionsService = module.get<ExhibitionsService>(ExhibitionsService);
});
// ...
}
A présent vous ne devriez plus voir d’erreur dans votre terminal !
Ajoutons maintenant nos tests pour la méthode getEventNameById()
:
import { HttpModule } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { ExhibitionsService } from './exhibitions.service';
import { InternalServerErrorException } from '@nestjs/common';
describe('ExhibitionsService', () => {
let exhibitionsService: ExhibitionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [HttpModule],
providers: [ExhibitionsService],
}).compile();
exhibitionsService = module.get<ExhibitionsService>(ExhibitionsService);
});
// ..
describe('getEventNameById', () => {
it('valid event uuid to return event name', async () => {
const eventName = exhibitionsService.getEventNameById(53488830);
await expect(eventName).resolves.toBe('Nuit européenne des musées 2023 : Île-de-France');
});
it('invalid event uuid to throw error', async () => {
const eventName = exhibitionsService.getEventNameById(42);
await expect(eventName).rejects.toBeInstanceOf(InternalServerErrorException);
});
});
});
Dans ces tests, puisque getEventNameById()
est une méthode asynchrone, nous utilisons les méthodes resolves
et rejects
de Jest pour les gérer.
Nos tests devraient maintenant passer correctement.
Puisque nous n’avons pas mocké la dépendance, la méthode getEventNameById()
effectue de vrais appels HTTP, ce qui pose les problèmes suivants (qui s’appliquent égallement si on consommait une base de donnée à la place d’une API) :
- Faire des appels réseau dans vos tests peuvent les rendre plus lents
- Vous n’avez pas la garantie que votre API vous retournera toujours le même résultat (vos tests sont donc dépendant d’une source de vérité tiers)
- Les tests devraient se concentrer sur le comportement de la méthode, peut importe les dépendances
A présent, voyons comment nous pouvons mocker notre service HTTP.
Mocker le service HttpService
Pour implémenter un mock dans NestJS, je recommande d’utiliser le package @golelup/ts-test.
npm install @golevelup/ts-jest
Utiliser la fonction createMock
nous donnera toutes les propriétés et sous-propriétés de l’objet que nous souhaitons mocker. L’alternative serait de définir manuellement toutes les propriétés nécessaires dans un objet personalisé, ce qui serait très répétitif et difficil à maintenir.
Combiné aux capacité de mock de Jest, cette librairie est donc très puissante.
Notre objectif est d’arrêter d’utiliser le service HTTP pour faire nos requêtes à l’API et d’implémenter une version mocké à la place.
Au lieu d’utiliser le HttPModule
comme import dans notre module de test, nous allons le replacer directement par la fonction utilitaire createMock()
:
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { ExhibitionsService } from './exhibitions.service';
import { InternalServerErrorException } from '@nestjs/common';
describe('ExhibitionsService', () => {
let exhibitionsService: ExhibitionsService;
let httpService: DeepMocked<HttpService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ExhibitionsService,
{
provide: HttpService,
useValue: createMock<HttpService>(),
}
]
}).compile();
exhibitionsService = module.get<ExhibitionsService>(ExhibitionsService);
httpService = module.get(HttpService);
});
// ...
});
Le type DeepMocked<HttpService>
permet de s’assurer que la variable httpService
a toutes les propriétés et sous-propriétés disponibles pour l’auto-complétion de notre IDE.
Quel impact ces changements ont-ils sur nos tests ?
Par exemple, notre test valid event uuid to return event name
nous avions écrit ceci :
it('valid event uuid to return event name', async () => {
const eventName = exhibitionsService.getEventNameById(53488830);
await expect(eventName).resolves.toBe('Nuit européenne des musées 2023 : Île-de-France');
});
Nous appellons la méthode getEventNameById
, qui utilise le HttpService
pour faire un appel à l’API opendatasoft.
Nous venons de remplacer ce HttpService
par un dummy que nous pouvons controler dans nos tests.
A présent, dans nos tests nous devrions avoir l’erreur suivante :
FAIL src/exhibitions/exhibitions.service.spec.ts
● ExhibitionsService › getEventNameById › valid event uuid to return event name
expect(received).resolves.toBe()
Received promise rejected instead of resolved
Rejected to value: [InternalServerErrorException: Internal Server Error]
46 | const eventName = exhibitionsService.getEventNameById(53488830);
47 |
> 48 | await expect(eventName).resolves.toBe('Nuit européenne des musées 2023 : Île-de-France');
| ^
49 | });
50 |
51 | it('invalid event uuid to throw error', async () => {
at expect (../node_modules/expect/build/index.js:113:15)
at Object.<anonymous> (exhibitions/exhibitions.service.spec.ts:48:13)
Test Suites: 1 failed, 1 passed, 2 total
Tests: 1 failed, 3 passed, 4 total
Snapshots: 0 total
Time: 1.263 s, estimated 2 s
C’est parceque notre dummy function ne fait rien !
Dans le test, mettons à jour la dummy function pour “mocker” la réponse de l’API :
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { ExhibitionsService } from './exhibitions.service';
import { InternalServerErrorException } from '@nestjs/common';
describe('ExhibitionsService', () => {
let exhibitionsService: ExhibitionsService;
let httpService: DeepMocked<HttpService>;
beforeEach(async () => {
// ...
});
describe('getEventNameById', () => {
it('valid event uuid to return event name', async () => {
const result = 'Nuit européenne des musées 2023 : Île-de-France';
httpService.axiosRef.mockResolvedValueOnce({
data: {
results: [{ originagenda_title: result }]
},
headers: {},
config: { url: '' },
status: 200,
statusText: '',
});
const eventName = exhibitionsService.getEventNameById(53488830);
await expect(eventName).resolves.toBe(result);
});
// ...
});
});
Nous implémentons un mock pour dire au test : “Quand tu joue ce test, assure toi que httpService
returne une réponse Axios avec cet objet spécifique dans la propriété data
.
Nos tests devraient maintenant passer.
Nous devons aussi mettre à jour le test invalid event uuid to throw error
puisqu’il utilise aussi la méthode getEventNameById
et donc HttpService
:
// ...
it('invalid event uuid to throw error', async () => {
httpService.axiosRef.mockResolvedValueOnce({
data: {
results: [{ }]
},
headers: {},
config: { url: '' },
status: 200,
statusText: '',
});
const eventName = exhibitionsService.getEventNameById(42);
await expect(eventName).rejects.toBeInstanceOf(InternalServerErrorException);
});
// ..
Une dernière optimisation que nous pouvons faire dans notre ExhibitionService
est de