Go to Hackademy website

Pilotez vos tests Elixir avec des scénarios

Ludovic de Luna

Posté par Ludovic de Luna dans les catégories backelixir

Cet article est publié sous licence CC BY-NC-SA

elixir_test_with_scenarios_header

À 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 :

  1. Setup
  2. Tests
  3. 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 !


L’équipe Synbioz.
Libres d’être ensemble.

Articles connexes

Le web en Crystal

27/08/2020

Crystal, web et Lucky (une intro basique) Depuis un moment j’ai envie de tester le langage Crystal. Celui-ci s’approchant de sa version 1.0, je me suis dit qu’il était temps de regarder ce qu’il...

WebRTC Partie 1 : Signalement par pigeon voyageur

09/07/2020

Alors qu’on parle de connexion pair à pair, si l’on regarde les cas d’usage de WebRTC en ligne, on pourra être surpris de constater que des serveurs centraux sont quasiment toujours requis...

Regex : attrapez-les tous !

09/04/2020

Encore un article sur les regex me direz-vous !? Effectivement, après avoir traité des quantificateurs, des propriétés Unicode, et même des emojis, que pourrais-je encore raconter que vous ne sachiez...

Ruby, Sidekiq et Crystal

25/07/2019

Dans le cadre d’une application web il nous est tous déjà arrivé de devoir effectuer une tâche assez longue en asynchrone pour ne pas gêner le flux de notre application. En Ruby la solution la plus...