Integration testing delle API ASP.NET Core - UGIdotNET
Questo sito si serve dei cookie per fornire servizi. Utilizzando questo sito acconsenti all'utilizzo dei cookie. Ulteriori informazioni Ok

Integration testing delle API ASP.NET Core

di pubblicato il 19/01/2023

ASP.NET Core 

In questo articolo utilizziamo xUnit come framework di testing per un progetto ASP.NET Core in .NET 7, che espone delle semplici API HTTP per la gestione di una ToDo list.

Introduzione

Conosciamo l'importanza del testing per le nostre applicazioni e sicuramente abbiamo scritto almeno una volta dei test automatici, molto probabilmente degli unit test, che verificassero il corretto funzionamento del nostro codice.

Ci potremmo però trovare nella situazione di dover testare la nostra applicazione ad un livello più ampio. Ad esempio, potremmo aver bisogno di verificare che degli endpoint HTTP rispondano correttamente, magari andando a recuperare o scrivere dei dati in un database.

In questo articolo vedremo come poter aggiungere degli integration test, scritti utilizzando xUnit come framework di testing, ad un progetto ASP.NET Core in .NET 7, che espone delle semplici API HTTP per la gestione di una ToDo list.

Cosa sono gli Integration test

Prima di partire proviamo a dare una definizione di cosa si intende con il termine "integration test".

Contrariamente agli unit test, i test di integrazione si occupano di verificare il corretto funzionamento della nostra applicazione ad un livello più ampio, includendo il più possibile anche l'infrastruttura su cui si poggia il nostro sistema come, ad esempio, il database.

Date queste premesse i nostri test dovranno verificare che invocando un determinato endpoint, la risposta, sia in termini di HTTP Status code che in termini di contenuto, sia quella attesa e che in caso di salvataggio di informazioni queste ultime vengano effettivamente persistite in un database di test.

Creazione e setup del progetto dei test

Come già descritto, la nostra applicazione è una semplice Web App ASP.NET Core che espone degli endpoint REST che ci permettono di gestire una ToDo list e che recuperano e salvano i dati in un database relazionale utilizzando Entity Framework Core.

Gli endpoint esposti sono i seguenti:

  • GET /api/todos: ottiene l'elenco di tutti i todo item
  • GET /api/todos/{id}: ottiene il dettaglio del todo item specificato dall'id
  • POST /api/todos: crea un nuovo todo item
  • PUT /api/todos/{id}: modifica il todo specificato dall'id
  • DELETE /api/todos/{id}: cancella il todo item specificato dall'id

 

Definito il nostro contesto andiamo a creare il nostro progetto di test, ad esempio utilizzando la .NET CLI nel seguente modo:

dotnet new xunit -n MyTestProject

E aggiungiamo a questo progetto appena creato il riferimento alla nostra Web application.

A questo punto aggiungiamo al nostro progetto di test il package Microsoft.AspNetCore.Mvc.Testing tramite NuGet.

Questo package ci permette di eseguire la web application in una classe TestServer, che esegue il codice ASP.NET Core in un server web in-memory e ci mette a disposizione la classe WebApplicationFactory<T> che ci servirà per poter interagire con la pipeline di ASP.NET Core e creare l'HttpClient che utilizzeremo per testare i nostri endpoint.

Aggiunto il package da NuGet andiamo a specificare nel nostro csproj di test Microsoft.NET.Sdk.Web come SDK:

<Project Sdk="Microsoft.NET.Sdk.Web">
  ....
</Project>

Aggiungiamo inoltre anche il package Microsoft.EntityFrameworkCore.InMemory per il provider in-memory di Entity Framework Core che permetterà di persistere i nostri dati in un database in memoria.

Scriviamo i nostri test di integrazione

A questo punto possiamo iniziare a scrivere il nostro primo test. Ad esempio potremmo verificare che la chiamata GET /api/todos risponda con uno status code 200 e contenga nel body l'elenco dei task che ci aspettiamo.
Creiamo la nostra classe TodoApiTest e aggiungiamo il metodo Get_Todos_Should_Response_With_Ok_Status_Code() che andrà a verificare le condizioni descritte prima:

[Fact]
public async Task Get_Todos_Should_Response_With_Ok_Status_Code()
{
  using var app = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
    {
      builder.ConfigureServices(services =>
      {
        var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TodoContext>));
        if (descriptor != null)
        {
          services.Remove(descriptor);
        }

        services.AddDbContext<TodoContext>(options => options.UseInMemoryDatabase("Todo-InMemory-Test"));
      });
    });

  var httpClient = app.CreateClient();

  var response = await httpClient.GetAsync("/api/todos");
  var items = await response.Content.ReadFromJsonAsync<IEnumerable<TodoItemModel>>();

  using var scope = app.Services.CreateScope();
  using var context = scope.ServiceProvider.GetRequiredService<TodoContext>();

  Assert.Equal(HttpStatusCode.OK, response.StatusCode);
  Assert.All(items, i => context.Todos.Select(t => t.Id).Contains(i.Id));
}

Come si può vedere dal codice, stiamo creando una istanza della classe WebApplicationFactory<T> specificando nel metodo WithWebHostBuilder la nostra pipeline di wireup che permette, ad esempio, di personalizzare le registrazioni dei nostri tipi nel motore di dependency injection.
Istanziato questo oggetto, viene utilizzato il metodo CreateClient() per creare una istanza della classe HttpClient e che ci permette di invocare gli endpoint HTTP da testare.

Soffermiamoci sulla creazione dell'istanza di WebApplicationFactory<Program> e cerchiamo di capire il motivo per cui ci mostra un errore.

Sappiamo che con le nuove versioni di .NET è stato introdotto il top level statement nel file Program.cs: questo significa che la nostra classe Program è implicita e viene creata come una classe partial e internal e non è quindi accessibile al di fuori dell'assembly della nostra web application.
Per risolvere questo problema possiamo agire in 2 modi:

  • Aggiungere il tag <InternalVisibleTo Include="NomeAssemblyDiTest" /> all'interno di un ItemGroup del csproj della nostra web application
  • Aggiungere una classe Program vuota che sia pubblica e partial subito dopo l'istruzione di app.Run()

 

Per le finalità di questo articolo scegliamo la seconda, modificando il file Program.cs dell'applicazione web come segue:

// ...

app.Run();

public partial class Program {}

A questo punto il nostro primo test dovrebbe compilare e concludersi con successo.

Passiamo verificare un altro caso d'uso.

Testiamo adesso che la chiamata in POST /api/todos, che va a creare un nuovo task, ci restituisca uno status code 201 in caso di successo e che il body della risposta contenga il modello creato.
Il codice del nostro test è il seguente:

[Fact]
public async Task Post_Todos_Should_Response_With_Created_Status_Code_And_Should_Return_The_Created_Item()
{
  using var app = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
    {
      builder.ConfigureServices(services =>
      {
        var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TodoContext>));
        if (descriptor != null)
        {
          services.Remove(descriptor);
        }

        services.AddDbContext<TodoContext>(options => options.UseInMemoryDatabase("Todo-InMemory-Test"));
      });
    });

  var httpClient = app.CreateClient();

  var model = new TodoItemModel { Title = "test creation", IsComplete = false };

  var response = await httpClient.PostAsJsonAsync("/api/todos", model);
  var responseContent = await response.Content.ReadFromJsonAsync<TodoItemModel>();

  Assert.Equal(HttpStatusCode.Created, response.StatusCode);
  Assert.Equal(model.Title, responseContent!.Title);
  Assert.NotEqual(0, responseContent!.Id);
}

Come possiamo vedere, facciamo il setup della nostra applicazione, creiamo l'HttpClient e invochiamo il metodo in POST, verificando alla fine che lo status code della risposta sia Created (201) e che, deserializzando il contenuto della risposta stessa, le informazioni contenute rispecchino il modello creato.

Sempre su questa chiamata è interessante verificare che se i dati passati nella richiesta non sono validi, la risposta che ci attendiamo deve essere una BadRequest (400) e che il body della richiesta contenga l'elenco degli errori. Il codice del nostro test potrebbe essere il seguente:

[Fact]
public async Task Post_Todos_Should_Response_With_Bad_Request_Status_Code_And_Should_Return_The_Validation_Errors()
{
  using var app = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
    {
      builder.ConfigureServices(services =>
      {
        var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TodoContext>));
        if (descriptor != null)
        {
          services.Remove(descriptor);
        }

        services.AddDbContext<TodoContext>(options => options.UseInMemoryDatabase("Todo-InMemory-Test"));
      });
    });

  var httpClient = app.CreateClient();

  var model = new TodoItemModel { Title = "", IsComplete = false };

  var response = await httpClient.PostAsJsonAsync("/api/todos", model);
  var responseContent = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();

  Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  Assert.Contains(nameof(model.Title), responseContent.Errors.Keys);
  Assert.Equal("title is required", responseContent.Errors[nameof(model.Title)].First());
}

Eseguiamolo e assicuriamoci che si concluda con successo.

Proviamo adesso a testare la chiamata di DELETE.
Nello specifico vogliamo verificare che in caso di successo, lo status code di risposta sia 200 e che effettivamente provando a recuperare nel database l'elemento interessato, quest'ultimo non sia più presente.
Il codice del nostro test è il seguente:

[Fact]
public async Task Delete_Todos_Should_Response_With_Ok_Status_Code_And_Remove_Todo_Item_Correctly()
{
  int todoItemId = 0;

  using var app = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
    {
      builder.ConfigureServices(services =>
      {
        var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TodoContext>));
        if (descriptor != null)
        {
          services.Remove(descriptor);
        }

        services.AddDbContext<TodoContext>(options => options.UseInMemoryDatabase("Todo-InMemory-Test"));

        using var scope = services.BuildServiceProvider().CreateScope();
        using var context = scope.ServiceProvider.GetRequiredService<TodoContext>();

        var item = new TodoItem { Title = "test", CreationDate = DateTime.UtcNow, IsComplete = false };
        context.Todos.Add(item);
        context.SaveChanges();

        todoItemId = item.Id;
      });
    });

  var httpClient = app.CreateClient();

  var response = await httpClient.DeleteAsync($"/api/todos/{todoItemId}");

  using var scope = app.Services.CreateScope();
  using var context = scope.ServiceProvider.GetRequiredService<TodoContext>();

  Assert.Equal(HttpStatusCode.Ok, response.StatusCode);
  Assert.DoesNotContain(context.Todos, t => t.Id == todoItemId);
}

Come possiamo vedere, a fronte della chiamata verifichiamo lo status code della risposta e andiamo a recuperare tramite IoC container l'istanza del DbContext di Entity Framework, verificando che l'elemento cancellato non esista più.

Creiamo una WebApplicationFactory custom

Possiamo notare come il codice di wireup della nostra classe di WebApplicationFactory sia molto simile tra i vari metodi e questo potrebbe portarci a chiedere se sia possibile portare a fattor comune il codice ripetuto.

Una soluzione è quella di creare una classe che estenda dalla WebApplicationFactory<T> ed istanziarla nei nostri test.
La nostra classe TodoApiWebApplicationFactory potrebbe essere scritta in questo modo:

public class TodoApiWebApplicationFactory : WebApplicationFactory<Program>
{
  protected override void ConfigureWebHost(IWebHostBuilder builder)
  {
    base.ConfigureWebHost(builder);

    builder.ConfigureServices(services =>
    {
      var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TodoContext>));
      if (descriptor != null)
      {
        services.Remove(descriptor);
      }

      services.AddDbContext<TodoContext>(options => options.UseInMemoryDatabase("Todo-InMemory-Test"));
    });
  }
}

Come è possibile vedere, facendo override del metodo ConfigureWebHost è possibile definire gli step di avvio della nostra web application e di conseguenza, andando a modificare i nostri test per istanziare questa nostra classe, tutti beneficeranno della stessa configurazione di avvio.

A questo punto potremmo però trovarci nella situazione di aver bisogno in test specifici di configurazioni dedicate, ad esempio per i test di "delete" dobbiamo aggiungere preventivamente al database l'elemento del quale dobbiamo verificare la corretta eliminazione.
In questo caso è possibile utilizzare il metodo WithWebHostBuilder sulla nostra istanza per personalizzare, dove necessario, eventuali configurazioni:

[Fact]
public async Task Delete_Todos_Should_Response_With_Ok_Status_Code_And_Remove_Todo_Item_Correctly()
{
  int todoItemId = 0;

  using var app = new TodoApiWebApplicationFactory()
    .WithWebHostBuilder(builder =>
    {
      builder.ConfigureServices(services =>
      {
        using var scope = services.BuildServiceProvider().CreateScope();
        using var context = scope.ServiceProvider.GetRequiredService<TodoContext>();

        var item = new TodoItem { Title = "test", CreationDate = DateTime.UtcNow, IsComplete = false };
        context.Todos.Add(item);
        context.SaveChanges();

        todoItemId = item.Id;
      });
    });

  var httpClient = app.CreateClient();

  var response = await httpClient.DeleteAsync($"/api/todos/{todoItemId}");

  using var scope = app.Services.CreateScope();
  using var context = scope.ServiceProvider.GetRequiredService<TodoContext>();

  Assert.Equal(HttpStatusCode.Ok, response.StatusCode);
  Assert.DoesNotContain(context.Todos, t => t.Id == todoItemId);
}

Una ulteriore miglioria da apportare potrebbe essere quella di utilizzare l'interfaccia IClassFixture<T> offerta da xUnit.
Facendo in modo che la nostra classe di test implementi l'interfaccia IClassFixture<TodoApiWebApplicationFactory> è possibile iniettare all'interno del costruttore della nostra classe una istanza di TodoApiWebApplicationFactory e riutilizzarla nei vari test.
Utilizzando questo approccio, inoltre, ci assicuriamo che venga invocato il Dispose al termine dei test.

public class TodoApiTest : IClassFixture<TodoApiWebApplicationFactory>
{
  private readonly TodoApiWebApplicationFactory _factory;

  public TodoApiTest(TodoApiWebApplicationFactory factory)
  {
    _factory = factory;
  }

  // Qui i nostri test...
}

Conclusioni

In questo articolo abbiamo introdotto la pratica dell'integration testing, descrivendo come è possibile implementare i nostri test di integrazione per le nostre applicazioni web ASP.NET Core.

Sulla documentazione ufficiale di Microsoft è inoltre presente un tutorial completo che descrive l'argomento, consultabile al seguente indirizzo https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-7.0.
Sono inoltre presenti ulteriori contenuti che trattano gli altri mondi relativi al testing come ad esempio unit test e stress tests.

Il codice utilizzato come esempio nell'articolo è consultabile su GitHub al repository https://github.com/albx/aspnetcore-integrationtesting-demo.