Articoli

Testing dei componenti Blazor con bUnit

da

Scrivere test unitari del nostro codice ci aiuta a renderlo più robusto ed a salvaguardare la nostra salute mentale nelle attività di refactoring, che sia per cambio di requisito o semplicemente migliorativo.

Unit test

“Lo unit test è il modo di testare un’unità – il più piccolo pezzo di codice che può essere logicamente isolato in un sistema.”
In Blazor i componenti rappresentano la più piccola unità di codice isolabile, anche se tecnicamente l’isolamento non è completo in quanto la dipendenza dal framework sottostante non è eliminabile. Questo definirebbe i nostri test degli integration test, ma possiamo però assumere che il framework sia sufficientemente testato e stabile per poter definire i nostri test sui componenti come unitari.

Per testare i componenti Blazor una delle librerie maggiormente utilizzate è bUnit, vediamo come utilizzarla nei nostri test.

Il Componente

Ipotizziamo di voler creare un componente Counter “evoluto” da riutilizzare nelle nostre applicazioni, che abbia le seguenti caratteristiche:

  • pulsate di incremento
  • pulsante di decremento
  • un parametro con il valore iniziale
  • un EventCallback con il valore corrente

Creiamo un nuovo progetto Razor Class Library ed aggiungiamo il nostro componente

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me to increment</button>
<button class="btn btn-secondary" @onclick="DecrementCount">Click me to decrement</button>

@code {
    [Parameter]
    public int DefaultCount { get; set; }

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

    private int currentCount;

    protected override void OnInitialized()
    {
        currentCount = DefaultCount;
    }

    private async Task IncrementCount()
    {
        currentCount++;
        if(OnNewCountValue.HasDelegate)
           await OnNewCountValue.InvokeAsync(currentCount);
    }

    private async Task DecrementCount()
    {
        currentCount--;
        if (OnNewCountValue.HasDelegate)
           await OnNewCountValue.InvokeAsync(currentCount);
    }
}

Testing

Aggiungiamo alla nostra solution un progetto di test, negli esempi che vedremo utilizzeremo NUnit ma è possibile utilizzare anche altri framework di testing quali xUnit.

Una volta creato e referenziato il progetto che contiene i componenti da testare, aggiungiamo i pacchetti nuget bUnit.Core e bUnit.Web al progetto di test. Fatto il Setup del progetto siamo pronti a scrivere il primo test: lo scopo sarà verificare che il DOM del nostro componente dopo l’inizializzazione sia quello atteso.

private TestContext ctx = new TestContext();

[SetUp]
public void SetUp()
{
    ctx = new TestContext();
}

[Test]
public void Init_ViewCountValueZero()
{
    var counter = ctx.RenderComponent<Counter>();

    counter.MarkupMatches(@"<h1>Counter</h1>
               <p role=""status"">Current count: 0</p>
               <button class=""btn btn-primary"">Click me to increment</button>
               <button class=""btn btn-secondary"">Click me to decrement</button>
");
}

Prima di tutto prepariamo l’ambiente definendo il contesto all’interno del quale andremo ad eseguire i test, sfruttando il framework NUnit definiamo un field ctx di tipo TestContext che verrà istanziato all’esecuzione di ogni test nel metodo SetUp decorato con l’attributo [SetUp]. La classe TestContext ci mette a disposizione una ambiente in cui eseguire i nostri componenti, un motore di Dependency Injection e altro ancora.

Il metodo RenderComponent istanzia il componente da testare all’interno del contesto e ritorna un oggetto di tipo IRenderedComponent<> con cui è possibile interagire per simulare i comportamenti da testare. Ottenuta l’istanza del nostro componente verifichiamo che, terminata la fase di inizializzazione, il DOM visualizzato sia esattamente quello che vogliamo. E’ possibile fare questa asserzione attraverso il metodo MarkupMatches.

Scriviamo adesso due test che abbiano lo scopo di verificare che, cliccando sui pulsanti incrementa/decremenenta, il nostro counter aggiorni il DOM con il valore corretto.

[Test]
public void ClicksButtonIncrement_ShowIncrementCounter()
{
    var counter = ctx.RenderComponent<Counter>();
    counter.Render();
    counter.SaveSnapshot();

    var button = counter.Find(".btn-primary");
    button.Click();

    var diff = counter.GetChangesSinceSnapshot();

    diff.ShouldHaveSingleTextChange("Current count: 1", null);
}

[Test]
public void ClicksButtonDecrement_ShowDecrementCounter()
{
    var counter = ctx.RenderComponent<Counter>();
    counter.Render();
    counter.SaveSnapshot();

    var button = counter.Find(".btn-secondary");
    button.Click();

    var diff = counter.GetChangesSinceSnapshot();

    diff.ShouldHaveSingleTextChange("Current count: -1", null);
}

Dopo aver inizializzato e renderizzato il nostro componente salviamo uno Snapshot del DOM con il metodo SaveSnapshot(). A questo punto, utilizzando il metodo Find, ricerchiamo e otteniamo (se esiste) un elemento del DOM utilizzando il selettore CSS. Ottenuto l’elemento del DOM possiamo interagire con esso ed invocarne il metodo Click.

Avendo salvato precedentemente il DOM, dopo l’inizializzazione del componente possiamo verificare le differenze con l’ultimo snapshot e fare l’asserzione su cosa sia cambiato, nel nostro caso il nuovo testo del counter. I test sono praticamente identici, ad eccezione del selettore CSS per individuare il Button con cui interagire ed, ovviamente, il testo atteso dall’asserzione.

Il componente che stiamo testando ha un parametro in ingresso ed una callback, vediamo quindi cosa il framework ci mette a disposizione per simulare il settaggio dei parametri e l’invocazione della callback.

[Test]
public void ParameterDefaultCountAndEventCallBack_ReturnCurrentValue()
{
    var ctx = new TestContext();
    var currentValue = -1;

    var defaultCount = ComponentParameterFactory.Parameter("DefaultCount", 10);
    var callback = ComponentParameterFactory.EventCallback("OnNewCountValue", (int value) => currentValue = value);
    var counter = ctx.RenderComponent<Counter>(defaultCount, callback);

    counter.MarkupMatches(@"<h1>Counter</h1>
               <p role=""status"">Current count: 10</p>
               <button class=""btn btn-primary"">Click me to increment</button>
               <button class=""btn btn-secondary"">Click me to decrement</button>");

    var button = counter.Find(".btn-primary");
    button.Click();

    Assert.AreEqual(11, currentValue);
    counter.MarkupMatches(@"<h1>Counter</h1>
               <p role=""status"">Current count: 11</p>
               <button class=""btn btn-primary"">Click me to increment</button>
               <button class=""btn btn-secondary"">Click me to decrement</button>");
}

La classe ComponentParameterFactory ci mette a disposizione una serie di metodi statici per definire parametri, callback e altro ancora come Cascade Parameter. Per definire il parametro passiamo al metodo Parameter il nome e il suo valore, mentre per la callback occorre anche definire la funzione che verrà eseguita.

In questo esempio utilizziamo una lambda con la firma corretta del parametro che aggiorni il valore di una variabile locale su cui fare all’asserzione.

Il metodo RenderComponent accetta un array di parametri, quindi passiamo i parametri precedentemente definiti al nostro context che inizializzerà il componente con i parametri opportuni. Possiamo fare una prima asserzione che verifica che il DOM prodotto contenga il valore di default che abbiamo passato al componente. Simulato il click su un pulsante verifichiamo che la callback sia invocata con il nuovo valore e che il DOM si aggiorni correttamente.

Dependency Injection

Immaginiamo, a scopo accademico, di voler disaccoppiare le logiche di incremento e decremento introducendo una dipendenza dalla seguente interfaccia

public interface IServiceCounter
{
    int Increment(int currentValue);
    int Decrement(int currentValue);
}

Quindi il nostro componente cambierebbe come segue

@inject IServiceCounter service
....

private async Task IncrementCount()
{
    currentCount = service.Increment(currentCount);
    if (OnNewCountValue.HasDelegate)
        await OnNewCountValue.InvokeAsync(currentCount);
}

private async Task DecrementCount()
{
    currentCount = service.Decrement(currentCount);
    if (OnNewCountValue.HasDelegate)
        await OnNewCountValue.InvokeAsync(currentCount);
}

Rilanciando i test andranno tutti in errore perchè non è stato istruito il motore di Dependecy Injection per risolvere la dipendenza del componente. Al momento la direttiva @inject non prevede la possibilità che la dipendenza non sia risolvibile e non valorizzerà mai con null il service (qui trovate una issue del repository di aspnet ).

Avendo aggiunto una dipendenza al nostro componente per garantire l’isolamento dell’ambiente di test al solo codice da testare, il componente, quello che dovremmo fare è implementare l’interfaccia all’interno del nostro test con un comportamento noto e prevedibile nel contesto del test. Moq è una libreria molto potente che ci permette, tra le altre cose, di implementare solo i metodi che ci occorrono di una determinata interfaccia. Aggiungiamo quindi al nostro progetto di test anche il nuget Moq, che utilizzeremo per creare un Mock dell’interfaccia, modificando il metodo di Setup come segue:

private TestContext ctx = new TestContext();
private Mock<IServiceCounter> service;

[SetUp]
public void SetUp()
{
    service = new Mock<IServiceCounter>();

    service.Setup(o => o.Increment(It.IsAny<int>()))
        .Returns((int v) => v + 1);
    service.Setup(o => o.Decrement(It.IsAny<int>()))
        .Returns((int v) => v - 1);

    ctx = new TestContext();
    ctx.Services.AddSingleton<IServiceCounter>(service.Object);
}

Utilizzando il paccheto Moq creiamo un oggetto che implementa dell’interfaccia IServiceCount e definiamo un implementazione per i due metodi che ci interessano ai fini dei nostri test, possiamo poi istruire il motore di dependency injection con l’oggetto creato.
Rilanciando i test vedremo che saranno di nuovo tutti “verdi”

Conclusioni

bUnit è un framework maturo per tutti gli scenari di testing dei nostri componenti e non può non essere preso in considerazione nel nostro toolkit di sviluppatori e amanti di Blazor. Come sempre potrete trovare i sorgenti a questo link.


Da buon capo scout, Buona Strada.