Rigenerare il database negli integration test di ASP.NET Core

di Moreno Gentili, in ASP.NET Core,

In un precedente script abbiamo visto come eseguire dei test di integrazione con xUnit (https://www.aspitalia.com/script/1351/Eseguire-Integration-Test-Progetto-ASP.NET-Core.aspx). Questo tipo di test mira a verificare che i vari componenti della nostra applicazione interagiscano correttamente tra loro, compreso il database. Infatti è importante arrivare a testare anche gli inserimenti, le modifiche e le eliminazioni delle righe nel database perché alcuni errori possono verificarsi proprio in quei momenti, come la violazione di un vincolo o il troncamento di un testo a causa di un campo con capienza limitata.

La sfida consiste nell'eseguire ogni test d'integrazione in perfetto isolamento, cioè fare in modo che il suo risultato non sia influenzato dal risultato dei test precedenti e dalle manipolazioni dei dati che hanno causato. Quindi, se vogliamo che i nostri test d'integrazione abbiano un esito predicibile, dobbiamo essere in grado di rigenerare il database locale prima che ogni test sia eseguito. A questo scopo [u]non[/u] dovremmo mai usare il database di produzione, dato che verrebbe distrutto, con conseguente perdita di tutti i dati contenuti. Usiamo invece un database di test predisposto allo scopo.

Rigenerare un database di test prima di ogni test

Se usiamo Entity Framework Core, questo compito sarà molto semplice perché possiamo contare sulle Migration ed eseguirle programmaticamente. L'intero database di test può essere quindi creato e distrutto usando la Database API del DbContext.

Iniziamo aprendo il progetto di test xUnit e in esso creiamo una nuova classe astratta chiamata ad esempio DatabaseTest. Facciamole implementare l'interfaccia IAsyncLifetime che ci porterà a definire due metodi:

  • InitializeAsync viene eseguito prima di ogni test, punto in cui potremo istanziare il DbContext ed eseguire le Migration per rigenerare il database;
  • DisposeAsync viene eseguito dopo ogni test, punto in cui distruggeremo il database di test generato poco prima.

Tale classe sfrutta anche l'oggetto WebApplicationFactory che abbiamo incontrato in un precedente articolo (https://www.aspitalia.com/articoli/asp.net-core/anteprima-aspnet-core-2-1-parte-2-p-3.aspx) e che ci servirà per preparare l'applicazione a ricevere le richieste sottoposte a test.

public class DatabaseTest<TDbContext, TStartup> : IAsyncLifetime
  where TDbContext : DbContext
  where TStartup : class
{
  protected readonly TDbContext dbContext;
  protected readonly HttpClient httpClient;
  private readonly DbContextOptions<TDbContext> dbContextOptions;
  private readonly IServiceScope serviceScope;

  public DatabaseTest(WebApplicationFactory<TStartup> factory)
  {
    //Creiamo le opzioni per il dbContext che contengono la
    //connection string al database di test
    this.dbContextOptions = CreateDbContextOptionsForTest();
    //Facciamo in modo che l'applicazione usi le opzioni appena create
    factory = ConfigureFactory(factory, this.dbContextOptions);
    //Creiamo uno scope per la dependency injection di ASP.NET Core
    serviceScope = factory.Services.CreateScope();
    //Otteniamo un riferimento al DbContext che useremo per inserire dati nel db
    this.dbContext = serviceScope.ServiceProvider.GetService<TDbContext>();
    //E creiamo un HttpClient per inviare richieste all'applicazione
    this.httpClient = factory.CreateClient();
  }

  public async Task InitializeAsync()
  {
    //Prima che venga eseguito il test, ricreiamo il database eseguendo le migration
    await this.dbContext.Database.MigrateAsync();
  }

  public async Task DisposeAsync()
  {
    //PERICOLO: Questo distruggerà il database al termine del test.
    //Assicurati di aver indicato una connection string a un database di test in CreateDbContextOptionsForTest.
    //Per sicurezza usiamo un if per verificare che nella connection string
    //appaia la parola "integration_test". La cautela non è mai troppa ;)
    string connectionString = this.dbContext.Database.GetDbConnection().ConnectionString;
    if (!connectionString.Contains("integration_test"))
    {
      throw new InvalidOperationException($"Attenzione! Questo non sembra un db di test: '{connectionString}'");
    }
    //Facciamo pulizia: eliminiamo il database
    //e distruggiamo lo scope in cui era stato creato il DbContext
    await this.dbContext.Database.EnsureDeletedAsync();
    serviceScope.Dispose();
  }

  private static WebApplicationFactory<TStartup> ConfigureFactory(WebApplicationFactory<TStartup> factory,
    DbContextOptions<TDbContext> dbContextOptions)
  {
    return factory.WithWebHostBuilder(webHostBuilder => {
      webHostBuilder.ConfigureServices((builderContext, services) => {
        //Rimuovo le DbContextOptions definite dall'applicazione
        services.Remove(services.Single(s => s.ServiceType == typeof(DbContextOptions<TDbContext>)));
        //E le sostituisco con quelle che hanno una connection string al db di test
        services.AddSingleton<DbContextOptions<TDbContext>>(dbContextOptions);
      });
    });
  }

  private static DbContextOptions<TDbContext> CreateDbContextOptionsForTest()
  {
    //Creiamo le DbContextOptions
    var optionsBuilder = new DbContextOptionsBuilder<TDbContext>();
    //Indichiamo una connection string che punti a un db di test
    var databaseFile = Path.Combine(Path.GetTempPath(), "integration_test.db");
    var connectionString = $"Data Source={databaseFile}";
    optionsBuilder.UseSqlite(connectionString);
    return optionsBuilder.Options;
  }
}

Come si vede, la proprietà Database del DbContext espone dei metodi come MigrateAsync ed EnsureDeletedAsync che ci permettono di ricreare e distruggere il database di test in maniera programmatica. Ora scriviamo una classe di test come la seguente, derivando dalla classe base DatabaseTest che abbiamo appena creato.

public class ProductTest : 
  DatabaseTest<ApplicationDbContext, Startup>,
  IClassFixture<WebApplicationFactory<Startup>>
{
  public ProductTest(WebApplicationFactory<Startup> fixture) : base (fixture) { }

  [Fact]
  public async Task ProductShouldBeAddedToCategory()
  {
    //ARRANGE
    //Preparo le condizioni iniziali del test inserendo
    //una o più entità nel database che è stato appena rigenerato
    var product = new Product { Id=1, Title = "New Product 1" };
    var category = new Category { Id=1, Title = "New Category 1" };
    this.dbContext.Add(product);
    this.dbContext.Add(category);
    await this.dbContext.SaveChangesAsync();
    
    //ACT
    //Ora esercito l'applicazione: invoco la sua Web API per
    //associare il prodotto alla categoria
    await this.httpClient.PostAsync("/api/product/1/category/1");

    //ASSERT
    //Verifico se nel database il prodotto risulta associato alla categoria
    var product = await this.dbContext.Products.FindAsync(1);
    Assert.Equal(1, product.CategoryId);
  }
}

Eseguire i test in maniera sequenziale

Il test runner di xUnit esegue i test contenuti nella stessa classe sequenzialmente, uno dopo l'altro. Se i test si trovano in classi diverse, allora verranno eseguiti in parallelo e questo potrebbe essere un problema se entrambi insistono sullo stesso database di test. Possiamo rendere sequenziali questi test associandoli ad una stessa collection. Il modo più facile per far questo è porre l'attributo Collection sulla classe DatabaseTest, come si vede nel seguente esempio.

[Collection("Database")]
public class DatabaseTest<TDbContext, TStartup> : ...

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Approfondimenti

I più letti di oggi