Condividi tramite


Testare ASP.NET app MVC core

Suggerimento

Questo contenuto è un estratto dell'eBook, Architect Modern Web Applications with ASP.NET Core and Azure, disponibile in .NET Docs o come PDF scaricabile gratuito che può essere letto offline.

Progettare applicazioni Web moderne con ASP.NET Core e anteprima della copertina di Azure eBook.

"Se non ti piacciono gli unit test del prodotto, probabilmente i tuoi clienti non piaceranno testarlo neanche." _-Anonimo-

Il software di qualsiasi complessità può non riuscire in modi imprevisti in risposta alle modifiche. Pertanto, i test dopo aver apportato modifiche sono necessari per tutte le applicazioni ma più semplici (o meno critiche). Il test manuale è il modo più lento, meno affidabile e più costoso per testare il software. Sfortunatamente, se le applicazioni non sono progettate per essere testabili, può essere l'unico mezzo di test disponibile. Le applicazioni scritte per seguire i principi architetturali descritti nel capitolo 4 devono essere ampiamente unit testabili. ASP.NET Applicazioni principali supportano l'integrazione automatizzata e i test funzionali.

Tipi di test automatizzati

Esistono molti tipi di test automatizzati per le applicazioni software. Il test di livello più semplice e più basso è lo unit test. A un livello leggermente superiore, sono presenti test di integrazione e test funzionali. Altri tipi di test, ad esempio test dell'interfaccia utente, test di carico, test di stress e smoke test, non rientrano nell'ambito di questo documento.

Test unità

Uno unit test testa una singola parte della logica dell'applicazione. È possibile descriverlo in modo più dettagliato elencando alcune delle cose che non lo sono. Uno unit test non testa il funzionamento del codice con le dipendenze o l'infrastruttura, ovvero ciò che servono i test di integrazione. Uno unit test non testa il framework in cui è scritto il codice. È consigliabile presupporre che funzioni o, se non lo si trova, inviare un bug e scrivere codice per una soluzione alternativa. Uno unit test viene eseguito completamente in memoria e in corso. Non comunica con il file system, la rete o un database. Gli unit test devono testare solo il codice.

Gli unit test, in virtù del fatto che testano solo una singola unità del codice, senza dipendenze esterne, devono essere eseguiti estremamente rapidamente. Pertanto, è necessario poter eseguire gruppi di test di centinaia di unit test in pochi secondi. Eseguirli spesso, idealmente prima di ogni push in un repository di controllo del codice sorgente condiviso e certamente con ogni compilazione automatizzata nel server di compilazione.

Test di integrazione

Anche se è consigliabile incapsulare il codice che interagisce con l'infrastruttura come database e file system, si avrà comunque un certo codice e probabilmente si vuole testarlo. Inoltre, è necessario verificare che i livelli del codice interagiscono come previsto quando le dipendenze dell'applicazione sono completamente risolte. Questa funzionalità è responsabilità dei test di integrazione. I test di integrazione tendono a essere più lenti e più difficili da configurare rispetto agli unit test, perché spesso dipendono da dipendenze esterne e dall'infrastruttura. Pertanto, è consigliabile evitare di testare elementi che potrebbero essere testati con unit test nei test di integrazione. Se è possibile testare uno scenario specifico con uno unit test, è necessario testarlo con uno unit test. In caso contrario, è consigliabile usare un test di integrazione.

I test di integrazione avranno spesso procedure di installazione e disinstallazione più complesse rispetto agli unit test. Ad esempio, un test di integrazione che viene eseguito su un database effettivo dovrà essere un modo per restituire il database a uno stato noto prima di ogni esecuzione del test. Man mano che vengono aggiunti nuovi test e lo schema del database di produzione si evolve, questi script di test tendono a crescere in dimensioni e complessità. In molti sistemi di grandi dimensioni, è poco pratico eseguire gruppi completi di test di integrazione nelle workstation di sviluppo prima di controllare le modifiche al controllo del codice sorgente condiviso. In questi casi, i test di integrazione possono essere eseguiti in un server di compilazione.

Test funzionali

I test di integrazione vengono scritti dal punto di vista dello sviluppatore per verificare che alcuni componenti del sistema funzionino correttamente insieme. I test funzionali vengono scritti dal punto di vista dell'utente e verificano la correttezza del sistema in base ai requisiti. L'estratto seguente offre un'analogia utile per pensare ai test funzionali, rispetto agli unit test:

"Molte volte lo sviluppo di un sistema è simile all'edificio di una casa. Anche se questa analogia non è del tutto corretta, è possibile estenderla ai fini della comprensione della differenza tra unit test e test funzionali. Gli unit test sono analoghi a un ispettore dell'edificio che visita il cantiere di una casa. Si concentra sui vari sistemi interni della casa, la fondazione, l'inquadratura, l'elettricità, l'idraulica e così via. Assicura (test) che le parti della casa funzioneranno correttamente e in modo sicuro, ovvero soddisfano il codice predefinito. I test funzionali in questo scenario sono analoghi ai proprietari di casa che visitano lo stesso cantiere. Presuppone che i sistemi interni si comportino in modo appropriato, che l'ispettore dell'edificio stia eseguendo il suo compito. Il proprietario di casa si concentra su ciò che sarà come vivere in questa casa. Si preoccupa di come la casa sembra, sono le varie camere una dimensione confortevole, fa la casa adatta alle esigenze della famiglia, sono le finestre in un buon posto per catturare il sole del mattino. Il proprietario sta eseguendo test funzionali sulla casa. Ha la prospettiva dell'utente. L'ispettore dell'edificio esegue unit test sulla casa. Ha la prospettiva del generatore".

Origine: unit test e test funzionali

Sono appassionato di dire "Come sviluppatori, falliamo in due modi: creiamo la cosa sbagliata, o creiamo la cosa sbagliata". Gli unit test assicurano di creare la cosa giusta; i test funzionali assicurano di creare la cosa giusta.

Poiché i test funzionali operano a livello di sistema, possono richiedere un certo grado di automazione dell'interfaccia utente. Come i test di integrazione, in genere funzionano anche con un certo tipo di infrastruttura di test. Questa attività li rende più lenti e fragili rispetto agli unit test e agli integration test. È necessario disporre di tutti i test funzionali necessari per avere la certezza che il sistema si comporti come previsto dagli utenti.

Piramide di test

Martin Fowler ha scritto sulla piramide dei test, un esempio di cui è illustrato nella figura 9-1.

Piramide di test

Figura 9-1. Piramide di test

I diversi livelli della piramide e le relative dimensioni rappresentano diversi tipi di test e quanti devono essere scritti per l'applicazione. Come si può notare, è consigliabile avere una grande base di unit test, supportata da un livello più piccolo di test di integrazione, con un livello ancora più piccolo di test funzionali. Ogni livello dovrebbe idealmente avere solo test in esso che non possono essere eseguiti adeguatamente a un livello inferiore. Tenere presente la piramide dei test quando si tenta di decidere quale tipo di test è necessario per uno scenario specifico.

Cosa testare

Un problema comune per gli sviluppatori che sono inesperti con la scrittura di test automatizzati sta arrivando con ciò che si vuole testare. Un buon punto di partenza consiste nel testare la logica condizionale. Ovunque sia presente un metodo con comportamento che cambia in base a un'istruzione condizionale (if-else, switch e così via), dovrebbe essere possibile ottenere almeno un paio di test che confermano il comportamento corretto per determinate condizioni. Se il codice presenta condizioni di errore, è consigliabile scrivere almeno un test per il "percorso felice" tramite il codice (senza errori) e almeno un test per il "percorso triste" (con errori o risultati atipici) per confermare che l'applicazione si comporta come previsto in caso di errori. Infine, provare a concentrarsi sui test che possono avere esito negativo, invece di concentrarsi sulle metriche come il code coverage. Più code coverage è migliore di meno, in genere. Tuttavia, la scrittura di alcuni altri test di un metodo complesso e business critical è in genere un uso migliore del tempo rispetto alla scrittura di test per le proprietà automatiche solo per migliorare le metriche di code coverage dei test.

Organizzazione dei progetti di test

I progetti di test possono essere organizzati in modo ottimale. È consigliabile separare i test in base al tipo (unit test, test di integrazione) e a ciò che stanno testando (per progetto, per spazio dei nomi). Se questa separazione è costituita da cartelle all'interno di un singolo progetto di test o da più progetti di test, è una decisione di progettazione. Un progetto è più semplice, ma per progetti di grandi dimensioni con molti test o per eseguire più facilmente set di test diversi, potrebbe essere necessario avere diversi progetti di test. Molti team organizzano i progetti di test in base al progetto che stanno testando, che per le applicazioni con più di alcuni progetti possono comportare un numero elevato di progetti di test, soprattutto se si suddivideno ancora in base al tipo di test in ogni progetto. Un approccio di compromissione consiste nell'avere un progetto per ogni tipo di test, per applicazione, con cartelle all'interno dei progetti di test per indicare il progetto (e la classe) da testare.

Un approccio comune consiste nell'organizzare i progetti dell'applicazione in una cartella "src" e i progetti di test dell'applicazione in una cartella parallela "test". Se si trova utile questa organizzazione, è possibile creare cartelle di soluzioni corrispondenti in Visual Studio.

Testare l'organizzazione nella soluzione

Figura 9-2. Testare l'organizzazione nella soluzione

È possibile usare qualsiasi framework di test preferito. Il framework xUnit funziona bene ed è ciò che tutti i test di ASP.NET Core e EF Core vengono scritti in. È possibile aggiungere un progetto di test xUnit in Visual Studio usando il modello illustrato nella figura 9-3 o dall'interfaccia della riga di comando usando dotnet new xunit.

Aggiungere un progetto di test xUnit in Visual Studio

Figura 9-3. Aggiungere un progetto di test xUnit in Visual Studio

Denominazione dei test

Denominare i test in modo coerente, con nomi che indicano le operazioni che ogni test esegue. Un approccio con cui ho avuto grande successo consiste nel assegnare un nome alle classi di test in base alla classe e al metodo che stanno testando. Questo approccio comporta molte classi di test di piccole dimensioni, ma rende estremamente chiaro ciò che ogni test è responsabile. Con il nome della classe di test configurato, per identificare la classe e il metodo da testare, è possibile usare il nome del metodo di test per specificare il comportamento sottoposto a test. Questo nome deve includere il comportamento previsto ed eventuali input o presupposti che dovrebbero produrre questo comportamento. Alcuni nomi di test di esempio:

  • CatalogControllerGetImage.CallsImageServiceWithId

  • CatalogControllerGetImage.LogsWarningGivenImageMissingException

  • CatalogControllerGetImage.ReturnsFileResultWithBytesGivenSuccess

  • CatalogControllerGetImage.ReturnsNotFoundResultGivenImageMissingException

Una variante di questo approccio termina ogni nome della classe di test con "Should" e modifica leggermente il tempo teso:

  • CatalogControllerGetImage Dovere.ChiamareImageServiceWithId

  • CatalogControllerGetImage Dovere.RegistroWarningGivenImageMissingException

Alcuni team trovano il secondo approccio di denominazione più chiaro, anche se leggermente più dettagliato. In ogni caso, provare a usare una convenzione di denominazione che fornisce informazioni dettagliate sul comportamento dei test, in modo che quando uno o più test abbiano esito negativo, è ovvio dai nomi che hanno avuto esito negativo. Evitare di denominare i test in modo vago, ad esempio ControllerTests.Test1, perché questi nomi non offrono alcun valore quando vengono visualizzati nei risultati dei test.

Se si segue una convenzione di denominazione come quella precedente che produce molte classi di test di piccole dimensioni, è consigliabile organizzare ulteriormente i test usando cartelle e spazi dei nomi. La figura 9-4 mostra un approccio all'organizzazione dei test per cartella all'interno di diversi progetti di test.

Organizzazione delle classi di test per cartella in base alla classe sottoposta a test

Figura 9-4. Organizzazione delle classi di test per cartella in base alla classe sottoposta a test.

Se una determinata classe applicazione ha molti metodi da testare (e quindi molte classi di test), può essere opportuno inserire queste classi in una cartella corrispondente alla classe dell'applicazione. Questa organizzazione non è diversa da come è possibile organizzare i file in cartelle altrove. Se sono presenti più di tre o quattro file correlati in una cartella contenente molti altri file, è spesso utile spostarli nella propria sottocartella.

Unit testing ASP.NET app Core

In un'applicazione ASP.NET Core ben progettata, la maggior parte della complessità e della logica di business verrà incapsulata in entità aziendali e in un'ampia gamma di servizi. L'ASP.NET'app Core MVC stessa, con controller, filtri, modelli di visualizzazione e visualizzazioni, deve richiedere alcuni unit test. Gran parte delle funzionalità di una determinata azione si trova all'esterno del metodo di azione stesso. Verificare se il routing o la gestione degli errori globali funzionano correttamente con uno unit test. Analogamente, tutti i filtri, inclusi i filtri di convalida e autorizzazione del modello, non possono essere sottoposti a unit test con un metodo di azione di un controller di destinazione. Senza queste fonti di comportamento, la maggior parte dei metodi di azione deve essere semplicemente piccola, delegando la maggior parte del lavoro ai servizi che possono essere testati indipendentemente dal controller che li usa.

In alcuni casi è necessario effettuare il refactoring del codice per eseguire unit test. Questa attività comporta spesso l'identificazione delle astrazioni e l'uso dell'inserimento delle dipendenze per accedere all'astrazione nel codice che si vuole testare, anziché scrivere codice direttamente sull'infrastruttura. Si consideri ad esempio questo metodo di azione semplice per la visualizzazione delle immagini:

[HttpGet("[controller]/pic/{id}")]
public IActionResult GetImage(int id)
{
  var contentRoot = _env.ContentRootPath + "//Pics";
  var path = Path.Combine(contentRoot, id + ".png");
  Byte[] b = System.IO.File.ReadAllBytes(path);
  return File(b, "image/png");
}

Il testing unità di questo metodo è reso difficile dalla dipendenza diretta da System.IO.File, che usa per leggere dal file system. È possibile testare questo comportamento per assicurarsi che funzioni come previsto, ma farlo con i file reali è un test di integrazione. Vale la pena notare che non è possibile eseguire unit test della route di questo metodo. A breve si vedrà come eseguire questo test con un test funzionale.

Se non è possibile eseguire unit test direttamente del comportamento del file system e non è possibile testare la route, cosa c'è da testare? Dopo il refactoring per rendere possibile il testing unità, è possibile individuare alcuni test case e comportamenti mancanti, ad esempio la gestione degli errori. Cosa fa il metodo quando non viene trovato un file? Cosa deve fare? In questo esempio il metodo refactoring è simile al seguente:

[HttpGet("[controller]/pic/{id}")]
public IActionResult GetImage(int id)
{
  byte[] imageBytes;
  try
  {
    imageBytes = _imageService.GetImageBytesById(id);
  }
  catch (CatalogImageMissingException ex)
  {
    _logger.LogWarning($"No image found for id: {id}");
    return NotFound();
  }
  return File(imageBytes, "image/png");
}

_logger e _imageService vengono entrambi inseriti come dipendenze. È ora possibile verificare che lo stesso ID passato al metodo di azione venga passato a _imageServicee che i byte risultanti vengano restituiti come parte di FileResult. È anche possibile verificare che la registrazione degli errori venga eseguita come previsto e che venga restituito un NotFound risultato se l'immagine non è presente, presupponendo che questo comportamento sia importante, ovvero non solo codice temporaneo aggiunto dallo sviluppatore per diagnosticare un problema. La logica dei file effettiva è stata spostata in un servizio di implementazione separato ed è stata aumentata per restituire un'eccezione specifica dell'applicazione per il caso di un file mancante. È possibile testare questa implementazione in modo indipendente, usando un test di integrazione.

Nella maggior parte dei casi, è consigliabile usare gestori di eccezioni globali nei controller, quindi la quantità di logica in esse deve essere minima e probabilmente non vale la pena eseguire unit test. Eseguire la maggior parte dei test delle azioni del controller usando test funzionali e la TestServer classe descritta di seguito.

Test di integrazione ASP.NET app Core

La maggior parte dei test di integrazione nelle app ASP.NET Core deve essere costituita dai servizi di test e da altri tipi di implementazione definiti nel progetto Infrastruttura. Ad esempio, è possibile verificare che EF Core sia stato aggiornato e recuperato correttamente i dati previsti dalle classi di accesso ai dati che risiedono nel progetto Infrastruttura. Il modo migliore per verificare che il progetto MVC di ASP.NET Core si comporti correttamente è con test funzionali eseguiti sull'app in esecuzione in un host di test.

Test funzionali ASP.NET app Core

Per le applicazioni ASP.NET Core, la TestServer classe rende i test funzionali abbastanza facili da scrivere. È possibile configurare un TestServer oggetto usando direttamente WebHostBuilder (o HostBuilder) (come si fa normalmente per l'applicazione) o con il WebApplicationFactory tipo (disponibile dalla versione 2.1). Provare a associare l'host di test all'host di produzione nel modo più vicino possibile, in modo che il comportamento dei test sia simile a quello che l'app eseguirà nell'ambiente di produzione. La WebApplicationFactory classe è utile per configurare ContentRoot di TestServer, che viene usata da ASP.NET Core per individuare una risorsa statica come Views.

È possibile creare test funzionali semplici creando una classe di test che implementa IClassFixture<WebApplicationFactory<TEntryPoint>>, dove TEntryPoint è la classe dell'applicazione Startup Web. Con questa interfaccia sul posto, la fixture di test può creare un client usando il metodo della CreateClient factory:

public class BasicWebTests : IClassFixture<WebApplicationFactory<Program>>
{
  protected readonly HttpClient _client;

  public BasicWebTests(WebApplicationFactory<Program> factory)
  {
    _client = factory.CreateClient();
  }

  // write tests that use _client
}

Suggerimento

Se si usa una configurazione API minima nel file di Program.cs , per impostazione predefinita la classe verrà dichiarata interna e non sarà accessibile dal progetto di test. È possibile scegliere qualsiasi altra classe di istanza nel progetto Web oppure aggiungerla al file Program.cs :

// Make the implicit Program class public so test projects can access it
public partial class Program { }

Spesso è consigliabile eseguire alcune configurazioni aggiuntive del sito prima dell'esecuzione di ogni test, ad esempio la configurazione dell'applicazione per l'uso di un archivio dati in memoria e il seeding dell'applicazione con dati di test. Per ottenere questa funzionalità, creare una sottoclasse personalizzata di ed eseguire l'override del WebApplicationFactory<TEntryPoint> relativo ConfigureWebHost metodo. L'esempio seguente proviene dal progetto eShopOnWeb FunctionalTests e viene usato come parte dei test nell'applicazione Web principale.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.eShopWeb.Infrastructure.Data;
using Microsoft.eShopWeb.Infrastructure.Identity;
using Microsoft.eShopWeb.Web;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;

namespace Microsoft.eShopWeb.FunctionalTests.Web;
public class WebTestFixture : WebApplicationFactory<Startup>
{
  protected override void ConfigureWebHost(IWebHostBuilder builder)
  {
    builder.UseEnvironment("Testing");

    builder.ConfigureServices(services =>
    {
      services.AddEntityFrameworkInMemoryDatabase();

      // Create a new service provider.
      var provider = services
            .AddEntityFrameworkInMemoryDatabase()
            .BuildServiceProvider();

      // Add a database context (ApplicationDbContext) using an in-memory
      // database for testing.
      services.AddDbContext<CatalogContext>(options =>
      {
        options.UseInMemoryDatabase("InMemoryDbForTesting");
        options.UseInternalServiceProvider(provider);
      });

      services.AddDbContext<AppIdentityDbContext>(options =>
      {
        options.UseInMemoryDatabase("Identity");
        options.UseInternalServiceProvider(provider);
      });

      // Build the service provider.
      var sp = services.BuildServiceProvider();

      // Create a scope to obtain a reference to the database
      // context (ApplicationDbContext).
      using (var scope = sp.CreateScope())
      {
        var scopedServices = scope.ServiceProvider;
        var db = scopedServices.GetRequiredService<CatalogContext>();
        var loggerFactory = scopedServices.GetRequiredService<ILoggerFactory>();

        var logger = scopedServices
            .GetRequiredService<ILogger<WebTestFixture>>();

        // Ensure the database is created.
        db.Database.EnsureCreated();

        try
        {
          // Seed the database with test data.
          CatalogContextSeed.SeedAsync(db, loggerFactory).Wait();

          // seed sample user data
          var userManager = scopedServices.GetRequiredService<UserManager<ApplicationUser>>();
          var roleManager = scopedServices.GetRequiredService<RoleManager<IdentityRole>>();
          AppIdentityDbContextSeed.SeedAsync(userManager, roleManager).Wait();
        }
        catch (Exception ex)
        {
          logger.LogError(ex, $"An error occurred seeding the " +
                    "database with test messages. Error: {ex.Message}");
        }
      }
    });
  }
}

I test possono usare questo oggetto WebApplicationFactory personalizzato usandolo per creare un client e quindi effettuare richieste all'applicazione usando questa istanza client. L'applicazione avrà i dati di cui è possibile eseguire il seeding che può essere usato come parte delle asserzioni del test. Il test seguente verifica che la home page dell'applicazione eShopOnWeb venga caricata correttamente e includa un elenco di prodotti aggiunto all'applicazione come parte dei dati di inizializzazione.

using Microsoft.eShopWeb.FunctionalTests.Web;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

namespace Microsoft.eShopWeb.FunctionalTests.WebRazorPages;
[Collection("Sequential")]
public class HomePageOnGet : IClassFixture<WebTestFixture>
{
  public HomePageOnGet(WebTestFixture factory)
  {
    Client = factory.CreateClient();
  }

  public HttpClient Client { get; }

  [Fact]
  public async Task ReturnsHomePageWithProductListing()
  {
    // Arrange & Act
    var response = await Client.GetAsync("/");
    response.EnsureSuccessStatusCode();
    var stringResponse = await response.Content.ReadAsStringAsync();

    // Assert
    Assert.Contains(".NET Bot Black Sweatshirt", stringResponse);
  }
}

Questo test funzionale esegue l'intero stack di applicazioni ASP.NET Core MVC/Razor Pages, inclusi tutti i middleware, i filtri e i binder che potrebbero essere presenti. Verifica che una determinata route ("/") restituisca il codice di stato di esito positivo previsto e l'output HTML. Lo fa senza configurare un server Web reale ed evita gran parte della fragilità che l'uso di un server Web reale per i test può verificarsi (ad esempio, problemi con le impostazioni del firewall). I test funzionali eseguiti su TestServer sono in genere più lenti rispetto all'integrazione e agli unit test, ma sono molto più veloci rispetto ai test eseguiti in rete in un server Web di test. Usare test funzionali per assicurarsi che lo stack front-end dell'applicazione funzioni come previsto. Questi test sono particolarmente utili quando si trova la duplicazione nei controller o nelle pagine e si risolve la duplicazione aggiungendo filtri. Idealmente, questo refactoring non modificherà il comportamento dell'applicazione e una suite di test funzionali verificherà questo è il caso.

Riferimenti: testare ASP.NET app MVC core