Articoli di programmazione

Model-View-Presenter con Blazor

Una semplice implementazione MVP per rendere testabili le logiche UI in Blazor

list-3205464_1280.jpg
Quando ho iniziato a lavorare con Blazor mi sono subito reso conto che, sebbene la struttura a componenti, che ricorda molto quella di React, Vue.Js e Angular favorisse una buona separazione del codice, forse era il caso di strutturare un minimo le mie pagine per renderle testabili.
I miei obiettivi erano:
- rendere testabile il codice che implememta la logica della UI, perché sui Test non si scherza
- scrivere meno codice possibile per ottenere questo risultato, perché il tempo è denaro e non voglio sprecarne troppo per predisporre ogni pagina
- lasciare le cose semplici e lineari, per poterle eventualmente cambiare rapidamente con le nuove versioni di Blazor

La scelta è quindi ricaduta su un pattern Model-View-Presenter ridotto all'osso.

Per prima cosa ho creato la minuscola classe base del Presenter e l'interfaccia base per le mie View:

public abstract class BasePresenter<TView>
{
private TView view;
public TView View { get { return view; } }

public virtual async Task InitAsync(TView view)
{
this.view = view;
await Task.CompletedTask;
}
}

public interface IBaseView
{
void UpdateView();
}

Poi sono passato a scrivere il Presenter, basandomi sulla pagina di esempio del Blazor Client WebAssembly, che Visual Studio predispone per mostrare un esempio di Data Fetching.
L'obiettivo era quindi caricare la stessa griglia di dati che mostrava la pagina demo, inoltre mostrare un bottone di richiesta aiuto ed in risposta mostrare un semplice messaggio.

Per prima cosa il Presenter si fa iniettare il Client che effettua la lettura dati dal Web Service.
Quando il Presenter viene inizializzato nella View, questa deve passargli il proprio riferimento.
Appena inizializzato, il Presenter carica i dati Json dal Client.
Il Presenter infine espone un metodo che la View dovrà chiamare in risposta al click del bottone.
Ho inoltre predisposto l'interfaccia per questa View specifica.

public class FetchDataPresenter : BasePresenter<IFetchDataView>
{
private readonly JsonClient jsonClient;

public FetchDataPresenter(JsonClient jsonClient)
{
this.jsonClient = jsonClient;
}

public override async Task InitAsync(IFetchDataView view)
{
await base.InitAsync(view);

View.Forecasts = await jsonClient.LoadForecasts();
View.UpdateView();
}

public void BtnHelpClicked()
{
View.Message = "I'm here to help!";
View.UpdateView();
}
}

public interface IFetchDataView : IBaseView
{
WeatherForecast[] Forecasts { get; set; }
string Message { get; set; }
}

public class JsonClient
{
private readonly HttpClient http;
public JsonClient(HttpClient http)
{
this.http = http;
}

public JsonClient()
{ }

public virtual async System.Threading.Tasks.Task<WeatherForecast[]> LoadForecasts()
{
return await http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
}
}

Successivamente sono passato a modificare la pagina Blazor, montandoci il Presenter.
La pagina immplementa l'interfaccia IFetchDataView specifica.
Il Presenter viene iniettato tramite Dependncy Injection.
Al'inizializzazione della pagina viene inizializzato anche il Presenter.
Quando il Preenter richiede di fare un refresh della visualizzazione, tramite il metodo UpdateView, si notifica Blazor che lo stato è cambiato.

@page "/fetchdata"
@using BlazorWeb.Shared
@using BlazorWeb.Client.Presenters

@implements IFetchDataView
@inject FetchDataPresenter Presenter

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (Forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<button @onclick=Presenter.BtnHelpClicked class="btn btn-primary">Help!</button>
<div>@Message</div>

<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in Forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}

@code{
public WeatherForecast[] Forecasts { get; set; }
public string Message { get; set; } = "";

protected override async Task OnInitializedAsync()
{
await Presenter.InitAsync(this);
}

public void UpdateView()
{
StateHasChanged();
}
}

In questo modo la pagina torna a fare il proprio lavoro di View, minimale ed oserei dire stupida, che non implementa aluna logica ma semplicemente renderizza e formatta i dati che gli arrivano dal Presenter.
E' il Presenter che ingloba le logiche UI.
E' tutto più pulito e soprattutto, non è richiesto un overhead particolare, se non quello di implementare una intefaccia (interface) per ogni View, quindi per pagina o componente.
Possiamo spostare i nostri Presenter in un progetto a parte perché sono astrazioni che si adattano ad ogni altra UI, anche per altri dispositivi o framework.
Ma il vantaggio più grande è: ora possiamo testare facilmente la logica della UI. E prima questo non si poteva fare in modo agevole. Si poteva usare qualcosa come bUnit, per testare direttamente i controlli della UI e fare asserzioni sul loro contenuto successivamente allo scatenarsi di eventi, ma non è quello di cui abbiamo veramente bisogno.

Predisponiamo quindi un paio di Unit Test molto semplici.
Quando l'utente clicca il bottone di aiuto, la UI deve mostrare il messaggio "sono qui per aiutarti".
Quando la pagina viene inizializzata i dati devono essere caricati una volta dal Web Service.
Usiamo MOQ eper i Mock e gli Stub.

[TestClass]
public class PresenterTest
{
FetchDataPresenter presenter;
Mock<IFetchDataView> fetchDataViewMock;
Mock<JsonClient> jsonClientMock;

[TestInitialize]
public void TestInit()
{
fetchDataViewMock = new Mock<IFetchDataView>();
fetchDataViewMock.SetupProperty(x => x.Message);

jsonClientMock = new Mock<JsonClient>();
jsonClientMock.Setup(x => x.LoadForecasts()).Returns(Task.FromResult(new WeatherForecast[] { }));

presenter = new FetchDataPresenter(jsonClientMock.Object);
}

[TestMethod]
public void GivenPresenter_WhenUserClicksHelpButton_ThenUIShowsMessageImHereToHelp()
{
Assert.IsTrue(fetchDataViewMock.Object.Message == null);
Task.Run(() => presenter.InitAsync(fetchDataViewMock.Object)).Wait();

presenter.BtnHelpClicked();
Assert.AreEqual("I'm here to help!", fetchDataViewMock.Object.Message);
}

[TestMethod]
public void GivenPresenter_WhenInit_ThenShouldLoadDataFromClient()
{
Task.Run(() => presenter.InitAsync(fetchDataViewMock.Object)).Wait();
jsonClientMock.Verify( x => x.LoadForecasts());
}
}


Ora che abbiamo predisposto tutto, possiamo iniziare a costruire UI più complesse e soprattutto, adottare un approccio di design TDD, quindi partendo dal Test per scrivere l'implementazione del Presenter ed infine della View.
Avrei potuto adottare un Model-View-ViewModel invece di un Model-View-Presenter?
Certamente sì. Avrei scritto più codice ed avrei reso le cose un pelino più complesse, ma avrei anche guadagnato qualcosa dal punto di vista della pulizia dell'architettura

#blazor #MVP #TDD #testing #netcore #aspnetcore