Introduction

Quand je développe des composants Blazor, j'ai longtemps eu tendance à les tester uniquement dans le navigateur. C'est naturel : un composant est visuel, il réagit aux clics, il affiche du HTML, il dépend parfois d'un service injecté. Mais à mesure que mes bibliothèques de composants grossissent, cette approche devient trop lente.

Un changement CSS, une condition d'affichage ou une régression sur un bouton peut casser plusieurs écrans sans que je m'en rende compte tout de suite. C'est précisément le genre de problème que je veux attraper avant d'ouvrir l'application complète.

Pour cela, j'utilise bUnit. L'idée est simple : je rends un composant Blazor dans un contexte de test, je lui passe des paramètres, j'injecte ses dépendances, je déclenche des événements puis je vérifie le HTML produit ou l'état interne du composant.

Dans cet article, je montre comment je structure mes tests bUnit avec des exemples en C# :

  1. Créer un projet de test bUnit.
  2. Tester le rendu HTML d'un composant.
  3. Passer des paramètres et déclencher des événements.
  4. Injecter un service de test.
  5. Tester une navigation avec le FakeNavigationManager.
  6. Gérer l'asynchrone avec WaitForAssertion.
  7. Décider ce que je teste avec bUnit et ce que je garde pour Playwright.

1. Ce que bUnit teste réellement

bUnit ne remplace pas un navigateur complet. Il ne sert pas à valider un parcours utilisateur de bout en bout, ni à vérifier qu'une vraie page fonctionne dans Chromium avec son CSS final. En revanche, il est très efficace pour tester le comportement d'un composant isolé.

Voici la séparation que j'applique :

flowchart TD
    A[Composant Blazor] --> B{Quel risque je veux couvrir ?}
    B -->|Rendu conditionnel| C[Test bUnit]
    B -->|Parametres et EventCallback| C
    B -->|Service injecte| C
    B -->|NavigationManager| C
    B -->|Interop JS fine| C
    B -->|Parcours complet utilisateur| D[Test Playwright]
    B -->|CSS final et responsive reel| D
    B -->|Authentification bout en bout| D

En pratique, bUnit me donne un cycle de feedback très court. Je peux tester une grille, un bouton, un formulaire ou un composant de notification sans lancer toute l'application, sans ouvrir un navigateur et sans préparer une base de données complète.


2. Créer un projet de test bUnit

bUnit n'est pas un runner de tests. Je l'utilise avec MSTest, qui s'intègre simplement dans mes solutions Visual Studio et dans mes pipelines .NET. Le point important est de garder bUnit pour le rendu des composants et MSTest pour l'exécution, les attributs et les assertions.

La méthode la plus rapide consiste à installer le template bUnit :

dotnet new install bunit.template
dotnet new bunit --framework mstest -o Appliman.Components.Tests
dotnet sln Appliman.sln add Appliman.Components.Tests
dotnet add Appliman.Components.Tests reference Appliman.Components

Si je crée le projet manuellement, je vérifie surtout deux points :

  • le SDK du projet de test doit être Microsoft.NET.Sdk.Razor ;
  • le projet de test doit référencer le projet qui contient les composants.

Un fichier .csproj minimal peut ressembler à ceci :

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="bunit" Version="2.7.2" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
    <PackageReference Include="MSTest" Version="3.7.3" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Appliman.Components\Appliman.Components.csproj" />
  </ItemGroup>
</Project>

Les versions évolueront, donc je les mets à jour avec le reste de la solution. Ce qui compte pour bUnit, c'est surtout le SDK Razor et la référence vers les composants à tester.


3. Premier composant : un badge de statut

Je commence avec un composant très simple. Il affiche un badge selon un état métier :

@* Components/StatusBadge.razor *@
<span class="badge @CssClass" role="status">
    @Label
</span>

@code {
    [Parameter]
    public bool IsActive { get; set; }

    private string Label => IsActive ? "Actif" : "Inactif";

    private string CssClass => IsActive ? "bg-success" : "bg-secondary";
}

Mon premier test vérifie le HTML rendu lorsque le badge est actif :

using Bunit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Appliman.Components;

[TestClass]
public sealed class StatusBadgeTests : BunitContext
{
    [TestMethod]
    public void AfficheUnBadgeActif()
    {
        // Act
        var cut = Render<StatusBadge>(parameters => parameters
            .Add(p => p.IsActive, true));

        // Assert
        cut.MarkupMatches("""
            <span class="badge bg-success" role="status">
                Actif
            </span>
            """);
    }
}

J'aime bien MarkupMatches parce qu'il fait une comparaison sémantique du markup. Je n'écris donc pas des tests fragiles sur des espaces, des retours à la ligne ou l'ordre exact de certains détails HTML sans importance.

Si je veux être plus ciblé, je peux aussi inspecter un élément avec AngleSharp :

[TestMethod]
public void AfficheUnBadgeInactif()
{
    var cut = Render<StatusBadge>(parameters => parameters
        .Add(p => p.IsActive, false));

    var badge = cut.Find("[role='status']");

    CollectionAssert.Contains(badge.ClassList.ToList(), "bg-secondary");
    Assert.AreEqual("Inactif", badge.TextContent.Trim());
}

Ma règle est simple : MarkupMatches pour verrouiller un rendu important, Find et des assertions ciblées quand je veux éviter de figer toute la structure HTML.


4. Tester un composant avec clic et EventCallback

Un composant Blazor n'est pas seulement du HTML : il réagit aux événements et remonte souvent des informations au parent. Voici un bouton de sélection :

@* Components/ProductSelector.razor *@
<button class="btn btn-outline-primary" @onclick="SelectAsync">
    @Name
</button>

@code {
    [Parameter, EditorRequired]
    public Guid ProductId { get; set; }

    [Parameter, EditorRequired]
    public string Name { get; set; } = string.Empty;

    [Parameter]
    public EventCallback<Guid> Selected { get; set; }

    private Task SelectAsync()
        => Selected.InvokeAsync(ProductId);
}

Le test vérifie que le clic déclenche l'EventCallback avec le bon identifiant :

[TestMethod]
public void DeclencheLaSelectionDuProduit()
{
    var expectedProductId = Guid.NewGuid();
    Guid? selectedProductId = null;

    var cut = Render<ProductSelector>(parameters => parameters
        .Add(p => p.ProductId, expectedProductId)
        .Add(p => p.Name, "Clavier")
        .Add(p => p.Selected, id => selectedProductId = id));

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

    Assert.AreEqual(expectedProductId, selectedProductId);
}

C'est typiquement le genre de test que je préfère avoir dans ma suite bUnit. Il est rapide, déterministe et couvre un contrat important : le composant n'a pas seulement affiché un bouton, il a bien notifié le parent.


5. Injecter un service métier factice

Beaucoup de mes composants ne reçoivent pas toutes leurs données par paramètres. Ils injectent un service, par exemple pour récupérer une liste de produits ou déclencher une action métier.

Je prends un composant qui charge des produits au démarrage :

public sealed record ProductSummary(Guid Id, string Name, bool IsAvailable);

public interface IProductCatalog
{
    Task<IReadOnlyList<ProductSummary>> GetAvailableProductsAsync();
}
@* Components/AvailableProducts.razor *@
@inject IProductCatalog Catalog

@if (_products is null)
{
    <p role="status">Chargement...</p>
}
else if (_products.Count == 0)
{
    <p>Aucun produit disponible.</p>
}
else
{
    <ul>
        @foreach (var product in _products)
        {
            <li>@product.Name</li>
        }
    </ul>
}

@code {
    private IReadOnlyList<ProductSummary>? _products;

    protected override async Task OnInitializedAsync()
    {
        _products = await Catalog.GetAvailableProductsAsync();
    }
}

Dans le test, j'enregistre un faux service dans la collection Services fournie par bUnit avant de rendre le composant :

using Microsoft.Extensions.DependencyInjection;

public sealed class FakeProductCatalog : IProductCatalog
{
    public Task<IReadOnlyList<ProductSummary>> GetAvailableProductsAsync()
        => Task.FromResult<IReadOnlyList<ProductSummary>>([
            new ProductSummary(Guid.NewGuid(), "Clavier", true),
            new ProductSummary(Guid.NewGuid(), "Souris", true)
        ]);
}

[TestClass]
public sealed class AvailableProductsTests : BunitContext
{
    [TestMethod]
    public void AfficheLesProduitsDisponibles()
    {
        Services.AddSingleton<IProductCatalog, FakeProductCatalog>();

        var cut = Render<AvailableProducts>();

        cut.WaitForAssertion(() =>
        {
            StringAssert.Contains(cut.Markup, "Clavier");
            StringAssert.Contains(cut.Markup, "Souris");
        });
    }
}

J'utilise WaitForAssertion parce que le composant effectue un chargement asynchrone dans OnInitializedAsync. Sans attente adaptée, le test peut tomber sur le premier rendu Chargement... et devenir instable.


6. Tester une navigation

bUnit fournit un FakeNavigationManager par défaut. C'est très pratique pour tester un composant qui appelle NavigateTo sans démarrer une vraie application.

Voici un composant qui redirige vers le détail d'un produit :

@* Components/ProductCard.razor *@
@inject NavigationManager Navigation

<article class="product-card">
    <h3>@Name</h3>
    <button @onclick="OpenDetails">Voir le détail</button>
</article>

@code {
    [Parameter]
    public Guid ProductId { get; set; }

    [Parameter]
    public string Name { get; set; } = string.Empty;

    private void OpenDetails()
        => Navigation.NavigateTo($"/products/{ProductId}");
}

Le test inspecte l'URI capturée par le FakeNavigationManager :

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

[TestMethod]
public void NavigueVersLeDetailProduit()
{
    var productId = Guid.NewGuid();

    var cut = Render<ProductCard>(parameters => parameters
        .Add(p => p.ProductId, productId)
        .Add(p => p.Name, "Clavier"));

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

    var navigation = Services.GetRequiredService<FakeNavigationManager>();

    Assert.Equal(
        $"http://localhost/products/{productId}",
        navigation.Uri);
}

Je préfère tester la navigation à ce niveau quand le composant porte une règle simple : construire la bonne URL, choisir une redirection selon un état, ou empêcher un départ. Pour vérifier un vrai parcours multi-pages, je passe plutôt à Playwright.


7. Structurer mes tests par contrat de composant

Quand une bibliothèque de composants grossit, le piège est d'écrire des tests qui recopient toute l'implémentation. Je préfère organiser mes tests autour du contrat public du composant : paramètres, événements, rendu visible et dépendances.

flowchart LR
    A[Test bUnit] --> B[Arrange]
    B --> B1[Parametres]
    B --> B2[Services factices]
    B --> B3[Etat initial]
    B1 --> C[Act]
    B2 --> C
    B3 --> C
    C --> C1[Render]
    C --> C2[Click / Change / Submit]
    C1 --> D[Assert]
    C2 --> D
    D --> D1[Markup visible]
    D --> D2[EventCallback]
    D --> D3[Navigation]
    D --> D4[Etat du composant]

Pour chaque composant, je me pose quatre questions :

Question Exemple de test
Que doit-il afficher avec les paramètres minimaux ? MarkupMatches ou Find.
Que se passe-t-il quand l'utilisateur clique ou modifie une valeur ? Click, Change, assertion sur callback.
Quelles dépendances dois-je simuler ? Services.AddSingleton, fake ou mock.
Quel comportement asynchrone peut rendre le test fragile ? WaitForAssertion ou WaitForState.

Cette approche m'évite de tester les détails privés. Si je refactore l'intérieur du composant sans changer son contrat, mes tests doivent continuer à passer.


8. Exemple complet : tester un mini formulaire

Voici un composant un peu plus réaliste : il saisit une quantité et appelle un service quand l'utilisateur valide.

public interface ICartService
{
    Task AddAsync(Guid productId, int quantity);
}
@* Components/AddToCartForm.razor *@
@inject ICartService Cart

<EditForm Model="this" OnValidSubmit="SubmitAsync">
    <label for="quantity">Quantité</label>
    <InputNumber id="quantity" @bind-Value="Quantity" />

    <button type="submit">Ajouter</button>
</EditForm>

@if (_saved)
{
    <p role="status">Produit ajouté.</p>
}

@code {
    [Parameter]
    public Guid ProductId { get; set; }

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

    private bool _saved;

    private async Task SubmitAsync()
    {
        await Cart.AddAsync(ProductId, Quantity);
        _saved = true;
    }
}

Je crée un faux service qui mémorise le dernier appel :

public sealed class SpyCartService : ICartService
{
    public Guid? LastProductId { get; private set; }
    public int? LastQuantity { get; private set; }

    public Task AddAsync(Guid productId, int quantity)
    {
        LastProductId = productId;
        LastQuantity = quantity;
        return Task.CompletedTask;
    }
}

Puis j'écris le test :

using Microsoft.Extensions.DependencyInjection;

[TestClass]
public sealed class AddToCartFormTests : BunitContext
{
    [TestMethod]
    public void AjouteLeProduitAvecLaQuantiteSaisie()
    {
        var productId = Guid.NewGuid();
        var cart = new SpyCartService();
        Services.AddSingleton<ICartService>(cart);

        var cut = Render<AddToCartForm>(parameters => parameters
            .Add(p => p.ProductId, productId));

        cut.Find("#quantity").Change("3");
        cut.Find("form").Submit();

        Assert.AreEqual(productId, cart.LastProductId);
        Assert.AreEqual(3, cart.LastQuantity);
        StringAssert.Contains(cut.Find("[role='status']").TextContent, "Produit ajouté");
    }
}

Le test reste centré sur le contrat : le service reçoit la bonne demande et l'utilisateur voit une confirmation.


9. Les erreurs que j'évite

Après plusieurs séries de tests sur des composants Blazor, j'ai identifié quelques erreurs récurrentes.

Tester trop de HTML

Si je verrouille tout le markup d'un gros composant, le moindre changement de structure casse le test alors que le comportement reste correct. Je réserve donc MarkupMatches aux composants simples ou aux zones de rendu vraiment contractuelles.

Oublier l'asynchrone

Un composant qui charge ses données dans OnInitializedAsync peut produire plusieurs rendus. Si j'écris une assertion immédiate, le test peut devenir intermittent. Dans ce cas, j'utilise WaitForAssertion.

Confondre bUnit et test end-to-end

bUnit est excellent pour le contrat d'un composant. Il ne me dit pas si toute l'application fonctionne avec son routage, son authentification réelle et son rendu final dans un navigateur. Pour cela, je garde Playwright.


10. Ma stratégie actuelle

Aujourd'hui, j'utilise bUnit sur trois familles de composants :

  1. Composants de bibliothèque : boutons, grilles, dialogues, notifications, sélecteurs.
  2. Composants métier réutilisables : cartes, formulaires partiels, blocs de workflow.
  3. Composants à logique conditionnelle forte : droits, état de chargement, erreurs, callbacks.

Je ne cherche pas à atteindre 100 % de couverture sur le HTML. Je veux surtout sécuriser les contrats qui m'ont déjà coûté du temps en régression : un callback oublié, un bouton désactivé dans le mauvais état, une navigation incorrecte, une liste vide mal affichée ou un rendu asynchrone instable.

La combinaison qui fonctionne bien pour moi est la suivante :

flowchart TD
    A[Tests unitaires C#] --> B[Services et logique pure]
    B --> C[bUnit]
    C --> D[Contrat des composants Blazor]
    D --> E[Playwright]
    E --> F[Parcours critiques navigateur]
    F --> G[Confiance avant livraison]

bUnit se place donc entre les tests unitaires classiques et les tests navigateur. C'est exactement l'endroit où je veux tester mes composants Blazor : assez proche du rendu réel pour attraper les erreurs d'interface, mais assez rapide pour tourner en continu pendant le développement.

Conclusion

Tester des composants Blazor avec bUnit m'oblige à clarifier leur contrat. Je ne teste plus seulement une méthode C# : je rends un composant, je lui donne des paramètres, je simule les services dont il dépend, je déclenche les événements utilisateur et je vérifie ce que l'utilisateur ou le parent du composant reçoit.

Ce n'est pas un remplacement de Playwright, et ce n'est pas non plus un simple test unitaire classique. C'est un outil intermédiaire très efficace pour stabiliser une bibliothèque de composants et accélérer les refactorings.

Dans mes projets, le gain principal est très concret : je peux modifier un composant partagé avec beaucoup plus de confiance, parce que je sais immédiatement si son rendu, ses callbacks ou ses dépendances injectées ne respectent plus le contrat attendu.

Sources