@VinceOPS

Nest : Tests E2E et Effets de bord

wait-for-assertion

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 ?

question-cat

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 à :

  1. À intervalle fixe,

  2. (Ré-)Exécuter l’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 (TypeError, ReferenceError)

  4. “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();
}
cat-stream

Code documenté et testé de waitForAssertion : Gist.


VinceOPS

Retrouvez-moi sur Twitter 🤷
@VinceOPS