Share via


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 das Testen mit einer anderen Datenbank als der, die in der Produktion verwendet wird (z. B. Sqlite), hier nicht behandelt wird, da die andere Datenbank als Testdouble verwendet wird; dieser Ansatz wird in Testen ohne Produktionsdatenbanksystem behandelt.

Die Haupthürde bei Tests, die eine echte Datenbank beinhalten, besteht darin, eine ordnungsgemäße Testisolation zu gewährleisten, damit sich die parallelen (oder sogar seriellen) Tests nicht gegenseitig stören. Der vollständige Beispielcode 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. Zwar ist es häufig einfach genug, die Datenbank über den regulären Installationsmechanismus zu installieren, aber für die meisten großen Datenbanken sind fertige Docker-Images verfügbar, die die Installation in der CI besonders einfach machen können. Für die Entwicklerumgebung GitHub Workspaces können Dev Container alle erforderlichen Dienste und Abhängigkeiten einrichten – einschließlich der Datenbank. Dies erfordert zwar eine anfängliche Investition in die Einrichtung, aber anschließend 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 der Verwendung von SQL Server kann LocalDB verwendet werden, um Tests lokal und praktisch ohne jegliche Einrichtung auszuführen. Die Datenbankinstanz wird bei Bedarf hochgefahren und spart möglicherweise Ressourcen auf weniger leistungsstarken Entwicklungsrechnern. LocalDB ist jedoch nicht ohne Probleme:

  • Sie unterstützt nicht alles, was von SQL Server Developer Edition unterstützt wird.
  • Es ist nur unter Windows verfügbar.
  • Sie kann beim ersten Testlauf Verzögerungen verursachen, 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 handhaben ist.

Wenn Sie eine Cloud-Datenbank verwenden, ist es in der Regel sinnvoll, mit einer lokalen Version der Datenbank zu testen, um die Geschwindigkeit zu erhöhen und die Kosten zu senken. Wenn Sie beispielsweise SQL Azure in der Produktion einsetzen, können Sie gegen einen lokal installierten SQL Server testen – die beiden sind sich extrem ähnlich (obwohl es trotzdem ratsam ist, Tests gegen SQL Azure selbst durchzuführen, bevor Sie in die Produktion gehen). Bei der Verwendung von Azure Cosmos DB ist der Azure Cosmos DB-Emulator ein nützliches Tool sowohl für die lokale Entwicklung als auch für die Ausführung von Tests.

Erstellen, Seeding 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 das Seeding genau einmal ausgeführt wird.

Bei Verwendung von Xunit kann dies über eine Klassenfixture erfolgen, die die Datenbank darstellt und über mehrere Testläufe hinweg 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 die oben genannte Fixture instanziiert wird, wird EnsureDeleted() verwendet, um die Datenbank abzulegen (falls sie aus einer vorherigen Ausführung vorhanden ist), und dann EnsureCreated(), um sie mit Ihrer neuesten Modellkonfiguration zu erstellen (siehe die Dokumente für diese 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 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);
}

Sie haben vielleicht schon bemerkt, dass die Erstellungslogik des Geräts gesperrt ist. Wenn die Fixture nur in einer einzigen Testklasse verwendet wird, ist es garantiert, dass es genau einmal von xUnit instanziiert wird; es ist jedoch üblich, dieselbe Datenbankfixture 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 nichts tatsächlich in die Datenbank committet wird, sehen andere Tests keine Änderungen und Störungen werden vermieden.

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

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

    return Ok();
}

Diese Methode kann mit dem folgenden Code getestet werden:

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

}

Einige Hinweise zum obigen Testcode:

  • Wir starten eine Transaktion, um sicherzustellen, dass die unten aufgeführten Änderungen nicht an die Datenbank committet werden und andere Tests nicht beeinträchtigen. Da die Transaktion nie committet wird, wird sie implizit am Ende des Tests zurückgesetzt, wenn die Kontextinstanz verworfen 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 auch 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 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();
}

Nehmen wir an, dass die Methode aus irgendeinem Grund die Verwendung einer serialisierbaren Transaktion erfordert (dies ist normalerweise nicht der Fall). Daher können wir keine Transaktion verwenden, um die Testisolation zu gewährleisten. Da der Test tatsächlich Änderungen an die Datenbank committet, definieren wir eine andere Fixture 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 Fixture ähnelt der oben verwendeten, enthält jedoch vor allem eine Cleanup-Methode. Wir rufen sie nach jedem Test auf, um sicherzustellen, dass die Datenbank auf den Startzustand zurückgesetzt wird.

Wenn diese Fixture nur von einer einzigen Testklasse verwendet wird, können wir sie als Klassenfixture wie oben referenzieren – xUnit parallelisiert Tests nicht innerhalb derselben Klasse (weitere Informationen zu Testsammlungen und Parallelisierung finden Sie in den xUnit-Dokumenten). Wenn wir diese Fixture 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 diese als xUnit-Sammlungsfixture statt als Klassenfixture.

Zunächst definieren wir eine Testsammlung, die auf unsere Fixture 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 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 unterschiedliche Fixtures 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. Test können somit fehlschlagen. Daher wird empfohlen, dies nur vorübergehend während des Entwicklungszyklus zu tun.

Effiziente Datenbankbereinigung

Wir haben oben gesehen, dass wir die Datenbank zwischen jedem Test bereinigen müssen, um Störungen zu vermeiden, wenn Änderungen tatsächlich in die Datenbank committet werden. 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. 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 am problematischsten, da sie eine Logik erfordern, die die Datenbank in ihren ursprünglichen Zustand zurücksetzt und die Parallelisierung deaktiviert.
  • 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 verändern, und möglicherweise auch, um unterschiedliche Konfigurationen der Seeddaten zu ermöglichen.
  • 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.