Test sul sistema di database di produzione

In questa pagina vengono illustrate le tecniche per la scrittura di test automatizzati che coinvolgono il sistema di database in cui viene eseguita l'applicazione nell'ambiente di produzione. Esistono approcci di test alternativi, in cui il sistema di database di produzione viene scambiato tramite il doppio del test; per altre informazioni, vedere la pagina di panoramica dei test. Si noti che i test su un database diverso rispetto a quello usato nell'ambiente di produzione (ad esempio Sqlite) non sono trattati qui, poiché il database diverso viene usato come doppio test; questo approccio è trattato in Test senza il sistema di database di produzione.

L'ostacolo principale con i test che implica un database reale consiste nel garantire un isolamento corretto dei test, in modo che i test in esecuzione in parallelo (o anche in serie) non interferiscano tra loro. Il codice di esempio completo per il codice seguente può essere visualizzato qui.

Suggerimento

Questa pagina illustra le tecniche xUnit , ma esistono concetti simili in altri framework di test, tra cui NUnit.

Configurazione del sistema di database

La maggior parte dei sistemi di database al giorno d'oggi può essere facilmente installata, sia negli ambienti CI che nei computer di sviluppo. Sebbene sia abbastanza facile installare il database tramite il normale meccanismo di installazione, le immagini Docker pronte per l'uso sono disponibili per la maggior parte dei database principali e possono semplificare l'installazione in CI. Per l'ambiente di sviluppo, Le aree di lavoro GitHub, Dev Container possono configurare tutti i servizi e le dipendenze necessari, incluso il database. Anche se questo richiede un investimento iniziale nella configurazione, una volta completato si dispone di un ambiente di test funzionante e può concentrarsi su elementi più importanti.

In alcuni casi, i database hanno una versione o un'edizione speciale che può essere utile per il test. Quando si usa SQL Server, è possibile usare Local DB per eseguire test in locale senza alcuna configurazione, attivando l'istanza del database su richiesta ed eventualmente risparmiando risorse in computer sviluppatori meno potenti. Tuttavia, Local DB non è senza problemi:

  • Non supporta tutte le funzionalità offerte da SQL Server Developer Edition.
  • È disponibile solo in Windows.
  • Può causare ritardi durante la prima esecuzione dei test mentre il servizio viene attivato.

È in genere consigliabile installare SQL Server Developer Edition anziché Local DB, poiché fornisce il set di funzionalità completo di SQL Server ed è in genere molto semplice da eseguire.

Quando si usa un database cloud, in genere è opportuno eseguire il test su una versione locale del database, sia per migliorare la velocità che per ridurre i costi. Ad esempio, quando si usa SQL Azure nell'ambiente di produzione, è possibile eseguire test su un'istanza di SQL Server installata in locale. I due sono estremamente simili, anche se è comunque consigliabile eseguire test su SQL Azure prima di passare all'ambiente di produzione. Quando si usa Azure Cosmos DB, l'emulatore di Azure Cosmos DB è uno strumento utile sia per lo sviluppo in locale che per l'esecuzione di test.

Creazione, seeding e gestione di un database di test

Dopo aver installato il database, è possibile iniziare a usarlo nei test. Nella maggior parte dei casi semplici, il gruppo di test ha un singolo database condiviso tra più test tra più classi di test, quindi è necessaria una logica per assicurarsi che il database venga creato e sottoposto a seeding esattamente una volta durante la durata dell'esecuzione del test.

Quando si usa Xunit, questa operazione può essere eseguita tramite una fixture di classe, che rappresenta il database e viene condivisa tra più esecuzioni di test:

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    context.AddRange(
                        new Blog { Name = "Blog1", Url = "http://blog1.com" },
                        new Blog { Name = "Blog2", Url = "http://blog2.com" });
                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

Quando viene creata un'istanza della fixture precedente, usa EnsureDeleted() per eliminare il database (nel caso esista da un'esecuzione precedente) e quindi EnsureCreated() per crearlo con la configurazione del modello più recente (vedere la documentazione per queste API). Dopo aver creato il database, la fixture ne esegue il seeding con alcuni dati che i test possono usare. Vale la pena dedicare del tempo a pensare ai dati di inizializzazione, perché la modifica successiva per un nuovo test potrebbe causare l'esito negativo dei test esistenti.

Per usare la fixture in una classe di test, è sufficiente implementare IClassFixture il tipo di fixture e xUnit lo inserisce nel costruttore:

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

La classe di test ha ora una Fixture proprietà che può essere usata dai test per creare un'istanza del contesto completamente funzionale:

[Fact]
public void GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = controller.GetBlog("Blog2").Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

Infine, potresti aver notato un blocco nella logica di creazione della fixture precedente. Se la fixture viene usata solo in una singola classe di test, è garantito che venga creata un'istanza esattamente una volta da xUnit; ma è comune usare la stessa fixture del database in più classi di test. xUnit fornisce fixture di raccolta, ma questo meccanismo impedisce l'esecuzione delle classi di test in parallelo, che è importante per le prestazioni dei test. Per gestirlo in modo sicuro con una fixture di classe xUnit, prendiamo un semplice blocco per la creazione e il seeding del database e usiamo un flag statico per assicurarsi che non sia mai necessario farlo due volte.

Test che modificano i dati

L'esempio precedente ha mostrato un test di sola lettura, che è il caso semplice dal punto di vista dell'isolamento del test: poiché non viene modificato nulla, l'interferenza dei test non è possibile. Al contrario, i test che modificano i dati sono più problematici, poiché possono interferire tra loro. Una tecnica comune per isolare i test di scrittura consiste nell'eseguire il wrapping del test in una transazione e di eseguire il rollback della transazione alla fine del test. Poiché non viene effettivamente eseguito il commit nel database, gli altri test non visualizzano modifiche ed è evitata alcuna interferenza.

Ecco un metodo controller che aggiunge un blog al database:

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

È possibile testare questo metodo con quanto segue:

[Fact]
public void AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = context.Blogs.Single(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

Alcune note sul codice di test precedente:

  • Si avvia una transazione per assicurarsi che le modifiche seguenti non vengano sottoposte a commit nel database e non interferiscano con altri test. Poiché la transazione non viene mai sottoposta a commit, viene eseguito il rollback implicito alla fine del test quando l'istanza di contesto viene eliminata.
  • Dopo aver apportato gli aggiornamenti desiderati, è possibile cancellare lo strumento di rilevamento delle modifiche dell'istanza del contesto con ChangeTracker.Clearper assicurarsi di caricare effettivamente il blog dal database seguente. È possibile usare invece due istanze di contesto, ma è necessario assicurarsi che la stessa transazione venga usata da entrambe le istanze.
  • È anche possibile avviare la transazione nell'oggetto della CreateContextfixture, in modo che i test ricevano un'istanza di contesto già in una transazione e siano pronti per gli aggiornamenti. Ciò consente di evitare casi in cui la transazione viene accidentalmente dimenticata, causando interferenze di test che possono essere difficili da eseguire durante il debug. È anche possibile separare test di sola lettura e scrittura in classi di test diverse.

Verifica che gestisca in modo esplicito le transazioni

Esiste una categoria finale di test che presenta una difficoltà aggiuntiva: i test che modificano i dati e gestiscono in modo esplicito le transazioni. Poiché i database in genere non supportano le transazioni annidate, non è possibile usare le transazioni per l'isolamento come indicato in precedenza, perché devono essere usate dal codice prodotto effettivo. Anche se questi test tendono ad essere più rari, è necessario gestirli in modo speciale: è necessario pulire il database allo stato originale dopo ogni test e la parallelizzazione deve essere disabilitata in modo che questi test non interferiscano tra loro.

Esaminiamo il metodo controller seguente come esempio:

[HttpPost]
public ActionResult UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

    var blog = _context.Blogs.FirstOrDefault(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

Si supponga che per qualche motivo, il metodo richiede l'uso di una transazione serializzabile (in genere non è il caso). Di conseguenza, non è possibile usare una transazione per garantire l'isolamento dei test. Poiché il test eseguirà effettivamente il commit delle modifiche nel database, verrà definita un'altra fixture con un database separato, per assicurarsi di non interferire con gli altri test già illustrati in precedenza:

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }
}

Questa fixture è simile a quella usata in precedenza, ma in particolare contiene un Cleanup metodo. Questa operazione verrà chiamata dopo ogni test per assicurarsi che il database venga reimpostato sullo stato iniziale.

Se questa fixture verrà usata solo da una singola classe di test, è possibile farvi riferimento come fixture di classe come sopra: xUnit non parallelizza i test all'interno della stessa classe (per altre informazioni sulle raccolte di test e sulla parallelizzazione nella documentazione xUnit). Se, tuttavia, si vuole condividere questa fixture tra più classi, è necessario assicurarsi che queste classi non vengano eseguite in parallelo, per evitare interferenze. A tale scopo, verrà usato come fixture di raccolta xUnit anziché come fixture di classe.

Prima di tutto, definiamo una raccolta di test che fa riferimento alla fixture e verrà usata da tutte le classi di test transazionali che lo richiedono:

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

A questo punto si fa riferimento alla raccolta di test nella classe di test e si accetta la fixture nel costruttore come prima:

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

Infine, si rende la classe di test eliminabile, disponendo il metodo della Cleanup fixture da chiamare dopo ogni test:

public void Dispose()
    => Fixture.Cleanup();

Si noti che poiché xUnit crea sempre un'istanza della fixture di raccolta una sola volta, non è necessario usare il blocco per la creazione e il seeding del database come è stato fatto in precedenza.

Il codice di esempio completo per quanto sopra può essere visualizzato qui.

Suggerimento

Se sono presenti più classi di test con test che modificano il database, è comunque possibile eseguirli in parallelo con fixture diverse, ognuno dei quali fa riferimento al proprio database. La creazione e l'uso di molti database di test non sono problematici e devono essere eseguite ogni volta che è utile.

Creazione efficiente del database

Negli esempi precedenti è stato usato EnsureDeleted() e EnsureCreated() prima di eseguire i test per assicurarsi di disporre di un database di test aggiornato. Queste operazioni possono risultare un po' lente in alcuni database, che possono essere un problema durante l'iterazione delle modifiche al codice e l'esecuzione di test in eccesso. In questo caso, è possibile impostare temporaneamente come commento EnsureDeleted nel costruttore della fixture: questo riuserà lo stesso database tra le esecuzioni di test.

Lo svantaggio di questo approccio è che se si modifica il modello di EF Core, lo schema del database non sarà aggiornato e i test potrebbero non riuscire. Di conseguenza, è consigliabile eseguire questa operazione solo temporaneamente durante il ciclo di sviluppo.

Pulizia efficiente del database

Si è visto sopra che quando le modifiche vengono effettivamente sottoposte a commit nel database, è necessario pulire il database tra ogni test per evitare interferenze. Nell'esempio di test transazionale precedente questa operazione è stata eseguita usando le API di EF Core per eliminare il contenuto della tabella:

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

context.AddRange(
    new Blog { Name = "Blog1", Url = "http://blog1.com" },
    new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();

Questo in genere non è il modo più efficiente per cancellare una tabella. Se la velocità di test è un problema, è consigliabile usare SQL non elaborato per eliminare la tabella:

DELETE FROM [Blogs];

È anche possibile prendere in considerazione l'uso del pacchetto respawn , che cancella in modo efficiente un database. Non è inoltre necessario specificare le tabelle da cancellare e quindi non è necessario aggiornare il codice di pulizia perché le tabelle vengono aggiunte al modello.

Riepilogo

  • Quando si esegue il test su un database reale, vale la pena distinguere tra le categorie di test seguenti:
    • I test di sola lettura sono relativamente semplici e possono essere sempre eseguiti in parallelo sullo stesso database senza doversi preoccupare dell'isolamento.
    • I test di scrittura sono più problematici, ma le transazioni possono essere usate per assicurarsi che siano isolate correttamente.
    • I test transazionali sono i più problematici, richiedendo la logica per ripristinare lo stato originale del database, oltre a disabilitare la parallelizzazione.
  • La separazione di queste categorie di test in classi separate può evitare confusione e interferenze accidentali tra i test.
  • Dare un po' di pensiero ai dati di test con seeding e provare a scrivere i test in modo da non interrompere troppo spesso se i dati di inizializzazione cambiano.
  • Usare più database per parallelizzare i test che modificano il database ed eventualmente anche per consentire configurazioni di dati di inizializzazione diverse.
  • Se la velocità dei test è un problema, è consigliabile esaminare tecniche più efficienti per la creazione del database di test e per la pulizia dei dati tra le esecuzioni.
  • Tenere sempre presente la parallelizzazione e l'isolamento dei test.