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 ?


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 (échec de l'assertion), s'il y en a une
  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 } 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 (échec de l'assertion), s'il y en a une
      catchError((err, o) => 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.