Blazor : Tests unitaires avec bUnit

Introduction

Faire des tests unitaires dans un projet informatique est important. L’arrivée de Blazor ne déroge pas à la règle. Bien que relativement récent, la communauté autour de Blazor est active et permet l’émergence de projets facilitant la vie des développeurs. C’est en cherchant dans l’immense réserve des paquets NuGet que j’ai trouvé bUnit qui permet de faire des tests unitaires sur les composants Blazor. Ce projet vient par ailleurs d’être mis en avant par les équipes de Microsoft. Je vais dans cet article vous présenter cet outil qui vous permettra de tester vos composants.

bUnit

Qu’est-ce que bUnit ?

Blazor fonctionne avec des composants qui héritent de Icomposant ou componentBase. Ces composants sont managés par le runtime C#. Ce runtime gère la durée de vie des composants, l’injection de dépendance ou encore l’arbre de rendus.

Il y a ensuite l’interface utilisateur qui crée le DOM, écoute les actions de l’utilisateur, les évènements du navigateur, etc.

Le but de bUnit, est de simuler cette gestion de l’interface utilisateur. bUnit met en place un contexte de test permettant d’installer les dépendances comme IJsInterop, dataservice. Il permet également de simuler un rendu des composants et ensuite d’y accéder et de les manipuler. Il crée également l’arbre DOM. Et enfin, il déclenche les évènements.

bUnit envoie les commandes au runtime C#, puis le runtime renvoie l’arbre de rendu mis à jour au contexte de test afin de mettre à jour le DOM.

Ne s’exécutant pas dans un navigateur, bUnit permet également de mocker le JsInterop.

Installation

Pour utiliser bUnit, il faut d’abord créer un projet de tests. Il fonctionne aussi bien avec xUnit, Nuit ou MSTest.

J’utilise pour ma part xUnit, mais libre à vous de choisir celui qui vous convient le plus.

Il faut ensuite ajouter le package Nuget correspondant à bUnit. Attention, bUnit est toujours en preview, il faut donc penser à cocher la case Inclure la version préliminaire pour pouvoir le choisir en utilisant l’interface graphique.

Il faut ensuite ajouter le Nuget bunit.

Ou via la console :

dotnet add package bunit –version 1.0.0-preview-01

Tests

Premier test

Afin d’illustrer le principe du test avec bUnit, je vais commencer par tester le composant compteur que l’on trouve à la création de chaque application Blazor par défaut.

Le code est très simple :

@page "/counter"

<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>


@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

Ici on incrémente simplement le compteur de la page à chaque fois que l’on clique sur le bouton.

Passons à la partie test. Dans le projet xUnit, je crée une nouvelle classe de test que je fais hériter de TestContext. Il s’agit d’une classe de bUnit permettant de créer des composants de test.

public class BUnitTests : TestContext

Si vous ne souhaitez pas faire hériter votre classe, il est possible d’instancier TestContext directement dans le test.

Ensuite, dans notre méthode de test, on crée le composant que l’on souhaite tester.

public class BUnitTests : TestContext
{       
 [Fact(DisplayName ="Initial view")]
        public void Test01()
        {
            var cut = RenderComponent<Counter>();
        }
}

Si vous avez choisi de ne pas faire hériter la classe de TestContext, il faudra alors changer légèrement l’implémentation en remplaçant var cut = RenderComponent<Counter>(); par :

ctx = new TestContext() ;

var cut = ctx.RenderComponent<HelloWorld>();

On peut maintenant tester l’affichage par défaut de la page. Pour cela, on va utiliser MarkupMatches. On passe en paramètre le rendu espéré pour la page. Lors du premier affichage, on souhaite la page du compteur avec un décompte à 0.

Autrement dit le HTML suivant :

<h1>Counter</h1>
 <p>Current count: 0</p>
<button class=""btn btn-primary"">Click me</button>

Ce qui se traduit pour notre test par ceci :

[Fact(DisplayName ="Initial view")]
public void Test01()
  {
      var cut = RenderComponent<Counter>();

      cut.MarkupMatches(@"<h1>Counter</h1>
                          <p>Current count: 0</p>
                          <button class=""btn btn-primary"">Click me</button>");
        }

On peut à présent exécuter le test.

Et on constate qu’il passe avec succès.

L’étape suivante consiste à tester que le clic sur le bouton va bien incrémenter notre compteur de 1.

On va cette fois utiliser l’attribut Find sur le composant afin de retrouver le bouton :

cut.Find(« button »).

Puis on simule le click :

cut.Find(« button »).Click();

En faisant cela, c’est comme si on avait cliqué sur le bouton depuis l’interface. On teste ensuite que le compteur est bien incrémenté de 1.

        [Fact(DisplayName = "Click on button add one to counter")]
        public void Test02()
        {
            var cut = RenderComponent<Counter>();

            cut.Find("button").Click();

            cut.MarkupMatches(@"<h1>Counter</h1>
                                <p>Current count: 1</p>
                                <button class=""btn btn-primary"">Click me</button>");
        }

Puis on peut exécuter à nouveau les tests.

Ces derniers passent avec succès. On sait donc que le clic sur le bouton incrémente bien le compteur de 1.

N.B. Dans le MarkupMatches, l’ordre des attributs d’un élément n’est pas important. Dans l’exemple du bouton, si on avait un id en plus, on pourrait indifféremment écrire

<button class=""btn btn-primary""  id=""unid"">Click me</button> ou <button id=""unid"" class=""btn btn-primary"">Click me</button>.

Paramètres

Il est possible également de tester également les paramètres.

Reprenons l’exemple précédent et ajoutons un paramètre.

@page "/counter"
<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>


@code {
    private int currentCount = 0;

    [Parameter]
    public int Increment { get; set; }

    private void IncrementCount()
    {
        currentCount += Increment;
    }
}

On ajoute donc un paramètre Increment qui servira à définir le pas d’incrément lors du clic sur le bouton.

Le test reprend quasiment la même syntaxe.  On définit la valeur du paramètre en utilisant la méthode SetParametersAndRender.

[Fact(DisplayName = "Component with paramater")]
        public void Test03()
        {
            var cut = RenderComponent<CounterWithParamter>();
            cut.SetParametersAndRender(parameters => parameters.Add(p => p.Increment, 3));

            cut.Find("button").Click();

            cut.MarkupMatches(@"<h1>Counter</h1>
                                <p>Current count: 3</p>
                                <button class=""btn btn-primary"">Click me</button>");
        }

On définit ici la valeur du paramètre à 3. On attend donc une valeur de 3 pour le rendu.

Événements asynchrones

bUnit permet également de gérer les évènements asynchrones. Modifions notre composant counter afin d’ajouter une petite touche d’asynchronisme.

@page "/counter"
<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>


@code {
    private int currentCount = 0;


    private async Task IncrementCount()
    {
        await Task.Delay(500);
        currentCount ++;
    }
}

La méthode d’incrément est modifiée afin d’ajouter un délai avant l’incrémentation.

Dans le test, il faut maintenant utiliser la méthode WaitForAssertion. De cette manière, le test attend que l’évènement soit terminé avant de commencer.

[Fact(DisplayName = "Async Event")]
        public void Test04()
        {
            var cut = RenderComponent<CounterAsync>();

            cut.Find("button").Click();

           cut.WaitForAssertion(() => cut.MarkupMatches(@"<h1>Counter</h1>
                                <p>Current count: 3</p>
                                <button class=""btn btn-primary"">Click me</button>"), timeout: TimeSpan.FromSeconds(2));
        }

La méthode possède un timeout par défaut de 1 seconde. Il est possible de définir une valeur différente en passant un Timespan en seconde au second argument.

IJSRuntime

Il est possible de simuler le JSInterop dans les tests bUnit.

Reprenons l’exemple du compteur et modifions légèrement le code afin de faire un appel js.

@page "/counter"
@inject IJSRuntime JSRuntime
<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@title


@code {
    private int currentCount = 0;
    private string title;

    private async Task IncrementCount()
    {
        currentCount++;
        title = await JSRuntime.InvokeAsync<string>("GetPageTitle");
    }
}

Le GetPageTitle se contente de renvoyer le titre de la page.

Du côté des tests, il faut également faire quelques ajustements.

JSInterop au sein de bUnit fonctionne avec 2 modes, strict ou loose.

Le mode loose configure l’implémentation pour retourner une valeur par défaut lorsqu’il reçoit un appel qui n’a pas été explicitement initialisé.

Le mode strict à l’inverse déclenche une exception si l’implémentation n’a pas été initialisée.

Par défaut, c’est le mode strict qui est utilisé. Aussi, dans le cas du test du compteur ou l’appel js n’a pas d’importance, il faut déclarer JSInterop.Mode = JSRuntimeMode.Loose; dans le test pour qu’il fonctionne sans erreur.

[Fact(DisplayName = "JSRuntime loose")]
        public void Test05()
        {
            JSInterop.Mode = JSRuntimeMode.Loose;
            var cut = RenderComponent<Counter>();

            cut.Find("button").Click();

            cut.MarkupMatches(@"<h1>Counter</h1>
                                <p>Current count: 1</p>
                                <button class=""btn btn-primary"">Click me</button>");
        }

Si cependant on veut tester l’appel JS, il faut alors l’initialiser avec la valeur de retour.

[Fact(DisplayName = "JSRuntime")]
        public void Test06()
        {
            JSInterop.Setup<string>("GetPageTitle").SetResult("BlazorBUnit");
            var cut = RenderComponent<Counter>();

            cut.Find("button").Click();

            cut.MarkupMatches(@"<h1>Counter</h1>
                                <p>Current count: 1</p>
                                <button class=""btn btn-primary"">Click me</button>
                                BlazorBUnit");
        }

On peut également vérifier que l’appel JS est bien invoqué en utilisant la méthode VerifyInvoke.

@page "/counter"
@inject IJSRuntime JSRuntime
<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@title


@code {
    private int currentCount = 0;
    private string title;

    private async Task IncrementCount()
    {
        currentCount++;
        await JSRuntime.InvokeVoidAsync("alert", "Incrément");
    }
}

On veut ici vérifier que l’alerte est bien exécutée. On utilise alors la méthode VerifyInvoke. Le second paramètre permet de vérifier que la méthode n’est appelée qu’une seule fois.

[Fact(DisplayName = "JSRuntime verify")]
        public void Test07()
        {
            var handler = JSInterop.SetupVoid("alert", "Incrément");
            var cut = RenderComponent<Counter>();

            cut.Find("button").Click();

            handler.VerifyInvoke("alert", calledTimes: 1);
        }

Injection de service

TestContext contient une propriété appelée Services. Cette propriété fonctionne de la même manière que celle que l’on retrouve dans le fichier Startup.cs.

Il suffit donc de déclarer de la même manière, les composants que l’on souhaite ajouter en faisant simplement par exemple :

Services.AddSingleton<IThemeService>();

Afin de tester l’injection de dépendance, j’ai créé une classe Theme.cs.

public class Theme
    {
        public bool DarkMode { get; set; }
        public string ButtonText { get; set; }
    }

Ainsi qu’une interface IThemeService

public interface IThemeService
    {
        Theme GetTheme();
    }

Dont l’implémentation concrète se trouve dans la classe ThemeService.

public class ThemeService : IThemeService
    {
        private static readonly string[] ButtonText = new[]
        {
            "click me", "clique moi", "cliccami", "Klick mich", "Haz click en mi"
        };

        public Theme GetTheme()
        {
            var rng = new Random();
            return new Theme
            {
                DarkMode = rng.Next(0, 2) == 0,
                ButtonText = ButtonText[rng.Next(ButtonText.Length)]
            };
        }
    }

Ce service renvoie un texte aléatoire du tableau ButtonText.

J’ai également modifié le composant counter afin que le texte du bouton soit défini par le service.

@page "/counter"
@inject IThemeService themeService
<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">@theme.ButtonText</button>


@code {
    private int currentCount = 0;


    private void IncrementCount()
    {
        currentCount++;
    }

    private Theme theme;

    protected override void OnInitialized()
    {
        theme = themeService.GetTheme();
    }
}

Il est ensuite possible dans le test d’injecter le service et d’utiliser un moq pour le rendu.

[Fact(DisplayName = "Dependency Injection")]
        public void Test08()
        {
            var mockedTheme = new Mock<IThemeService>();
            mockedTheme.Setup(_ => _.GetTheme()).Returns(new Theme { ButtonText = "Click me" });

            Services.AddSingleton<IThemeService>(mockedTheme.Object);

            var cut = RenderComponent<Counter>();

            cut.Find("button").Click();

            cut.MarkupMatches(@"<h1>Counter</h1>
                                <p>Current count: 1</p>
                                <button class=""btn btn-primary"">Click me</button>");
        }

Conclusion

Dans cet article, je vous ai présenté un outil permettant de tester vos composants Blazor. Il est bien sûr possible d’aller bien plus loin dans les tests avec par exemple une sélection plus précise des éléments à tester dans la page en faisant un usage pertinent des méthodes Find, FindAll. Aussi je vous invite à consulter le site de bUnit pour en apprendre davantage. Et bien évidemment la pratique reste le meilleur moyen de vous familiariser avec ce formidable outil de test.

Laisser un commentaire