Testing Library : Parcours utilisateur en test d’intégration

Rémy Codron
Just-Tech-IT

--

Pour tester une application de bout en bout, il est coutume d’utiliser des frameworks tels que Cypress ou Playwright. Cependant la mise en place de ces solutions peut devenir longue et coûteuse.

Une solution alternative pour tester des parcours utilisateur en test d’intégration est l’utilisation de Testing Library.

Une application simple comme exemple

Prenons comme exemple une application basique de site de vente en ligne qui permet d’acheter des fruits :

les sources sont disponibles sur : https://github.com/buddyvegas/shop-example

Pour cet exemple les appels vers une API fictive ont été mockés grâce à MSW.

Le choix de cette librairie n’est pas anodin et va nous permettre de faciliter l’écriture de nos tests.

Des tests unitaires par composant

Il est courant de tester unitairement chaque composant d’une application. En se penchant sur l’architecture du projet d’exemple on pourrait tester :

  • la liste des produits dans le catalogue (Catalog)
  • la modification de la quantité (AddToCart)
  • l’ajout au panier (AddToCart)
  • l’affichage du panier (Cart)
  • l’appel à l’api pour récupérer les produits (product.service.ts)
  • l’appel à l’api pour enregistrer une commande (purchase.service.ts)
  • etc…

Ces tests permettraient de valider l’affichage de chaque composant et des appels à l’api mais ne rendraient pas compte de la cohérence entre eux.

Les parcours utilisateur

Il serait alors pertinent de couvrir des parcours utilisateur de notre application :

  • le client ajoute 2 pommes et 3 kiwis à son panier
  • le client vide son panier
  • le client passe une commande de 2 pommes et 3 kiwis avec succès
  • une erreur se produit au moment de la validation de la commande

Les différentes étapes pour écrire un parcours utilisateur

Dans la suite de l’article nous allons nous pencher sur le parcours “le client passe une commande de 2 pommes et 3 kiwis avec succès” et voir les étapes qui permettent de faciliter l’écriture et la lecture :

Arrange

  • Builders : permettent de rendre le plus lisible possible la construction des objets reçus via les appels externes
  • Mocker les appels vers l’API grâce à MSW

Act

  • Render : Afficher l’application
  • Actions : permettent de simuler les interactions utilisateurs propre à notre application (par exemple : ajouter tel produit au panier, valider la commande, …)

Assert

  • Vérifier que le résultat affiché est bien celui attendu
Les différentes étapes de l’écriture d’un parcours en test d’intégration

Builders

Les objets retournés par l’API peuvent avoir une structure plus ou moins complexe. Selon les besoins des tests on peut se retrouver avec une liste interminable d’objets à initialiser.

L’utilisation de Builders pour nos objets devient indispensable.

  • Pour notre objet Product retourné par l’api :
type ProductApiType = {
id: string;
name: string;
image: string;
};
  • Le Builder pour les tests sera :
class ProductApiBuilder {
private product = {} as ProductApiType;
constructor() {
this.product.id = v4(); // génère un guid aléatoire
this.product.image = 'http://fakeimage/product.jpg';
}
pomme(): ProductApiBuilder {
this.product.name = "Pomme";
return this;
}
build(): ProductApiType {
return this.product;
}
}
  • On pourra générer un objet dans notre test :
const pomme = new ProductApiBuilder().pomme().build();

L’exemple ici est pour un cas très simple. Même avec des objets complexes on facilite la lecture et évite de polluer notre test avec des initialisations à rallonge. En général, la plupart des tests vont utiliser les mêmes objets comme support, à quelques petites variantes près.

Mocker les appels vers l’api

Sur un parcours utilisateur, aucun élément interne à notre application n’a besoin d’être mocké. Le context est vraiment appelé, notre mécanique d’appel à l’API est vraiment utilisée. Seuls les appels vers l’extérieur seront mockés.

Une fois nos tests configurés on pourra mocker ces 2 appels nécessaires à notre parcours :

server.use(
rest.get("http://fakeapi/products", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([pomme, kiwi]));
}),
rest.post(http://fakeapi/purchase, (req, res, ctx) => {
return res(ctx.status(200));
}),
);
  • GET /products : on retourne une pomme et un kiwi comme produits
  • POST /purchase : la validation de commande renverra une 200 OK

Actions

Une fois notre App rendue il faut maintenant écrire le parcours que le client effectuera.

NB : Dans notre exemple, un appel asynchrone étant fait au chargement de notre application. Il sera nécessaire d’implémenter une méthode qui attend que tous les éléments soient chargés et que la page ait fini de s’afficher.

Testing Library permet de naviguer dans notre DOM et d’effectuer les interactions nécessaires. Cependant à l’image de la création des objets pour les retours de notre API, le code peut vite devenir complexe et le test perdre en simplicité de lecture.

Prenons le cas de l’ajout de 2 pommes et 3 kiwis dans le panier. Sans nos Actions le code se présenterait de la façon suivante :

test('le client commande 2 pommes et 3 kiwis', async () => {
// ... builder + mock api
render(<App />);
await catalogIsReady(catalog);
// Ajout de 2 pommes
const pommeElement = screen.getByTitle("Pomme");
const addPomme = within(pommeElement).getByLabelText('add');
act(() => {
fireEvent.click(addPomme);
});
act(() => {
fireEvent.click(addPomme);
});
act(() => {
fireEvent.click(within(pommeElement)
.getByLabelText('addToCart'));
});
// Ajout de 3 kiwis
const kiwiElement = screen.getByTitle("Kiwi");
const addKiwi = within(kiwiElement).getByLabelText('add');
act(() => {
fireEvent.click(addKiwi);
});
act(() => {
fireEvent.click(addKiwi);
});
act(() => {
fireEvent.click(addKiwi);
});
act(() => {
fireEvent.click(within(kiwiElement)
.getByLabelText('addToCart'));
});
});

Vu la simplicité de l’exemple (ajout de 2 pommes et 3 kiwis au panier) autant dire que les tests deviendront rapidement illisibles pour des cas plus complexes. La maintenance et l’ajout de test seraient très vite laborieux.

Pour simplifier une fois de plus notre lecture des tests, chaque action spécifique que l’utilisateur effectuera sur notre application pourra être généralisée dans des méthodes à part.

Il est très important de noter que chaque application aura ses propres Actions. Il ne faut pas chercher à créer des actions trop génériques (Testing Library le fait déjà très bien).

Pour l’ajout au panier on peut donc se créer une action AddToCart :

function addToCart(product: ProductApiType, quantity: number): void {
const element = screen.getByTitle(product.name);
const addProduct = within(element).getByLabelText('add');
for (let i = 1; i <= quantity; i++) {
act(() => {
fireEvent.click(addProduct);
});
}
const addToCart = within(element).getByLabelText('addToCart');
act(() => {
fireEvent.click(addToCart);
});
}

Elle reprend exactement le fonctionnement de l’exemple précédent. Ajout de X produits au panier.

Côté test on se retrouvera avec :

test('le client commande 2 pommes et 3 kiwis', async () => {
// ... builder + mock api
render(<App />);
await catalogIsReady(catalog);
addToCart(pomme, 2);
addToCart(kiwi, 3);
});

Un test lisible

Une fois nos Builders créés, les mocks de notre API configurés et les Actions bien identifiées, on se retrouve avec des parcours simples à lire et à écrire :

test('le client commande 2 pommes et 3 kiwis', async () => {
// Arrange
const pomme = new ProductApiBuilder().pomme().build();
const kiwi = new ProductApiBuilder().kiwi().build();
const catalog = [pomme, kiwi]; server.use(
rest.get("http://fakeapi/products", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(catalog));
}),
rest.post("http://fakeapi/purchase"), (req, res, ctx) => {
return res(ctx.status(200));
}),
);
// Act
render(<App />);
await catalogIsReady(catalog);
addToCart(pomme, 2);
addToCart(kiwi, 3);
openCart(); purchase(); // Assert
await screen.findByText('Votre commande est terminée. Merci pour votre achat.');
});

NB : le findByText levant une exception si le texte n’est pas trouvé. On s’en sert ici comme assertion.

Couverture de code

Quelques mots sur la couverture de code. En créant nos 4 parcours, sans aucun autre test, on obtient :

La couverture est excellente mais ne couvre pas l’ensemble des cas. En se penchant sur le code il sera ici facile d’identifier les cas non couverts.

Pour ce genre de cas à la marge il est inutile de faire des parcours. Des tests unitaires sur les composants concernés permettront de couvrir facilement ces règles.

Les tests en erreur

Par défaut si un test échoue Testing Library affichera le DOM généré. Comme on affiche l’ensemble de notre application, les erreurs seront illisibles.

Pour remédier à ce problème et afficher uniquement l’exception retournée, on peut configurer Testing Library pour ne plus afficher la stack trace du DOM dans notre setupTest :

configure({
getElementError: (message) => {
const error = new Error(message);
error.name = 'TestingLibraryElementError';
error.stack = undefined;
return error;
},
});

TL;DR;

Les parcours de test peuvent être une alternative facile et rapide à mettre en place par rapport aux tests end-to-end avec un framework tel que Cypress par exemple.

En se construisant une librairie de Builders et d’Actions propres à notre application il sera possible d’écrire des tests simples à lire et à maintenir, couvrant l’essentiel de nos parcours utilisateur et facilitant la maintenance de notre application.

De plus, une refacto dans la structure de notre application ne devrait que très rarement impacter les parcours de tests. L’affichage restant identique, les différentes actions continueront à s’exécuter et on s’assurera que l’expérience utilisateur n’a pas été dégradée.

Articles annexes

--

--