Tester des composants Blazor avec bUnit

Un composant Blazor rendu dans un test bUnit avec xUnit
Tester des composants Blazor avec bUnit

Dans mes projets Blazor, la logique métier est testée avec xUnit depuis longtemps, mais les composants eux-mêmes ne l’étaient pas. Si un @if affichait le mauvais bouton, je le voyais seulement en ouvrant la page. Dans cet article, on ajoute un projet de tests bUnit à une application Blazor et on couvre les cas que je rencontre le plus souvent.

bUnit rend un composant Blazor en mémoire, sans navigateur. On peut ensuite chercher des éléments dans le HTML produit, cliquer sur des boutons et vérifier le résultat. Les exemples utilisent bUnit 2.11, xUnit et .NET 10.

Le code complet est disponible ici : mongeon/code-examples · blazor-bunit-testing.

Le projet de tests

On part d’une solution qui contient déjà le projet Blazor (ici Arbitres.Web). On crée un projet xUnit à côté, on le relie au projet Blazor et on ajoute le paquet bunit :

dotnet new xunit -o Arbitres.Web.Tests
dotnet add Arbitres.Web.Tests reference Arbitres.Web
dotnet add Arbitres.Web.Tests package bunit

C’est tout pour la configuration. Les tests peuvent être écrits en C# ou dans des fichiers .razor. Dans cet article, j’utilise des classes C# ordinaires, ce qui fonctionne avec le SDK Microsoft.NET.Sdk que crée le template xUnit. Pour écrire des tests en .razor, il faut changer le SDK du projet de tests pour Microsoft.NET.Sdk.Razor.

Attention : si vous avez déjà utilisé bUnit 1.x, plusieurs noms ont changé dans la version 2. TestContext est devenu BunitContext, RenderComponent<T>() est devenu Render<T>() et AddTestAuthorization() est devenu AddAuthorization(). Les exemples que vous trouvez en ligne utilisent souvent encore les anciens noms.

Le composant à tester

Le premier composant affiche un match de baseball. S’il manque des arbitres, il affiche combien et un bouton pour se proposer. Il a aussi un bouton qui copie le lien du match dans le presse-papiers avec du JavaScript.

@* GameCard.razor *@
@inject IJSRuntime JS

<div class="game-card">
    <h3>@Game.AwayTeam @@ @Game.HomeTeam</h3>
    <p>@Game.StartTime.ToString("yyyy-MM-dd HH:mm")</p>

    @if (Game.MissingUmpires > 0)
    {
        <span class="missing">Manque @Game.MissingUmpires arbitre(s)</span>
        <button class="volunteer" @onclick="() => OnVolunteer.InvokeAsync(Game.Id)">Me proposer</button>
    }

    <button class="copy-link" @onclick="CopyLinkAsync">Copier le lien</button>
</div>

@code {
    [Parameter, EditorRequired]
    public Game Game { get; set; } = default!;

    [Parameter]
    public EventCallback<int> OnVolunteer { get; set; }

    private async Task CopyLinkAsync()
    {
        await JS.InvokeVoidAsync("navigator.clipboard.writeText", $"https://arbitres.ca/matchs/{Game.Id}");
    }
}

Le modèle est un simple record :

public record Game(int Id, string HomeTeam, string AwayTeam, DateTime StartTime, int RequiredUmpires, int AssignedUmpires)
{
    public int MissingUmpires => RequiredUmpires - AssignedUmpires;
}

Un premier test avec Render et MarkupMatches

La classe de tests hérite de BunitContext. Ça donne accès à Render<T>(), aux services et au JSInterop de bUnit. BunitContext implémente IDisposable, donc xUnit s’occupe de le nettoyer après chaque test.

using Bunit;

namespace Arbitres.Web.Tests;

public class GameCardTests : BunitContext
{
    private static readonly Game FullGame = new(1, "Expos", "Blue Jays", new DateTime(2026, 10, 3, 19, 0, 0), 2, 2);
    private static readonly Game GameMissingOne = new(2, "Expos", "Blue Jays", new DateTime(2026, 10, 4, 13, 0, 0), 2, 1);

    [Fact]
    public void Affiche_les_equipes_du_match()
    {
        // On passe les paramètres du composant avec des expressions typées
        var cut = Render<GameCard>(parameters => parameters
            .Add(p => p.Game, FullGame));

        cut.Find("h3").MarkupMatches("<h3>Blue Jays @ Expos</h3>");
    }

    [Fact]
    public void Match_complet_n_affiche_pas_le_bouton()
    {
        var cut = Render<GameCard>(parameters => parameters
            .Add(p => p.Game, FullGame));

        Assert.Empty(cut.FindAll("button.volunteer"));
    }

    [Fact]
    public void Match_incomplet_affiche_le_nombre_manquant()
    {
        var cut = Render<GameCard>(parameters => parameters
            .Add(p => p.Game, GameMissingOne));

        cut.Find(".missing").MarkupMatches("<span class=\"missing\">Manque 1 arbitre(s)</span>");
    }
}

cut veut dire component under test, c’est la convention dans la documentation de bUnit. Find() prend un sélecteur CSS et lance une exception si aucun élément ne correspond. Pour vérifier qu’un élément est absent, on utilise plutôt FindAll() avec Assert.Empty.

MarkupMatches() compare le HTML sémantiquement. Les espaces, l’ordre des attributs et les commentaires ne comptent pas, donc le test ne casse pas quand on reformate le fichier .razor. Je vous recommande de l’utiliser plutôt qu’une comparaison de chaînes sur cut.Markup.

Simuler un clic et vérifier un EventCallback

Pour tester le bouton « Me proposer », on passe une lambda au paramètre OnVolunteer et on clique sur le bouton :

[Fact]
public void Clic_sur_me_proposer_envoie_l_id_du_match()
{
    int? volunteeredGameId = null;
    var cut = Render<GameCard>(parameters => parameters
        .Add(p => p.Game, GameMissingOne)
        .Add(p => p.OnVolunteer, id => volunteeredGameId = id));

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

    Assert.Equal(2, volunteeredGameId);
}

Click() déclenche le gestionnaire @onclick du composant, puis bUnit refait le rendu. Il existe aussi Change(), Input(), Submit() et les autres événements du DOM. Si le composant modifie son propre état au clic, on peut vérifier le nouveau HTML directement après.

Les appels JavaScript en mode strict

Le bouton « Copier le lien » appelle navigator.clipboard.writeText. Par défaut, le JSInterop de bUnit est en mode strict : tout appel JavaScript qui n’a pas été configuré fait échouer le test. Si on clique sur le bouton sans rien configurer, on obtient ceci :

Bunit.JSRuntimeUnhandledInvocationException: bUnit's JSInterop has not been configured to handle the call:
    InvokeVoidAsync("navigator.clipboard.writeText", "https://arbitres.ca/matchs/1")
Configure bUnit's JSInterop to handle the call with following:
    SetupVoid("navigator.clipboard.writeText", "https://arbitres.ca/matchs/1")

Le message donne directement le code à ajouter. On configure l’appel avant le rendu, puis on vérifie qu’il a eu lieu :

[Fact]
public void Copier_le_lien_appelle_le_presse_papiers()
{
    // L'appel attendu, avec ses arguments
    JSInterop.SetupVoid("navigator.clipboard.writeText", "https://arbitres.ca/matchs/1");
    var cut = Render<GameCard>(parameters => parameters
        .Add(p => p.Game, FullGame));

    cut.Find("button.copy-link").Click();

    JSInterop.VerifyInvoke("navigator.clipboard.writeText");
}

Pour les appels qui retournent une valeur, on utilise JSInterop.Setup<T>("fonction").SetResult(valeur). Si un composant fait beaucoup d’appels JavaScript qui ne sont pas importants pour le test, on peut passer en mode permissif avec JSInterop.Mode = JSRuntimeMode.Loose. Personnellement, je garde le mode strict, parce qu’il signale les appels JavaScript que je n’avais pas prévus.

Un composant qui charge ses données

Le deuxième composant affiche la liste des matchs à venir. Il injecte un IGameService et charge les données dans OnInitializedAsync. Il affiche aussi le nom de l’utilisateur connecté avec AuthorizeView, on y revient dans la section suivante.

@* GameList.razor *@
@inject IGameService GameService

<AuthorizeView>
    <Authorized>
        <p class="welcome">Bonjour @context.User.Identity?.Name</p>
    </Authorized>
    <NotAuthorized>
        <a class="login" href="authentication/login">Se connecter</a>
    </NotAuthorized>
</AuthorizeView>

@if (games is null)
{
    <p class="loading">Chargement...</p>
}
else if (games.Count == 0)
{
    <p class="empty">Aucun match à venir.</p>
}
else
{
    @foreach (var game in games)
    {
        <GameCard Game="game" />
    }
}

@code {
    private IReadOnlyList<Game>? games;

    protected override async Task OnInitializedAsync()
    {
        games = await GameService.GetUpcomingGamesAsync();
    }
}

Dans l’application, IGameService appelle une API. Dans les tests, on enregistre un faux service dans Services, qui est un IServiceCollection ordinaire. Si on oublie, bUnit lance There is no registered service of type 'Arbitres.Web.IGameService' au rendu.

Le faux service utilise un TaskCompletionSource. Ça permet de décider à quel moment l’appel se termine, et donc de tester l’état « Chargement… » avant que les données arrivent :

public class FakeGameService : IGameService
{
    private readonly TaskCompletionSource<IReadOnlyList<Game>> tcs = new();

    public Task<IReadOnlyList<Game>> GetUpcomingGamesAsync() => tcs.Task;

    // Termine l'appel avec les matchs reçus
    public void Complete(params Game[] games) => tcs.SetResult(games);
}

Les tests :

using Bunit;
using Bunit.TestDoubles;
using Microsoft.Extensions.DependencyInjection;

namespace Arbitres.Web.Tests;

public class GameListTests : BunitContext
{
    private readonly FakeGameService gameService = new();
    private readonly BunitAuthorizationContext auth;

    public GameListTests()
    {
        Services.AddSingleton<IGameService>(gameService);
        auth = AddAuthorization();
    }

    [Fact]
    public void Affiche_chargement_pendant_l_appel()
    {
        var cut = Render<GameList>();

        cut.Find(".loading").MarkupMatches("<p class=\"loading\">Chargement...</p>");
    }

    [Fact]
    public void Affiche_une_carte_par_match()
    {
        var cut = Render<GameList>();

        gameService.Complete(
            new Game(1, "Expos", "Blue Jays", DateTime.Today, 2, 2),
            new Game(2, "Capitales", "Aigles", DateTime.Today, 2, 0));

        // Le rendu se fait après la fin de la tâche, on attend qu'il arrive
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents<GameCard>().Count));
    }

    [Fact]
    public void Affiche_un_message_si_aucun_match()
    {
        var cut = Render<GameList>();

        gameService.Complete();

        cut.WaitForAssertion(() => cut.Find(".empty").MarkupMatches("<p class=\"empty\">Aucun match à venir.</p>"));
    }
}

Render() retourne dès le premier rendu, même si OnInitializedAsync n’est pas terminé. C’est pour ça que le premier test voit « Chargement… ». Quand la tâche se termine, le composant refait son rendu de son côté, et WaitForAssertion() relance l’assertion à chaque rendu jusqu’à ce qu’elle passe ou que le délai d’une seconde expire. Sans WaitForAssertion(), le test peut vérifier le HTML avant le deuxième rendu et échouer de façon intermittente.

FindComponents<GameCard>() retourne les composants enfants rendus. Ça permet de vérifier la structure sans dépendre du HTML de GameCard, qui est déjà testé de son côté.

AuthorizeView dans les tests

Le constructeur de GameListTests appelle AddAuthorization(). Sans cet appel, le rendu échoue parce qu’AuthorizeView a besoin des services d’autorisation :

Cannot provide a value for property 'AuthorizationPolicyProvider' on type
'Microsoft.AspNetCore.Components.Authorization.AuthorizeView'.
There is no registered service of type 'Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider'.

AddAuthorization() enregistre de faux services d’autorisation et retourne un BunitAuthorizationContext. Par défaut, l’utilisateur n’est pas connecté. Pour simuler un utilisateur connecté, on appelle SetAuthorized() avant le rendu :

[Fact]
public void Visiteur_voit_le_lien_de_connexion()
{
    var cut = Render<GameList>();

    Assert.NotNull(cut.Find("a.login"));
}

[Fact]
public void Utilisateur_connecte_voit_son_nom()
{
    auth.SetAuthorized("Gabriel");

    var cut = Render<GameList>();

    cut.Find(".welcome").MarkupMatches("<p class=\"welcome\">Bonjour Gabriel</p>");
}

Le même objet a aussi SetRoles(), SetPolicies() et SetClaims() pour tester un AuthorizeView Roles="Admin" ou une politique précise. C’est un cas où bUnit est très utile : pour vérifier à la main qu’un bouton d’administration n’apparaît pas pour un arbitre ordinaire, il faut se connecter avec deux comptes différents.

Ce que bUnit ne couvre pas

bUnit n’exécute pas de navigateur. Le CSS n’est pas appliqué, le JavaScript n’est jamais exécuté (on vérifie seulement qu’il est appelé) et la navigation entre les pages n’est pas réelle. Pour un bouton caché par une règle CSS ou un problème dans le code JavaScript, il faut un test de bout en bout avec un outil comme Playwright. bUnit sert à tester la logique d’affichage des composants, et quelques tests Playwright peuvent couvrir les parcours principaux.

Avec dotnet watch test (voir cet article), les tests bUnit roulent en quelques secondes à chaque modification d’un fichier .razor.

Bonne programmation, et vérifiez les noms de méthodes si vous suivez un exemple écrit pour bUnit 1.x.


Cet article a été rédigé avec l’aide de l’IA et révisé par moi.

Catégories : .NET  Blazor 
Étiquettes : Blazor  bUnit  xUnit  Tests  .NET  C# 

Suggestions de lecture :