Utilisation de SpecFlow pour rendre accessibles les tests automatisés

Objectifs

Suite au précédent article sur les tests automatisés de composants Blazor avec BUnit, plusieurs difficultés ont nécessité une résolution :

  1. En l'état, la production de cas de tests par un Product Owner est complexe car elle nécessite une transformation en un code qu'on ne peut pas qualifier de réellement complexe, mais qui nécessite dans tous les cas l'intervention d'un développeur.
  2. Les tests générés mélangent du métier (les cas de tests) et du code d'exécution pour piloter le Component Under Test. Ceci va à l'encontre du Principe de Séparation des Responsabilités.
  3. La modification de la valeur d'un MudNumericField ne fonctionne pas comme celle sur un MudSelect. Il était donc nécessaire d'adapter le mode de pilotage pour ce cas particulier.

C'est la raison pour laquelle ce billet vient compléter le précédent. Il montrera l'utilisation de Specflow pour mettre en place une approche de type Behaviour Driven Development, où les cas de tests peuvent être écrits de manière très simple par un PO, et le cas particulier d'affectation de valeur sur un MudNumericField sera traité au passage.

Préparation du projet Visual Studio

Il n'y a bien sûr aucun intérêt à reprendre les instructions d'installation de l'extension Visual Studio pour utiliser SpecFlow. Sachez juste qu'il est plus facile de passer par cette manipulation que de déployer tous les NuGet nécessaires.

Une fois le projet de type SpecFlow ajouté dans la solution qui avait été commencé sur l'article de blog précédent, un exemple est créé qui simule un test d'une calculatrice. Il va nous servir de base pour notre premier test BDD.

Si vous souhaitez réutiliser les tests réalisés dans l'article précédent, pensez à utiliser un type de projet SpecFlow basé sur MSTest

Création d'un scénario

Sans renommer le fichier Features/Calculator.feature pour le moment, modifier son contenu pour qu'il réalise un scénario plus proche de ce qu'il est possible de réaliser avec notre composant SelectorBindObj :

@mytag
Scenario: Tripler un montant fourni
    Given la valeur du montant de base est 20
    And le mode de multiplication est le numéro 2
    When le calcul est terminé
    Then le résultat sur le montant calculé doit être 60

Normalement, le principe est auto-décrit, pourvu qu'on comprenne quelques mots d'anglais. Le Scenario décrit le cas de test, et les Given décrivent le contexte du cas d'usage (dans quel cas on se place à l'initialisation du test). Ensuite, la validation du scénario consiste à dire que quand une action est menée (When), les résultats correspondants doivent être constatés (Then). Cette grammaire fortement simplifiée permet à un PO de se concentrer sur les cas de tests et la complexité fonctionnelle, sans être exposé à la complexité dite accidentelle que représente le code source, les Assert, etc.

Si on lance cette entrée dans l'Explorateur de tests de Visual Studio, le résultat sera bien sûr négatif, car aucun branchement des descriptions n'a été réalisé sur du code. C'est ce que nous allons faire maintenant.

Branchement des étapes de test

En ouvrant le fichier StepDefinitions/CalculatorStepDefinitions.cs, on voit que tout est déjà (presque) prêt pour brancher les différentes étapes du test sur des bouts de code qui réaliseront le test unitaire automatique MSTest associé. C'est là que le développeur va intervenir, pour faire en sorte que chaque instruction en langage naturel dans le scénario soit transformée en un ensemble de lignes de code qui vont réaliser le travail correspondant.

Pour commencer avec un exemple simple, nous allons créer une nouvelle fonction (ou modifier une de celles données en exemple) pour implémenter le premier Given :

[Given("la valeur du montant de base est (.*)")]
public void ChangerMontantBase(int number)
{
    cut = RenderComponent<SelectorBindObj>(parameters => parameters
        .Add(comp => comp.ParamBase, number));
}

L'attribut de décoration [Given] doit contenir exactement la même chaîne que dans le scénario, avec dans notre cas une partie qui est variable : la fin de la phrase correspond à un groupe d'expression régulière, qui va récupérer le contenu suivant la partie fixe de la phrase, et la mettant en correspondance avec ce qui sera le paramètre number de la fonction. Le nom de la fonction lui-même n'a pas d'importance, mais il est recommandé de la nommer de manière précise pour faciliter les éventuelles étapes de débogage.

Pour que ce bout de code fonctionne, il va nous falloir réaliser deux actions supplémentaires :

  1. Tout d'abord, la classe doit hériter de BUnit.TestContext, sinon la fonction RenderComponent ne sera pas utilisable.
  2. Ensuite, la variable cut (pour Component Under Test) doit être déclarée, mais dans la classe car nous aurons besoin de la réutiliser ensuite. Il faut donc rajouter un membre dans la classe avec l'instruction suivante :
private IRenderedComponent<SelectorBindObj> cut = null;

C'est ceci qui nous permettra de garder la même instance BUnit pendant les appels aux autres fonctions, comme dans la fonction suivante :

[Given("le mode de multiplication est le numéro (.*)")]
public void ModifierModeMultiplication(int number)
{
    var item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "selecteur");
    var select = item.FindComponent<MudSelect<int>>();
    select.InvokeAsync(() => select.Instance.SelectedValues = new List<int> { number });
}

Notez que le And dans le scénario n'est qu'un When retraduit pour rendre plus logique la lecture chaînée du scénario.

On ne réexplique pas ce que fait BUnit, car tout ceci était dans l'article de blog précédant celui-ci. Tant qu'on est sur la comparaison avec ce qui se faisait directement dans un test automatisé unitaire, toutefois, le lecteur attentif se rappellera qu'il avait été nécessaire de réaliser des initialisations de l'interop Javascript. Pour reproduire ceci, nous mettons en haut de la classe la méthode suivante avec le décorateur [BeforeScenario] qui permet de faire en sorte que la fonction soit appelé au démarrage du scénario, et donc que les initialisations soient bien réalisées avant que le cut soit initié :

[BeforeScenario] public void BeforeScenario()
{
    JSInterop.Mode = JSRuntimeMode.Loose;
    Services.AddMudServices();
}

L'ajout de la méthode suivante est un peu spécial, et on pourrait arguer qu'il n'est pas à la bonne place, mais nous allons le garder pour l'exemple et nous reviendrons dessus un peu plus loin pour proposer une alternative plus propre. En effet, le When ne fait pour l'instant rien, car le seul fait d'affecter les valeurs sur les composants suffit à rafraîchir l'affichage. Pour être vraiment propre, il faudrait peut-être mettre en place une commande de l'attente d'une modification de l'état car les mécanismes asynchrones pourraient faire en sorte que le test arrive trop tôt à ses validations. Nous ne rentrons pour l'instant pas dans ces sophistications, et la méthode est donc au plus simple possible, à savoir vide de toute commande :

[When("le calcul est terminé")]
public void AttenteFinDeCalcul()
{
    // Rien à faire car le bind est automatique
}

Nous arrivons à la dernière étape à implémenter, et il s'agit de la vérification du fonctionnement correct. Comme ce qui est généré indirectement par SpecFlow sont des tests unitaires MSTest, nous retombons logiquement sur l'utilisation des Assert comme outils de vérification du comportement attendu :

[Then("le résultat sur le montant calculé doit être (.*)")]
public void VerificationDuResultat(int result)
{
    var item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "resultat");
    var total = item.FindComponent<MudNumericField<int>>();
    Assert.AreEqual(result, total.Instance.Value);
}

Exécution des tests pilotés par SpecFlow

Tout est désormais prêt pour un lancement du test et, comme il s'agit de tests unitaires auto-générés, le pilotage se fait par les mêmes outils, à savoir l'Explorateur de tests. Un lancement montre que l'exécution est correcte :

Tests SpecFlow OK

On est alors dans une situation beaucoup plus simple pour le PO, car l'ajout de cas de tests supplémentaires se fait avec une grammaire très simple, et en plus assistée par l'Intellisense, qui propose les méthodes disponibles. On peut ainsi, en quelques lignes, rajouter des tests pour les autres modes de multiplication, la vérification du support des nombres négatifs, des montants nuls, etc. :

Scenario: Décupler un montant fourni
    Given la valeur du montant de base est 5
    And le mode de multiplication est le numéro 3
    When le calcul est terminé
    Then le résultat sur le montant calculé doit être 50

Scenario: Doubler un montant fourni avec une valeur négative
    Given la valeur du montant de base est -20
    And le mode de multiplication est le numéro 1
    When le calcul est terminé
    Then le résultat sur le montant calculé doit être -40

Scenario: Une base nulle entraine un résultat nul
    Given la valeur du montant de base est 0
    And le mode de multiplication est le numéro 1
    When le calcul est terminé
    Then le résultat sur le montant calculé doit être 0

Et comme ni les tests ni l'implémentation ne posent problème, tout passe bien en vert :

Tests SpecFlow multiples OK

Réorganisation des scénarios

Arrivés à ce point, nous pourrions laisser en l'état, mais le fait de passer par une initialisation de paramètre n'est pas proche de la réalité de la manipulation du composant, où celui-ci est initialisé avec une valeur, mais voit ses contenus modifiés de manière interactive. Pour refléter ceci, nous allons déplacer le code correspondant dans la fonction BeforeScenario, en utilisant une valeur fixe, comme zéro, qui parait logique dans notre cas de figure :

[BeforeScenario] public void BeforeScenario()
{
    JSInterop.Mode = JSRuntimeMode.Loose;
    Services.AddMudServices();

    cut = RenderComponent<SelectorBindObj>(parameters => parameters
        .Add(comp => comp.ParamBase, 0));
}

La fonction ChangerMontantBase se retrouverait en théorie vide, mais comme on a besoin désormais qu'elle fournisse la valeur du montant, il faudrait adapter le code comme ceci :

[Given("la valeur du montant de base est (.*)")]
public void ChangerMontantBase(int number)
{
    var item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "montant");
    var baseCalcul = item.FindComponent<MudNumericField<int>>();
    baseCalcul.InvokeAsync(() => baseCalcul.Instance.Value = number);
}

Malheureusement, quand on lance les tests, on se retrouve avec une erreur :

Tests SpecFlow KO

Correction du problème de modification de valeurs numériques

Le fait que le test basé sur la valeur nulle continue à fonctionner donne une première idée du problème, et les messages d'erreur d'assertion avec une valeur constatée systématiquement nulle confirment que le problème est que la valeur n'a pas été affectée, et est resté sur celle initialisée par le paramètre.

Contrairement à un composant MudSelect, un composant MudNumericField ne peut pas être manipulé directement par la propriété Value de l'instance. Il est nécessaire d'adapter le code comme suit :

[Given("la valeur du montant de base est (.*)")]
public void ChangerMontantBase(int number)
{
    var item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "montant");
    var baseCalcul = item.FindComponent<MudNumericField<int>>();
    baseCalcul.Find("input").Change(number);
    baseCalcul.Find("input").Blur();
}

Les tests passent alors correctement :

Tests SpecFlow OK de retour

Pour référence, la solution a été trouvée à partir d'une discussion sur le dépôt de bUnit (https://github.com/bUnit-dev/bUnit/discussions/588) qui a ensuite menée aux tests unitaires qui sont réalisés par l'équipe de MudBlazor, en utilisant justement bUnit (https://github.com/MudBlazor/MudBlazor/blob/dev/src/MudBlazor.UnitTests/Components/NumericFieldTests.cs) ; bref, la garantie de trouver le bon code ! Personnellement, je n'ai toujours pas bien compris pourquoi le mode de fonctionnement n'est pas le même que pour un MudSelect, car on s'attendrait à une consistance d'instruction sur ce point. Mais je ne suis pas non plus en mesure de qualifier ceci de bogue. Si je propose une issue sur le site de MudBlazor, je poserai la référence sur une mise à jour de ce billet.

Test multiple

A titre de remarque additionnelle, il est possible de créer des tests multiples. Ainsi, on pourrait modifier par exemple la structure des tests à valeur nulle plus haut pour s'assurer que le résultat reste zéro quel que soit le mode de multiplicateur utilisé :

Scenario: Une base nulle entraine un résultat nul quel que soit le mode
    Given la valeur du montant de base est 0
    And le mode de multiplication est le numéro 1
    When le calcul est terminé
    Then le résultat sur le montant calculé doit être 0
    Given le mode de multiplication est le numéro 2
    When le calcul est terminé
    Then le résultat sur le montant calculé doit être 0
    Given le mode de multiplication est le numéro 3
    When le calcul est terminé
    Then le résultat sur le montant calculé doit être 0

Et la documentation d'intégration dans Visual Studio montre qu'on peut aller encore beaucoup plus loin avec des tableaux de valeurs, des scénarios paramétrés, etc.

Un tout dernier point : ne pas oublier de recompiler lors des changements de code pour les étapes ou d'une redéfinition des scénarios. Ceux-ci peuvent rester avec une coloration syntaxique qui est fausse si on ne recompile pas. Dans certains cas, le lancement par l'Explorateur de tests peut également se faire sur des anciennes versions. Enfin, il existe un bug connu de détection des tests sur Visual Studio qui nécessite dans certains cas de fermer et recharger l'IDE.

links

social