Testare componenti Blazor utilizzando bUnit

pubblicato da il 08/07/2021 alle 8:14

ASP.NET Core  Blazor 

Introduzione

Blazor è diventato, soprattutto nel mondo .NET, rapidamente molto popolare tra gli sviluppatori permettendo lo sviluppo di Single Page Applications (SPA) utilizzando C# come linguaggio principale.
Una delle domande che possono sorgere è se esista un modo per creare una suite di test automatici per i nostri componenti Blazor. Una possibile risposta a questo quesito è insita in una libreria che si chiama bUnit.

Cosa è bUnit

bUnit è una libreria di testing per componenti Blazor, il cui obiettivo è, per usare le parole del suo creatore, "make it easy to write comprehensive, stable unit tests".

Nella documentazione viene illustrato in modo dettagliato come aggiungere bUnit manualmente ad un progetto di test basato su xUnit, NUnit o MSTest oppure come utilizzare il template di progetto di bUnit installabile con il pacchetto bUnit.template.

Creiamo un progetto di test

Per questo articolo, prendiamo come riferimento un progetto di test che utilizza xUnit come tool di testing. Partiamo, quindi, creando un progetto xUnit utilizzando il comando:

dotnet new xunit -n MyTestProject

Procediamo quindi installando i pacchetti necessari mediante NuGet. Per far ciò posizioniamoci nella cartella del progetto di test creato in precedenza ed eseguiamo il comando:

dotnet add package bunit

Prima di iniziare a scrivere i nostri test dobbiamo:

  1. modificare lo SDK nel file csproj del progetto di test impostandolo a Microsoft.NET.Sdk.Razor
  2. aggiungere, al progetto di test appena creato, un riferimento al progetto che contiene i componenti che vogliamo testare

Test in C#

La prima modalità con la quale è possibile scrivere test in bUnit consiste nell'utilizzare le classi C# analogamente a come faremmo se stessimo scrivendo, utilizzando xUnit, un test non indirizzato a una particolare tecnologia.
Nel nostro progetto di test creiamo quindi una classe chiamata MyComponentTest e, al suo interno, definiamo un metodo MyComponent_Should_Render_Correctly decorato con l'attributo Fact di xUnit in questo modo:

[Fact]
public void MyComponent_Should_Render_Correctly()
{
  using var context = new TestContext();

  var component = context.RenderComponent<MyComponent>();
  component.MarkupMatches("<h1>Hello MyComponent</h1>");
}

Il codice mostrato crea un contesto di test che permette di renderizzare il componente Blazor oggetto di test con il metodo RenderComponent<TComponent>() e verificarne poi il markup invocando il metodo MarkupMatches.

Questo metodo al momento non compilerà perchè non abbiamo ancora creato il componente MyComponent all'interno del progetto Blazor referenziato dal progetto di test.
Creiamo quindi un file MyComponent.razor e al suo interno aggiungiamo il seguente markup:

<h1>Hello MyComponent</h1>

Fatto questo possiamo eseguire, mediante il comando dotnet test, il test che abbiamo scritto e verificare che abbia esito positivo.

bUnit ci offre inoltre la possibilità di evitare la creazione di un'istanza della classe TestContext in ogni metodo di test facendo ereditare direttamente la classe di test da essa. Il codice visto prima con questa modifica diventa il seguente:

public class MyComponentTest : TestContext
{
  [Fact]
  public void MyComponent_Should_Render_Correctly()
  {
    var component = context.RenderComponent<MyComponent>();
    component.MarkupMatches("<h1>Hello MyComponent</h1>");
  }
}

A questo punto proviamo ad aggiungere un Parameter al nostro componente Blazor, modificando il codice di quest'ultimo come segue:

<h1>Hello @Name</h1>

@code {
  [Parameter] public string Name { get; set; }
}

L'esecuzione del test questo fallirà poichè il parametro del componente non è ancora stato gestito. Per correggere il test, modifichiamo il codice del metodo nel seguente modo:

[Fact]
public void MyComponent_Should_Render_Correctly()
{
  var component = RenderComponent<MyComponent>(
    (nameof(MyComponent.Name), "MyComponent"));

  component.MarkupMatches("<h1>Hello MyComponent</h1>");
}

Come mostrato, un possibile modo per gestire i Parameter è consiste nel passare come tuple l'elenco dei parametri necessari al componente. Un approccio ulteriore è quello di specificare una Action come argomento del metodo, come mostrato di seguito:

var component = RenderComponent<MyComponent>(parameters =>
{
  parameters.Add(c => c.Name, "MyComponent");
});

Nel caso si trattasse di una istanza già presente del componente che voglio testare, potremmo utilizzare il metodo SetParametersAndRender(params ComponentParameter[] parameters) in questo modo:

component.SetParametersAndRender(ComponentParameter.CreateParameter(nameof(MyComponent.Name), "MyComponent"));

A questo punto, il test potrà essere eseguito con successo.

Se volessimo testare dei parametri di tipo EventCallback, potremmo utilizzare lo stesso approccio definendo della variabili di tipo Action e Action<T> da utilizzare come valori dei parametri. Ad esempio, aggiungiamo al nostro componente un button nel markup ed un parameter di tipo EventCallback<string> in questo modo:

<h1>Hello @Name</h1>
<button type="button" @onclick="ClickButton">click</button>

@code {
  [Parameter]
  public string Name { get; set; }

  [Parameter]
  public EventCallback<string> OnNameUppercased { get; set; }

  async Task ClickButton()
  {
    await OnNameUppercased.InvokeAsync(Name.ToUpper());
  }
}

Inoltre, nel caso volessimo testare che l'evento venga gestito correttamente potremmo aggiungere un nuovo test come il seguente:

[Fact]
public void MyComponent_Should_Raise_OnNameUppercased_Event_If_Button_Is_Clicked()
{
  Action<string> onNameUppercasedHandler = name => Assert.Equal("MyComponent".ToUpper(), name);

  var component = RenderComponent<MyComponent>(parameters =>
  {
    parameters
      .Add(c => c.Name, "MyComponent")
      .Add(c => c.OnNameUppercased, onNameUppercasedHandler);
  });

  component.Find("button").Click();
}

Test utilizzando file .razor

Un ulteriore modo offerto da bUnit per la scrittura dei test è quello che ci permette di definire i nostri test in file .razor. Con questo approccio il primo dei test visti in precedenza potrebbe essere irformulato così:

@code {
  [Fact]
  public void MyComponent_Should_Render_Correctly()
  {
    using var context = new TestContext();

    var component = context.Render(@<MyComponent Name="MyComponent" />);

    component.MarkupMatches(@<div>
      <h1>Hello MyComponent</h1>
      <button type="button">click</button>
    </div>);
  }
}

Come possiamo vedere, il metodo del test viene definito nella sezione code del file razor. Inoltre è possibile definire il componente da testare utilizzando direttamente il markup preceduto dal carattere @.

Come per i test scritti in un normale file di codice C#, anche in questo caso è possibile ereditare dalla classe TestContext specificando la direttiva @inherits in questo modo:

@inherits TestContext

@code {
  [Fact]
  public void MyComponent_Should_Render_Correctly()
  {
    var component = Render(@<MyComponent Name="MyComponent" />);

    component.MarkupMatches(@<div>
      <h1>Hello MyComponent</h1>
      <button type="button">click</button>
    </div>);
  }
}

Per quanto riguarda, invece, il test che si occupa di verificare il comportamento del parametro di tipo EventCallback il codice diventa il seguente:

@inherits TestContext

@code {
  [Fact]
  public void MyComponent_Should_Render_Correctly(){ ... }

  [Fact]
  public void MyComponent_Should_Raise_OnNameUppercased_Event_If_Button_Is_Clicked()
  {
    Action<string> onNameUppercasedHandler = name => Assert.Equal("MyComponent".ToUpper(), name);

    var component = Render(@<MyComponent Name="MyComponent"
                                         OnNameUppercased="onNameUppercasedHandler"/>);

    component.Find("button").Click();
  }
}

Gestire la Dependency Injection nei componenti

Oltre al rendering del componente e dei suoi Parameter, bUnit offre anche gli strumenti per gestire eventuali tipi registrati nel motore di IoC ed iniettati nei nostri componenti Blazor.
Per fare questo la classe TestContext espone la proprietà Services su cui possiamo andare a richiamare i metodi che siamo soliti usare in ASP.NET Core per registrare i servizi da iniettare poi tramite Dependency injection.

Ad esempio, per semplificare poniamo il caso che il componente Counter che ci offre il template di Visual Studio abbia una dipendenza ad un servizio ICounterService in questo modo:

@page "/counter"
@inject ICounterService 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 = counter.Increment(currentCount);
  }
}

Come possiamo vedere, al click del bottone viene chiamato il metodo Increment(currentCount) dell'istanza di ICounterService iniettata. A noi in questo caso serve testare che quando clicco il bottone Click me venga chiamato il metodo del servizio.
Con bUnit possiamo utilizzare il TestContext e aggiungere tramite la proprietà Services una implementazione fittizia oppure un mock creato con librerie come Moq:

@using Moq;

@code {
  [Fact]
  public void Click_Button_Should_Call_Increment_Method_On_Counter_Service()
  {
    var counterMock = new Mock<ICounterService>();

    using var context = new TestContext();
    context.Services.AddSingleton<ICounterService>(counterMock.Object);

    ...
  }
}

Se, invece, il nostro componente di test derivasse da TestContext, la proprietà Services diventerebbe utilizzabile direttamente in questo modo:

@using Moq;
@inherits TestContext

@code {
  [Fact]
  public void Click_Button_Should_Call_Increment_Method_On_Counter_Service()
  {
    var counterMock = new Mock<ICounterService>();

    Services.AddSingleton<ICounterService>(counterMock.Object);

    ...
  }
}

Aggiunta la dipendenza, possiamo a questo punto testare che il metodo Increment() venga chiamato correttamente al click del bottone, in questo modo:

@using Moq;
@inherits TestContext

@code {
  [Fact]
  public void Click_Button_Should_Call_Increment_Method_On_Counter_Service()
  {
    var counterMock = new Mock<ICounterService>();

    Services.AddSingleton<ICounterService>(counterMock.Object);

    var component = Render(@<Counter >/);
    component.Find("button").Click();

    counterMock.Verify(c => c.Increment(It.IsAny<int>()), Times.Once);
  }
}

Conclusioni

In questo articolo abbiamo abbiamo introdotto le funzionalità di base che bUnit offre per aggiungere una suite di test al nostro progetto Blazor.

Naturalmente, oltre a quelle descritte in questo articolo bUnit offre anche altre funzionalità interessanti: ad esempio, permette di emulare l'interfaccia IJSRuntime abilitando quindi la possibilità di scrivere test che verifichino il corretto funzionamento della parte di interoperabilità con Javascript, oppure permette di simulare la parte di Autenticazione/Autorizzazione esponendo dei metodi sulla classe TestContext che abilitano questa funzionalità.

Tramite l'installazione del pacchetto RichardSzalay.MockHttp, infine, è anche possibile creare un mock per la classe HttpClient che ci permetta di verificare il corretto comportamento dei nostri componenti che fanno uso di chiamate HTTP per recuperare informazioni.

Per approfondimenti, tutta la documentazione di bUnit è consultabile all'indirizzo https://bunit.dev.