Article écrit par Ludovic de Luna
À la fois documentation et validateur de l’implémentation, le test est une composante essentielle à la qualité logicielle. Elixir, tout comme son écosystème, intègre le test dans son ADN. Mon premier projet Elixir remonte à 2018. Nous découvrions chez Synbioz les approches possibles et la manière d’utiliser cette technologie.
Bien que nous ayons organisé avec soin nos tests, j’ai constaté que leur qualité s’était dégradée au fil des évolutions de notre base de code. Je souhaite partager avec vous une approche que j’ai récemment expérimentée pour mieux contrôler les tests au travers d’un modèle plus déclaratif. Ce n’est pas une solution exhaustive, mais elle reste simple et centrée sur ExUnit, le framework de test embarqué dans Elixir.
Gargantua est passé par là !
Nous étions confiants lors de l’implémentation initiale : reproductibilité du test via des générateurs pour le jeu de données, séparation des tests unitaires et d’intégration, un peu de factorisation et de macros pour éviter la répétition… C’était déjà correct.
Mais en revenant sur les tests après plus d’un an d’exploitation, je constate qu’ils perdent en lisibilité pour certains cas – la factorisation n’apportant pas le résultat attendu sur ce point. Nos tests prennent invariablement de l’embonpoint.
Voici un exemple de départ très simpliste de ce que nous avons :
describe "MyApp.hello/2" do test "Get a simple greeting when the reg number is not 1000" do # Build objects users = %{ 1 => build( :user, %{name: "Baker", regnum: 1} ) } # Assertions assert "Hello Baker !" = MyApp.hello(users, 1) end end
Cet exemple ne pose en soi aucun problème. Mais parfois, on souhaite valider le résultat d’une même action de façon répétitive en fonction du jeu de données.
Ne voyant pas de solution immédiate, j’ai continué en ce sens tout en sachant qu’il faudrait tôt ou tard trouver une autre approche.
Et ce moment est arrivé récemment pour un cas qui nécessitait d’ajouter plusieurs scénarios pour valider la correction d’un bogue assez vicieux. C’est le point de départ qui m’a fait dire que nous perdrions durablement en qualité si nous poursuivions ainsi.
Scénariser le test
Je voulais modifier notre approche pour obtenir :
- un jeu de tests déclaratif pour lequel il serait simple de désactiver ou d’ajouter un élément dans les phases de construction ;
- l’entête me donne de visu les conditions du banc d’essai sans besoin d’aller trop loin dans la lecture du code ;
- le corps du test devait contenir les assertions et rien d’autre.
Et pour y arriver, il fallait utiliser un composant que j’ai trop sous-estimé : l’objet context
. Couplé à la fonction de rappel via le setup
et à l’annotation tag
, j’avais tout ce qu’il me fallait.
Mais avant d’aller plus loin, résumons le rôle de chacun.
Le context
est un tableau associatif (Map en Elixir) mis à disposition par le framework ExUnit et partagé auprès de l’ensemble des tests tout comme des phases de préparation. N’oubliez pas qu’en Elixir, la donnée est immuable. Il n’est donc pas possible de modifier son contenu en dehors des mécanismes prévus à cet effet par ExUnit.
Le setup
est une phase de préparation qui définit les conditions dans lesquelles le test va se dérouler. Il s’agit d’actions faites avant chaque test au travers d’une (ou plusieurs) fonction de rappel. Cette phase a également pour objectif d’alimenter le context
en données par fusion avec les éléments renvoyés par la fonction de rappel.
Le tag
permet d’alimenter le context
avec les données du test avant même sa phase de préparation, ici aussi par fusion.
Réfléchissez quelques instants à l’usage du contexte pour orienter les tests par fonction et par scénario, le tout adossé à des fonctions de support (ou helpers) pour préserver la lisibilité de l’ensemble.
Voici comment procéder en 3 étapes.
Étape 1 – externaliser la préparation
L’idée est d’avoir une fonction qui recevra en argument le context
(un Map) et retournera les modifications à appliquer à ce dernier (par fusion). Souvenez-vous que le context
contiendra déjà les éléments déclarés en entête de chaque test.
Voici un exemple de fonction de préparation d’un test (méthode privé) :
defp prepare_users(context) do # ... do something with the context ... # return changes to apply on the context: %{name: "Baker"} end
Une fonction de préparation doit toujours renvoyer un résultat. Et ce résultat est standardisé en Elixir.
Ici, on retourne un Map qui sera fusionné avec le context
. A minima, il faut retourner un indicateur de réussite (l’atome « :ok
») si on ne veut pas modifier le context
. Mais on peut aussi cumuler l’indicateur de réussite avec les éléments à fusionner via une liste de mots-clés.
Exemple :
{:ok, [name: "Baker"]}
En dernière position dans un atome, les crochets sont optionnels pour la liste de mots-clés :
{:ok, name: "Baker"}
Le corps de notre fonction contient ce que nous aurions normalement placé dans la déclaration du setup
, que nous allons utiliser pour la suite un peu différemment.
Vos fonctions de préparation peuvent se placer dans la même section que vos tests, regroupées soit en début soit en fin.
Étape 2 – chaîner les étapes de préparation
Nous avions pour habitude d’avoir un seul appel à la fonction setup
. Elle était très générique à l’ensemble du fichier de test. Il est possible d’avoir une fonction setup
en plus par section de description (« describe
»), et c’est ce que nous allons faire.
À l’inverse de la classique déclaration comme ci-après :
setup(context) do # all actions of setup ... # ... and finally the success indicator :ok end
Je vous propose d’utiliser une autre syntaxe, plus courte, et qui va appeler en cascade les fonctions créées à l’étape précédente :
setup [:prepare_users, :prepare_other_thing, :prepare_commons]
La liste ci-dessus contient le nom des fonctions à appeler. Le setup
leur donnera en argument le context
et traitera le retour comme nous l’avons vu à l’étape précédente.
Quelle est la conséquence de l’échec d’une fonction de rappel ? Le test associé sera marqué en échec et les fonctions de rappel qui suivent ne seront pas exécutées. Tout comme pour un test, le rapport final mentionnera les détails de l’erreur.
Étape 3 – mise en œuvre du « tag »
Pour alimenter notre contexte avec de nouvelles entrées, nous allons utiliser les tags. Voici un exemple :
setup [:prepare_users] @tag user_reg_number: 10 test "Get a simple greeting when the reg number is not 1000" do # Assertions here... end
Ici, on ajoute au tableau associatif context
une entrée reg_number
. Nous pourrons l’utiliser aussi bien dans une fonction en phase de préparation que dans le test en lui-même. L’une des phases de préparations pourrait être :
defp prepare_users(%{user_reg_number: reg_number} = _context), do: build(:user, reg_number: reg_number) defp prepare_users(_context), do: :ok
Ici, on va créer un utilisateur qui aura le numéro de registre fournis dans le contexte via la clé user_reg_number
. Si cette information est manquante, l’utilisateur ne sera pas créé (le cas de la seconde fonction).
Exemple complet
J’ai créé un petit dépôt GitHub pour jouer avec ce principe. Il vous faudra un Elixir opérationnel (ou l’utiliser via une image Docker). Le projet Elixir s’appelle « ElixirUnitTests » et ne contient véritablement qu’un seul module. J’avoue avoir été peu inspiré pour lui trouver un nom (le mal de tous les développeurs).
Voici l’organisation générale du fichier de test. Nous verrons les 3 points qui composent le test de façon séparée :
- Setup
- Tests
- Helpers
Un module de test reprend le nom du module qu’il teste suivit de « Test
».
defmodule ElixirUnitTestsTest do # ... prepare test env describe "ElixirUnitTests.hello/2" do # 1. Setup by calling functions before start each test # (see the article bellow) # 2. Tests # (see the article bellow) # 3. Helpers for ElixirUnitTests.hello/2 # (see the article bellow) end end
C’est assez générique. La phase de préparation via le setup
, puis les tests
(assertions) et quelques fonctions de support ou helpers
.
1 — Voici le setup
:
# Setup by calling functions before start each test setup [:load_fixtures]
2 — Vient ensuite les tests
:
# Tests @tag user: %{name: "Baker"} @tag congratulate: false test "Simple hello with the name", %{users: users, id: id} = _context do assert "Hello Baker !" = ElixirUnitTests.hello(users, id) end @tag user: %{name: "Alan"} @tag congratulate: true test "Hello with congratulations", %{users: users, id: id} = _context do assert "Hello Alan ! Congratulations !" = ElixirUnitTests.hello(users, id) end
3 — Et pour finir, les helpers
:
# Helpers for ElixirUnitTests.hello/2 defp load_fixtures(%{user: user, congratulate: true} = _context), do: build_users(user, 1000) defp load_fixtures(%{user: user} = _context), do: build_users(user, 1) def build_users(attributes, id) do [ users: %{id => build(:user, put_in(attributes[:regnum], id))}, id: id ] end
La fonction que vous ne pouvez pas voir est build
. Elle fait partie des fonctions de support pour l’ensemble des tests et a en charge la création d’une structure de modèle (Ecto) pour le stockage en base de données.
Résumé
Nous avons vu comment exploiter le tag
pour piloter le banc de test et conserver dans le corps du test uniquement les assertions. C’est une autre approche pour créer des tests en fonction de scénarios en limitant la surcharge sur chaque test.
C’est toujours difficile d’extraire un exemple utile depuis un code métier pour un article. J’espère que l’intérêt de la solution est intact et que ça pourra vous aider dans vos tests en Elixir.
Si vous souhaitez jouer avec l’exemple donné plus haut dans l’article, je vous ai concocté un dépôt sous GitHub qui contient le projet Elixir entier.
Je vous souhaite de prendre plaisir dans vos développements Elixir !