Freigeben über


Testen für Ihr Produktionsdatenbanksystem

Auf dieser Seite besprechen wir Techniken zum Schreiben automatisierter Tests, die das Datenbanksystem betreffen, mit dem die Anwendung in der Produktion ausgeführt wird. Es gibt alternative Testansätze, bei denen das Produktionsdatenbanksystem durch Testdoppel ausgetauscht wird; weitere Informationen finden Sie auf der Seite "Testübersicht ". Beachten Sie, dass Tests mit einer anderen Datenbank als das, was in der Produktion (z. B. Sqlite) verwendet wird, hier nicht behandelt werden, da die andere Datenbank als Testdoppel verwendet wird; Dieser Ansatz wird in Tests ohne Ihr Produktionsdatenbanksystem behandelt.

Die Haupthürde bei Tests, die eine echte Datenbank beinhalten, besteht darin, eine ordnungsgemäße Testisolation zu gewährleisten, sodass sich die parallelen (oder sogar seriellen) Tests nicht gegenseitig stören. Der vollständige Beispielcode für das untenstehende Beispiel kann hier angezeigt werden.

Tipp

Diese Seite zeigt xUnit-Techniken, aber ähnliche Konzepte sind in anderen Testframeworks vorhanden, einschließlich NUnit.

Einrichten ihres Datenbanksystems

Die meisten Datenbanksysteme können heutzutage sowohl in CI-Umgebungen als auch auf Entwicklercomputern problemlos installiert werden. Obwohl es häufig einfach genug ist, die Datenbank über den regulären Installationsmechanismus zu installieren, sind ready-to-use Docker-Images für die meisten wichtigen Datenbanken verfügbar und können die Installation in CI besonders einfach machen. Für die Entwicklerumgebung können GitHub-Arbeitsbereiche, Dev Container alle erforderlichen Dienste und Abhängigkeiten einrichten – einschließlich der Datenbank. Dies erfordert zwar eine anfängliche Investition in die Einrichtung, aber sobald dies abgeschlossen ist, haben Sie eine Arbeitstestumgebung und können sich auf wichtigere Dinge konzentrieren.

In bestimmten Fällen verfügen Datenbanken über eine Sonderedition oder Version, die zum Testen hilfreich sein kann. Bei Verwendung von SQL Server kann LocalDB verwendet werden, um Tests lokal ohne Setup auszuführen, die Datenbankinstanz bei Bedarf zu drehen und Ressourcen auf weniger leistungsfähigen Entwicklercomputern zu sparen. LocalDB ist jedoch nicht ohne probleme:

  • Es unterstützt nicht alles, was SQL Server Developer Edition tut.
  • Es ist nur unter Windows verfügbar.
  • Dies kann zu einer Verzögerung bei der ersten Testausführung führen, wenn der Dienst hochgefahren wird.

Es wird in der Regel empfohlen, SQL Server Developer Edition anstelle von LocalDB zu installieren, da sie den vollständigen SQL Server-Featuresatz bereitstellt und im Allgemeinen sehr einfach zu erledigen ist.

Wenn Sie eine Clouddatenbank verwenden, ist es in der Regel geeignet, eine lokale Version der Datenbank zu testen, um die Geschwindigkeit zu verbessern und die Kosten zu verringern. Wenn Sie beispielsweise SQL Azure in der Produktion verwenden, können Sie einen Test mit einem lokal installierten SQL Server durchführen – die beiden sind äußerst ähnlich (obwohl es immer noch sinnvoll ist, Tests mit SQL Azure selbst auszuführen, bevor sie in die Produktion gehen). Bei der Verwendung von Azure Cosmos DB ist der Azure Cosmos DB-Emulator sowohl für die lokale Entwicklung als auch für die Ausführung von Tests hilfreich.

Erstellen, Befüllen und Verwalten einer Testdatenbank

Nachdem Ihre Datenbank installiert wurde, können Sie sie in Ihren Tests verwenden. In den einfachsten Fällen verfügt Ihre Testsuite über eine einzelne Datenbank, die zwischen mehreren Tests über mehrere Testklassen hinweg gemeinsam genutzt wird. Daher benötigen wir eine Logik, um sicherzustellen, dass die Datenbank während der Lebensdauer des Testlaufs erstellt und genau einmal seediert wird.

Bei Verwendung von Xunit kann dies über eine Klassen-Fixture erfolgen, die die Datenbank darstellt und für mehrere Testläufe freigegeben wird.

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

    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);
}

Wenn das oben genannte Fixture instanziiert wird, nutzt es EnsureDeleted(), um die Datenbank zu löschen (falls diese aus einem vorherigen Durchlauf noch existiert), und anschließend EnsureCreated(), um sie mit Ihrer neuesten Modellkonfiguration zu erstellen (siehe die Dokumentation zu diesen APIs). Sobald die Datenbank erstellt ist, führt die Fixture ein Seeding mit Daten durch, die unsere Tests verwenden können. Es lohnt sich, einige Zeit über Ihre Seed-Daten nachzudenken, da eine spätere Änderung für einen neuen Test dazu führen kann, dass vorhandene Tests fehlschlagen.

Um die Fixture in einer Testklasse zu verwenden, implementieren Sie einfach IClassFixture über Ihren Fixturetyp, und xUnit fügt sie in Ihren Konstruktor ein:

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

    public TestDatabaseFixture Fixture { get; }

Ihre Testklasse verfügt jetzt über eine Fixture Eigenschaft, die von Tests verwendet werden kann, um eine voll funktionsfähige Kontextinstanz zu erstellen:

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

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

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

Sie haben vielleicht schon bemerkt, dass die Erstellungslogik des Geräts gesperrt ist. Wenn die Vorrichtung nur in einer einzigen Testklasse verwendet wird, ist es garantiert, dass sie genau einmal von xUnit instanziiert wird; es ist jedoch üblich, dieselbe Datenbankinstallation in mehreren Testklassen zu verwenden. xUnit stellt Sammlungsfixtures bereit, aber dieser Mechanismus verhindert, dass Ihre Testklassen parallel ausgeführt werden, was für die Testleistung wichtig ist. Um dies mit einer xUnit-Klassenfixture sicher zu handhaben, verwenden wir eine einfache Sperre für die Erstellung und das Seeding der Datenbank und ein statisches Flag, um sicherzustellen, dass wir dies nie zweimal tun müssen.

Tests, die Daten ändern

Im obigen Beispiel wurde ein schreibgeschützter Test gezeigt, bei dem es sich um den einfachen Fall aus einem Testisolationsstandpunkt handelt: Da keine Änderung vorgenommen wird, sind keine Teststörungen möglich. Im Gegensatz dazu sind Tests, die Daten ändern, problematischer, da sie sich gegenseitig stören können. Eine gängige Methode zum Isolieren von Schreibtests ist das Umschließen des Tests in einer Transaktion und das Zurücksetzen dieser Transaktion am Ende des Tests. Da tatsächlich nichts in die Datenbank geschrieben wird, sehen andere Tests keine Änderungen, und Interferenzen werden vermieden.

Hier ist eine Controllermethode, die unserer Datenbank einen Blog hinzufügt:

[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    await _context.SaveChangesAsync();

    return Ok();
}

Wir können diese Methode mit den folgenden Tests testen:

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

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

    context.ChangeTracker.Clear();

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

}

Einige Hinweise zum obigen Testcode:

  • Wir starten eine Transaktion, um sicherzustellen, dass die unten aufgeführten Änderungen nicht an der Datenbank gebunden sind und andere Tests nicht beeinträchtigen. Da die Transaktion nie festgeschrieben wird, wird sie am Ende des Tests implizit zurückgesetzt, wenn die Instanz des Kontexts aufgelöst wird.
  • Nachdem wir die gewünschten Aktualisierungen vorgenommen haben, löschen wir die Änderungsverfolgung der Kontextinstanz mit ChangeTracker.Clear, um sicherzustellen, dass wir den Blog tatsächlich aus der folgenden Datenbank laden. Stattdessen könnten wir zwei Kontextinstanzen verwenden, aber dann müssen wir sicherstellen, dass die gleiche Transaktion von beiden Instanzen verwendet wird.
  • Möglicherweise möchten Sie die Transaktion sogar im CreateContext der Fixture starten, sodass Tests eine Kontextinstanz erhalten, die bereits in einer Transaktion enthalten und für Updates bereit ist. Dies kann dazu beitragen, Fälle zu verhindern, in denen die Transaktion versehentlich vergessen wird, was zu Teststörungen führt, die schwer zu debuggen sind. Sie können ebenfalls Lese- und Schreibtests in verschiedenen Testklassen trennen.

Tests, die Transaktionen explizit verwalten

Es gibt eine letzte Kategorie von Tests, die eine zusätzliche Schwierigkeit darstellen: Tests, die Daten ändern und auch explizit Transaktionen verwalten. Da Datenbanken in der Regel keine geschachtelten Transaktionen unterstützen, ist es nicht möglich, Transaktionen für die Isolation wie oben zu verwenden, da sie von tatsächlichem Produktcode verwendet werden müssen. Obwohl diese Tests eher selten sind, ist es notwendig, sie auf besondere Weise zu behandeln: Sie müssen die Datenbank nach jedem Test aufräumen, und parallelisierung muss deaktiviert werden, damit diese Tests sich nicht gegenseitig stören.

Sehen wir uns die folgende Controllermethode als Beispiel an:

[HttpPost]
public async Task<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.
    await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

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

    blog.Url = url;
    await _context.SaveChangesAsync();

    await transaction.CommitAsync();
    return Ok();
}

Nehmen wir an, dass die Methode aus irgendeinem Grund eine serialisierbare Transaktion erfordert (dies ist in der Regel nicht der Fall). Daher können wir keine Transaktion verwenden, um die Testisolation zu gewährleisten. Da der Test tatsächlich Änderungen an der Datenbank übernimmt, definieren wir eine andere Einrichtung mit einer eigenen, separaten Datenbank, um sicherzustellen, dass wir die anderen oben gezeigten Tests nicht beeinträchtigen:

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

    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();
    }
}

Diese Vorrichtung ähnelt dem oben verwendeten, enthält jedoch eine Cleanup Methode. Wir rufen dies nach jedem Test auf, um sicherzustellen, dass die Datenbank auf den Startzustand zurückgesetzt wird.

Wenn diese Vorrichtung nur von einer einzigen Testklasse verwendet wird, können wir sie als Klasseneinrichtung wie oben referenzieren – xUnit parallelisiert Tests nicht innerhalb derselben Klasse (weitere Informationen zu Testsammlungen und Parallelisierung in den xUnit-Dokumenten). Wenn wir diese Einrichtung jedoch zwischen mehreren Klassen teilen möchten, müssen wir sicherstellen, dass diese Klassen nicht parallel ausgeführt werden, um Störungen zu vermeiden. Dazu verwenden wir dies als xUnit-Sammlungseinrichtung und nicht als Klasseneinrichtung.

Zunächst definieren wir eine Testauflistung, die auf unsere Vorrichtung verweist und von allen transaktionsalen Testklassen verwendet wird, die dies erfordern:

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

Wir verweisen nun auf die Testsammlung in unserer Testklasse und akzeptieren die Fixture im Konstruktor wie zuvor:

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

    public TransactionalTestDatabaseFixture Fixture { get; }

Schließlich machen wir unsere Testklasse verwerfbar, indem wir dafür sorgen, dass die Cleanup-Methode der Fixture nach jedem Test aufgerufen wird:

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

Beachten Sie, dass xUnit die Sammlungsfixture nur einmal instanziiert und wir daher keine Sperren für die Erstellung und das Seeding der Datenbank verwenden müssen.

Der vollständige Beispielcode für den obigen Code kann hier angezeigt werden.

Tipp

Wenn Sie mehrere Testklassen mit Tests haben, die die Datenbank ändern, können Sie diese weiterhin parallel ausführen, indem Sie unterschiedliche Geräte haben, die jeweils auf eine eigene Datenbank verweisen. Das Erstellen und Verwenden vieler Testdatenbanken ist nicht problematisch und sollte immer dann durchgeführt werden, wenn es hilfreich ist.

Effiziente Datenbankerstellung

In den obigen Beispielen haben wir vor dem Ausführen von Tests EnsureDeleted() und EnsureCreated() verwendet, um sicherzustellen, dass wir über eine aktuelle Testdatenbank verfügen. Diese Vorgänge können in bestimmten Datenbanken etwas langsam sein, was ein Problem sein kann, wenn Sie Codeänderungen durchlaufen und Tests immer wieder ausführen. Wenn dies der Fall ist, sollten Sie EnsureDeleted im Konstruktor Ihrer Fixture vorübergehend auskommentieren: Dadurch wird dieselbe Datenbank für Testläufe wiederverwendet.

Der Nachteil dieses Ansatzes besteht darin, dass ihr Datenbankschema, wenn Sie Ihr EF Core-Modell ändern, nicht auf dem neuesten Stand ist, und Tests können fehlschlagen. Daher wird nur empfohlen, dies vorübergehend während des Entwicklungszyklus zu tun.

Effiziente Datenbankbereinigung

Wir haben oben gesehen, dass wir, wenn Änderungen tatsächlich an die Datenbank gebunden sind, die Datenbank zwischen jedem Test bereinigen müssen, um Störungen zu vermeiden. Im obigen Transaktionstestbeispiel haben wir dies mithilfe von EF Core-APIs durchgeführt, um den Inhalt der Tabelle zu löschen:

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();

Dies ist in der Regel nicht die effizienteste Möglichkeit, eine Tabelle zu löschen. Wenn die Testgeschwindigkeit ein Problem darstellt, sollten Sie stattdessen unformatierte SQL verwenden, um die Tabelle zu löschen:

DELETE FROM [Blogs];

Sie können auch die Verwendung des Respawn-Pakets in Betracht ziehen, das eine Datenbank effizient löscht. Darüber hinaus ist es nicht erforderlich, dass Sie die tabellen angeben, die gelöscht werden sollen, und daher muss der Bereinigungscode nicht aktualisiert werden, wenn Tabellen zu Ihrem Modell hinzugefügt werden.

Zusammenfassung

  • Beim Testen mit einer echten Datenbank lohnt es sich, zwischen den folgenden Testkategorien zu unterscheiden:
    • Schreibgeschützte Tests sind relativ einfach und können immer parallel mit derselben Datenbank ausgeführt werden, ohne sich um die Isolation kümmern zu müssen.
    • Schreibtests sind problematischer, aber Transaktionen können verwendet werden, um sicherzustellen, dass sie ordnungsgemäß isoliert sind.
    • Transaktionstests sind das problematischste, was eine Logik erfordert, um die Datenbank wieder auf den ursprünglichen Zustand zurückzusetzen, sowie die Parallelisierung zu deaktivieren.
  • Durch die Trennung dieser Testkategorien in separate Klassen können Verwirrung und unbeabsichtigte Störungen zwischen Tests vermieden werden.
  • Überlegen Sie sich im Vorfeld, welche Seed-Testdaten Sie verwenden möchten, und versuchen Sie, Ihre Tests so zu schreiben, dass sie nicht zu oft abbrechen, wenn sich diese Daten ändern.
  • Verwenden Sie mehrere Datenbanken, um Tests zu parallelisieren, die die Datenbank ändern, und möglicherweise auch, um verschiedene Seed-Datenkonfigurationen zuzulassen.
  • Wenn die Testgeschwindigkeit ein Problem darstellt, sollten Sie sich effizientere Techniken zum Erstellen der Testdatenbank und zum Bereinigen der Daten zwischen Ausführungen ansehen.
  • Denken Sie immer daran, Parallelisierung und Isolation zu testen.