Cet article est publié sous licence CC BY-NC-SA
À 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.
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.
Je voulais modifier notre approche pour obtenir :
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.
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.
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.
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).
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 :
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.
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 !
L’équipe Synbioz.
Libres d’être ensemble.
Nos conseils et ressources pour vos développements produit.