Nest : Tests E2E et Effets de bord
Dans le monde merveilleux des tests d’intégration et E2E (end-to-end, de “bout en bout” 🥐), il est fréquent de vérifier le bon fonctionnement d’un service tiers. Cependant, dans un scénario complet, les interactions avec ledit service sont parfois faites de manière asynchrone car non-critiques ou non-bloquantes. Alors comment les tester ?
Notre application (API REST) est développée avec Nest; Jest sert de framework et lanceur de tests (unitaires, d’intégration, E2E), supertest est utilisé pour exécuter des assertions HTTP.
Suite à un appel HTTP, on souhaite vérifier la mise à jour d’un document dans une base de données Elasticsearch, sachant que l’exécution de celle-ci est asynchrone : c’est un effet de bord. Le client reçoit une réponse avant que la mise à jour ne soit effective.
🕺 Les exemples de code ci-après sont volontairement restreints à une forme simple et concise, facilitant leur lecture et leur compréhension
Test first
Une première approche naïve (et invalide 🤷) consiste à faire une assertion immédiate, considérant qu’Elasticsearch a déjà été mis à jour :
it('should update the value in Elasticsearch', async () => {
// appel HTTP
await request(server)
.put(endpointURL) // PUT @ "/users/:userId"
.send(input) // { firstName: 'Mike' }
.expect(HttpStatus.NO_CONTENT);
// récupération de la donnée & assertion
const { document } = await elasticsearchService.get(UserIndex, userId);
return expect(document.firstName).toBe(updatedUser.firstName);
});
La majorité des exécutions de ce test se soldera par un échec puisque la réponse HTTP arrivera avant qu’Elastic n’ait reçu l’ordre de (ou n’ait pu) se mettre à jour. Cependant, l’essentiel y est. Il ne reste qu’à attendre le succès de notre assertion en la ré-exécutant jusqu’à ce qu’elle passe, ou que le test expire (timeout).
Si le scénario n’est toujours pas clair, voici une ébauche du contrôleur gérant cette route de mise à jour :
Contrôleur
@Put('/users/:userId')
async updateUser(
@Param('userId') userId: string,
@Body(ValidationPipe) user: UserInputDto,
) {
await this.usersService.update(userId, user);
this.elasticsearchService.update(UserIndex, { userId, ...user });
}
Contrairement à l’exécution de usersService.update
, le contrôleur n’attend pas celle de elasticsearchService.update
: il envoie immédiatement une réponse.
⚠ Dans une application réelle, le déclenchement de la synchronisation d’Elasticsearch devrait être effectuée dans/par
usersService
, avec (par exemple) un gestionnaire d’événements. Pas dans le code du contrôleur 😏.
On souhaite donc écrire un flux consistant à :
- À intervalle fixe,
- (Ré-)Exécuter l’assertion,
- Ignorer l’erreur lancée s’il y en a une (échec de l’assertion), sauf runtime errors relevant d’un problème de code (
TypeError
,ReferenceError
) - “Compléter” si une valeur est émise 🎉, ou timeout si le temps imparti est écoulé 😿
⏲ Dans Jest, le délai d’expiration d’un test est de 5 secondes par défaut. Il est possible de le modifier en utilisant jest.setTimeout.
Implémentation
Le test est modifié pour confier l’exécution de l’assertion à une fonction waitForAssertion
, responsable dudit flux :
it('should asynchronously update the value in Elasticsearch', async () => {
await request(server)
.put(endpointURL)
.send(input)
.expect(HttpStatus.NO_CONTENT);
await waitForAssertion(async () => {
const { document } = await elasticsearchService.get(UserIndex, userId);
return expect(document.firstName).toBe(updatedUser.firstName);
});
});
Et le code de waitForAssertion
, écrit avec RxJS (qui fait partie des dépendances de Nest) :
Il existe bien sûr d’autres moyens d’atteindre le même objectif, avec ou sans RxJs.
import { from, interval, throwError } from 'rxjs';
import { catchError, first, switchMap, timeout } from 'rxjs/operators';
/**
* (Doc. et tests disponibles dans le Gist en fin d'article 📚)
*/
export function waitForAssertion(
assertion: () => any,
timeoutDelay: number = 1000,
intervalDelay: number = 100
) {
// 1. À intervalle fixe,
return interval(intervalDelay)
.pipe(
// 2. (Ré-)Exécuter l'assertion,
switchMap(() => from(Promise.resolve(assertion()))),
// 3. Ignorer l'erreur lancée s'il y en a une (échec de l'assertion), sauf runtime errors relevant d'un problème de code
catchError((err, o) => (err instanceof ReferenceError || err instanceof TypeError ? throwError(err) : o)),
// 4.1. "Compléter" si une valeur est émise 🎉,
first(),
// 4.2. ou timeout si le temps imparti est écoulé 😿
timeout(timeoutDelay),
)
.toPromise();
}
Code documenté et testé de waitForAssertion
: Gist.
Retrouvez-moi sur Twitter :
@VinceOPS