Tests automatisés avec BUnit et MudBlazor

Objectif

Le but de ce billet est de montrer comment automatiser des tests d'un composant Blazor avec BUnit, et en particulier les difficultés rencontrées pour piloter la saisie de valeur dans des composants comme MudBlazor, en restant dans le principe de pilotage par les tests de l'interface, avec le binding qui fonctionne normalement (piloter les valeurs sous-jacentes depuis BUnit ne permettrait pas un test réaliste).

Création du composant

Dans une solution de type Blazor, ajoutez un composant nommé par exemple SelectorBindObj.razor et insérez le markup ci-dessous (avec une route si vous souhaitez tester interactivement) :

<MudGrid Class="my-4">
    <MudItem xs="12" Class="d-flex align-center" id="montant">
        <MudNumericField T="int" @bind-Value="boundObject.Base" />
    </MudItem>
    <MudItem xs="12" Class="d-flex align-center" id="selecteur">
        <MudSelect T="int" @bind-Value="boundObject.Taux" Immediate="true" Label="Multiplicateur" Variant="Variant.Filled" AnchorOrigin="Origin.BottomCenter">
            <MudSelectItem Value="1">Double</MudSelectItem>
            <MudSelectItem Value="2">Triple</MudSelectItem>
            <MudSelectItem Value="3">Decuple</MudSelectItem>
        </MudSelect>
    </MudItem>
    <MudItem xs="12" Class="d-flex align-center" id="resultat">
        <MudNumericField T="int" @bind-Value="boundObject.Total" ReadOnly="true" />
    </MudItem>
</MudGrid>

Notez dans le code ci-dessus que des identifiants ont été positionnés sur les balises de type MudItem. Ceci nous permettra, dans les tests automatisés ci-dessous, de localiser plus facilement les composants à manipuler.

Et surtout, ceci permettra, si la structure du composant change, de ne pas avoir à revenir sur le test automatisé pour qu'il continue à faire son office.

Dans la section @code (ou dans le fichier SelectorBindObj.razor.cs), rajoutez le code suivant :

    public int _ParamBase = 1000;

    [Parameter]
    public int ParamBase
    {
        get
        {
            return _ParamBase;
        }
        set
        {
            _ParamBase = value;
            boundObject.Base = _ParamBase;
        }
    }

    private BindObject boundObject = new BindObject();

    public class BindObject
    {
        public int Base { get; set; } = 100;

        public int Taux { get; set; } = 1;

        public int Total
        {
            get
            {
                switch (Taux)
                {
                    case 1: return Base * 2;
                    case 2: return Base * 3;
                    case 3: return Base * 10;
                }
                return 0;
            }
            set { }
        }
    }

Une fois le mécanisme mis en place pour accéder à la page, vous pourrez vérifier le bon mode de fonctionnement en changeant la base et le type de calcul et en constatant la valeur résultante :

Affichage page Blazor

Tout est prêt pour passer à la suite, c'est-à-dire les tests automatisés. Remarquez toutefois la structure C# utilisée pour la valeur servant de base au calcul :

  • ParamBase est un paramètre Blazor, dont la propriété en écriture met à jour la propriété Base de l'instance de BindObject qui sert pour le binding.
  • BoundObject est une instance regroupant toutes les données bindées sur les composants de la page.
  • La classe de cette instance contient une propriété en lecture-écriture nommée Base et qui est bindée en bidirectionnel avec le premier composant de type MudNumericField.

Les points ci-dessus sont particulièrement importants, car le pilotage par le framework de tests BUnit n'est pas du tout le même selon qu'on travaille sur l'initialisation d'un composant par les paramètres ou la modification des valeurs dans le composant en cours, en simulant une action utilisateur sur l'interface graphique correspondante. C'est d'ailleurs la principale (voire la seule) difficulté que cet article de blog cherche à expliquer.

Création du projet de tests

Même si on ne s'y attend pas, créer le projet Visual Studio pour les tests peut toutefois représenter une petite difficulté additionnelle. En prérequis, il faut ajouter un projet de type Bibliothèque de classes.

La suite consiste à ajouter le package NuGet Bunit, qui va servir à piloter les composants Blazor en mémoire (bref, à simuler du end-to-end, sans avoir besoin d'un navigateur, ni même d'ailleurs d'un navigateur headless en mémoire, ce qui est assez génial quand on y pense et donne une valeur supplémentaire au modèle de composant Blazor).

Après cela, il faut rajouter le package MSTest.TestFramework pour avoir accès aux attributs de décoration des artefacts de tests. Mais attention, le package MSTest.TestAdapter est également nécessaire, sinon l'intégration des tests dans Visual Studio ne fonctionne pas. La documentation demande d'ajouter aussi Microsoft.NET.Test.Sdk.

Ajout du test unitaire

Pour que le test soit détectable par Visual Studio, la classe de test doit être déclarée public et être décorée avec l'attribut [TestClass] ; quant aux méthodes de tests, même chose sur la visibilité et la décoration avec [TestMethod]. Pour avoir accès aux fonctions de BUnit, faire hériter la classe de Bunit.TestContext. Et bien sûr, ajouter une dépendance au projet contenant le composant Blazor à tester.

La première chose à faire dans le code est de rajouter le nécessaire pour que MudBlazor puisse fonctionner dans le contexte réduit du test automatisé. Comme il n'y a pas de navigateur, il faut en particulier simuler la présence de Javascript, même si - dans notre cas - il n'est pas nécessaire de réaliser un mock précis d'une fonctionnalité :

JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();

Le premier appel piloté par le test que nous allons réaliser sur le composant consiste bien sûr à l'initialiser et, à cette occasion, nous allons injecter une valeur de paramètre pour ParamBase, qui sera valué à 10 (toutes les valeurs sont des entiers pour simplifier l'exemple) :

int valeurBase = 10;
var cut = RenderComponent<SelectorBindObj>(parameters => parameters
    .Add(comp => comp.ParamBase, valeurBase));

Avant même de continuer le test (qui consistera à vérifier que la valeur précisée en base est bien multipliée comme précisé dans la liste de sélection et que le résultat est correct), il peut être intéressant de vérifier que le composant se trouve correctement initialisé :

var item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "montant");
var baseCalcul = item.FindComponent<MudNumericField<int>>();
Assert.AreEqual(valeurBase, baseCalcul.Instance.Value);

C'est justement dans le code ci-dessus que nous utilisons l'identifiant montant passé sur le MudItem, de façon à rendre plus solide le test, qui ne cassera pas si le contrôle cible est déplacé ailleurs dans la structure du composant.

L'initialisation du composant par un paramètre est donc testée et la suite va consister à piloter la liste de sélection pour choisir la seconde entrée, qui correspond à la demande de triplement de la valeur :

item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "selecteur");
var select = item.FindComponent<MudSelect<int>>();
select.InvokeAsync(() => select.Instance.SelectedValues = new List<int> { 2 });

Ce code n'appelle pas de remarque particulière, à part que les valeurs sélectionnées sont systématiquement fournies sous forme de liste, même si le composant est paramétré en mode mono-sélection, ce qui rend plus simple l'interop par code. Il convient donc de bien fournir une List<int> pour piloter la sélection du mode de multiplication.

Enfin, nous arrivons au test final de validation du composant proprement dit, et qui va vérifier que le triplement du montant de base qui avait été affecté plus haut est bien réalisé sur la valeur identifiée par resultat et lisible dans le MudNumericField en lecture seule :

item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "resultat");
var total = item.FindComponent<MudNumericField<int>>();
Assert.AreEqual(valeurBase * 3, total.Instance.Value);

Après tous ces ajouts, le code du test doit normalement ressembler à ceci :

using Bunit;
using ExplorationBUnit.Client.Pages;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MudBlazor.Services;
using MudBlazor;

namespace TestsBUnit
{
    [TestClass]
    internal class TestSelectorBindObject : Bunit.TestContext
    {
        [TestMethod]
        public void TestSelectionBindObject()
        {
            JSInterop.Mode = JSRuntimeMode.Loose;
            Services.AddMudServices();

            int valeurBase = 10;
            var cut = RenderComponent<SelectorBindObj>(parameters => parameters
                .Add(comp => comp.ParamBase, valeurBase));

            var item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "montant");
            var baseCalcul = item.FindComponent<MudNumericField<int>>();
            Assert.AreEqual(valeurBase, baseCalcul.Instance.Value);

            item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "selecteur");
            var select = item.FindComponent<MudSelect<int>>();
            select.InvokeAsync(() => select.Instance.SelectedValues = new List<int> { 2 });

            item = cut.FindComponents<MudItem>().First(item => item.Instance.FieldId == "resultat");
            var total = item.FindComponent<MudNumericField<int>>();
            Assert.AreEqual(valeurBase * 3, total.Instance.Value);
        }
    }
}

Exécution du test

L'explorateur de tests peut être appelé par le raccourci Ctrl+E, T, ou bien en sélectionnant l'entrée correspondante dans le menu Affichage. Il montre normalement les tests détectés par Visual Studio :

Explorateur de tests VS

Le raccourci Ctrl+R, Ctrl+T vous permet de lancer le test sur lequel vous êtes positionné en mode débogage (ne pas utiliser le second Ctrl pour réaliser la même commande sans débogage ; remplacer les T par des A pour lancer tous les tests).

Si tout se passe bien, vous verrez alors le test unitaire en mode succès, comme montré ci-dessus.

links

social