📐 Construire une app en JS
7. Les commandes sont asynchrones

Les commandes sont asynchrones

Avez-vous remarqué une évolution importante dans la dernière version de notre test ?

// Avant : le calendrier est vide
const initialCalendar = Calendar.of(accommodation)
expect(initialCalendar.count()).toBe(0)
 
// 2 locataires différents tentent une réservation simultanée
const bookings = await Promise.all([
    book(accommodation, tenant1, stay),
    book(accommodation, tenant2, stay),
])
 
// 1 seule réservation est acceptée.
const alteredCalendar = Calendar.of(accommodation)
expect(alteredCalendar.count()).toBe(1)

La commande book() est désormais asynchrone. Elle ne retourne plus une réservation, mais une promesse de réservation.

Les commandes sont généralement asynchrones. Car une commande modifie l'état du système, et que cette modification s'opère au travers d'une API ou d'une base de données.

Les reducers

On connait une exception notable, fréquente dans le code frontend : les reducers.

Oui, un reducer est une commande, puisqu'elle modifie l'état du système.

Vous pensiez que tout ce qui était écrit jusqu'à présent ne s'appliquait qu'au code backend NodeJS. Pas du tout. C'est justement cela que l'on adore dans JavaScript. La liberté de transcender les frontières du réseau !

L'état du système peut indifféremment être :

  1. une collection d'entités dans une base de données
  2. des données d'un autre système accessibles au travers d'une API
  3. des objets JavaScript dans le navigateur web (ex : liste des filtres appliqués à une liste)

Si vous utilisez Redux, vous connaissez cette limitation : un reducer doit être synchrone. La raison n'est pas celle qu'on croit. Ce n'est pas que Redux manquerait de transaction : c'est même l'inverse. Un dispatch Redux est une transaction — il s'exécute d'un bloc, sans que rien ne puisse s'intercaler. C'est justement ce qui impose la contrainte. Un reducer doit être une fonction pure : mêmes entrées, même sortie, aucun effet de bord. C'est cette pureté qui rend l'état prévisible et rejouable (c'est elle qui permet le time-travel debugging). Autoriser un await au milieu, ce serait rouvrir une fenêtre pendant laquelle un autre dispatch peut passer. Et retomber exactement sur le problème du chapitre précédent.

Parfois, on se questionne sur la location d'une commande. Et si je localisais le code de la fonction booking() dans le frontend ?

Rien ne l'empêche après tout. Je monte une instance de BaaS (Backend as a service) et j'ai des APIs pour lire et écrire les données.

Et ça fonctionne. Pendant un moment.

Jusqu'au jour où deux vacanciers cliquent en même temps. Chacun exécute la commande dans son propre navigateur, et aucun des deux ne peut voir ce que fait l'autre.

Plus moyen de garantir l'isolation d'une commande de réservation lorsqu'elle s'exécute dans le frontend.

La concurrence d'accès se gère dans le backend. Le frontend, lui, se limite aux commandes synchrones — ce qui ne signifie évidemment pas que rien n'y est asynchrone : le chargement des données l'est. Mais ce sont des requêtes, pas des commandes. Nous y reviendrons.

Les événements

Une commande est aussi un événement.

Parce qu'un utilisateur (ou un webhook, un scheduler…) déclenche la commande à un moment.

Donc on peut écrire un scénario dans notre test. Un scénario est un enchaînement d'événements, comme dans un film.

// Avant : le calendrier est vide
const initialCalendar = Calendar.of(accommodation)
expect(initialCalendar.count()).toBe(0)
 
// Réservation puis annulation
await run(
    Calendar.of(accommodation),
    book(tenant, stay),
    cancel(),
)
 
// Il ne reste aucune réservation active.
const alteredCalendar = Calendar.of(accommodation)
expect(alteredCalendar.countActive()).toBe(0)

Quelle est cette sorcellerie ?

Voici une fonction run() qui enchaîne 3 fonctions, dont 2 commandes book() et cancel().

Au passage, ce test pose déjà une question à l'expert métier : une réservation annulée disparaît-elle du calendrier, ou y reste-t-elle avec un statut cancelled ? La réponse est presque toujours la seconde — on veut garder la trace. D'où le countActive() plutôt qu'un count(), qui compterait aussi les annulations.

Observez l'expressivité du code de notre test. Il s'apparente presque à un langage métier (DSL : Domain Specific Language) qui raconterait :

  • prend le calendrier des réservations de l'hébergement
  • ajoute la réservation du locataire tenant pour le séjour stay
  • puis annule la réservation

Un test raconte une histoire.

Une histoire compréhensible par celui qui porte le projet, même s'il ne sait pas coder.

Voilà le genre de test que l'on veut écrire.