Freigeben über


Testen ohne Ihr Produktionsdatenbanksystem

Auf dieser Seite besprechen wir Techniken zum Schreiben automatisierter Tests, die das Datenbanksystem, auf dem die Anwendung in der Produktion läuft, nicht einbeziehen, indem die Datenbank mit einem Test-Double ausgetauscht wird. Es gibt verschiedene Arten von Test-Doubles und Ansätze, um dies zu tun, und es wird empfohlen, Auswahl einer Teststrategie gründlich zu lesen, um die verschiedenen Optionen vollständig zu verstehen. Schließlich ist es auch möglich, das Produktionsdatenbanksystem zu testen. Dies wird in Testen für Ihr Produktionsdatenbanksystem behandelt.

Tipp

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

Repositorymuster

Wenn Sie sich entschieden haben, Tests ohne Einbeziehung Ihres Produktionsdatenbanksystems zu schreiben, ist die empfohlene Technik dafür das Repositorymuster. Weitere Informationen hierzu finden Sie in diesem Abschnitt. Der erste Schritt bei der Implementierung des Repository-Musters besteht darin, Ihre EF Core-LINQ-Abfragen in eine separate Schicht auszulagern, die wir später als Stub oder Mock verwenden werden. Hier ist ein Beispiel für eine Repository-Schnittstelle für unser Bloggingsystem:

public interface IBloggingRepository
{
    Task<Blog> GetBlogByNameAsync(string name);

    IAsyncEnumerable<Blog> GetAllBlogsAsync();

    void AddBlog(Blog blog);

    Task SaveChangesAsync();
}

... und hier ist eine partielle Beispielimplementierung für die Produktionsverwendung:

public class BloggingRepository : IBloggingRepository
{
    private readonly BloggingContext _context;

    public BloggingRepository(BloggingContext context)
        => _context = context;

    public async Task<Blog> GetBlogByNameAsync(string name)
        => await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);

    // Other code...
}

Es ist ganz einfach: Das Repository umschließt einfach einen EF Core-Kontext und stellt Methoden bereit, die darauf die Datenbankabfragen und -aktualisierungen ausführen. Ein wichtiger Punkt ist, dass unsere GetAllBlogs-Methode IAsyncEnumerable<Blog> (oder IEnumerable<Blog>) zurückgibt und nicht IQueryable<Blog>. Die Rückgabe des Letzteren würde bedeuten, dass Abfrageoperatoren weiterhin über das Ergebnis erstellt werden können, sodass EF Core immer noch an der Übersetzung der Abfrage beteiligt ist. Dies würde den Zweck eines Repositorys zunichte machen. IAsyncEnumerable<Blog> ermöglicht es uns, einfach als Stub oder Mock zu verwenden, was das Repository zurückgibt.

Für eine ASP.NET Core-Anwendung müssen wir das Repository als Dienst in der Dependency Injection registrieren, indem wir Folgendes zu den ConfigureServices der Anwendung hinzufügen:

services.AddScoped<IBloggingRepository, BloggingRepository>();

Schließlich werden unsere Controller mit dem Repository-Service anstelle des EF Core-Kontextes verknüpft und führen Methoden auf ihm aus:

private readonly IBloggingRepository _repository;

public BloggingControllerWithRepository(IBloggingRepository repository)
    => _repository = repository;

[HttpGet]
public async Task<Blog> GetBlog(string name)
    => await _repository.GetBlogByNameAsync(name);

Zu diesem Zeitpunkt wird Ihre Anwendung gemäß dem Repositorymuster erstellt: Der einzige Kontaktpunkt mit der Datenzugriffsebene – EF Core – ist jetzt über die Repositoryebene, die als Mediator zwischen Anwendungscode und tatsächlichen Datenbankabfragen fungiert. Sie können jetzt Tests schreiben, indem Sie das Repository einfach per Stub auslagern oder es mit Ihrer bevorzugten Mocking-Bibliothek simulieren. Hier ist ein Beispiel für einen simulierten Test mit der beliebten Moq-Bibliothek:

[Fact]
public async Task GetBlog()
{
    // Arrange
    var repositoryMock = new Mock<IBloggingRepository>();
    repositoryMock
        .Setup(r => r.GetBlogByNameAsync("Blog2"))
        .Returns(Task.FromResult(new Blog { Name = "Blog2", Url = "http://blog2.com" }));

    var controller = new BloggingControllerWithRepository(repositoryMock.Object);

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

    // Assert
    repositoryMock.Verify(r => r.GetBlogByNameAsync("Blog2"));
    Assert.Equal("http://blog2.com", blog.Url);
}

Der vollständige Beispielcode kann hier angezeigt werden.

SQLite In-Memory

SQLite kann ganz einfach als EF Core-Anbieter für Ihre Testsuite und nicht als Produktionsdatenbanksystem (e.g. SQL Server) konfiguriert werden; Weitere Informationen finden Sie in den SQLite-Anbieterdokumenten. In der Regel ist es jedoch eine gute Idee, beim Testen das In-Memory-Datenbankfeature von SQLite zu verwenden, da sie eine einfache Isolierung zwischen den Tests ermöglicht und keinen Umgang mit tatsächlichen SQLite-Dateien erfordert.

Um SQLite im Arbeitsspeicher zu verwenden, ist es wichtig zu verstehen, dass eine neue Datenbank immer dann erstellt wird, wenn eine Verbindung auf niedriger Ebene geöffnet wird und dass sie beim Schließen dieser Verbindung gelöscht wird. In der normalen Verwendung öffnet und schließt ef Cores DbContext Datenbankverbindungen nach Bedarf – jedes Mal, wenn eine Abfrage ausgeführt wird - um zu vermeiden, dass die Verbindung unnötig lange dauert. Bei SQLite In-Memory würde dies jedoch dazu führen, dass die Datenbank jedes Mal zurückgesetzt wird. Daher öffnen wir die Verbindung, bevor wir sie an EF Core übergeben, und sorgen dafür, dass sie erst geschlossen wird, wenn der Test abgeschlossen ist:

    public SqliteInMemoryBloggingControllerTest()
    {
        // Create and open a connection. This creates the SQLite in-memory database, which will persist until the connection is closed
        // at the end of the test (see Dispose below).
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();

        // These options will be used by the context instances in this test suite, including the connection opened above.
        _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
            .UseSqlite(_connection)
            .Options;

        // Create the schema and seed some data
        using var context = new BloggingContext(_contextOptions);

        if (context.Database.EnsureCreated())
        {
            using var viewCommand = context.Database.GetDbConnection().CreateCommand();
            viewCommand.CommandText = @"
CREATE VIEW AllResources AS
SELECT Url
FROM Blogs;";
            viewCommand.ExecuteNonQuery();
        }

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

    BloggingContext CreateContext() => new BloggingContext(_contextOptions);

    public void Dispose() => _connection.Dispose();

Die Tests können nun CreateContext aufrufen, der einen Kontext zurückgibt, der die Verbindung verwendet, die wir im Konstruktor eingerichtet haben.

Den vollständigen Beispielcode für SQLite In-Memory-Tests finden Sie hier.

In-Memory-Anbieter

Wie auf der Testübersichtsseite erläutert, wird von der Verwendung des Speicheranbieters für Tests dringend abgeraten; erwägen Sie stattdessen die Verwendung von SQLite oder die Implementierung des Repositorymusters. Wenn Sie sich für die Verwendung von In-Memory entschieden haben, finden Sie hier einen typischen Konstruktor für eine Testklasse, der vor jedem Test eine neue In-Memory-Datenbank einrichtet und startet:

public InMemoryBloggingControllerTest()
{
    _contextOptions = new DbContextOptionsBuilder<BloggingContext>()
        .UseInMemoryDatabase("BloggingControllerTest")
        .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning))
        .Options;

    using var context = new BloggingContext(_contextOptions);

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

Den vollständigen Beispielcode für In-Memory-Tests finden Sie hier.

Benennung der In-Memory-Datenbank

In-Memory-Datenbanken werden durch einen einfachen String-Namen identifiziert, und es ist möglich, mehrmals eine Verbindung mit derselben Datenbank herzustellen, indem derselbe Name angegeben wird (aus diesem Grund muss im obigen Beispiel vor jedem Test EnsureDeleted aufgerufen werden). Beachten Sie jedoch, dass In-Memory-Datenbanken im internen Dienstanbieter des Kontexts verwurzelt sind; In den meisten Fällen verwenden Kontexte denselben Dienstanbieter, während das Konfigurieren von Kontexten mit verschiedenen Optionen die Verwendung eines neuen internen Dienstanbieters auslösen kann. Wenn dies der Fall ist, übergeben Sie explizit dieselbe Instanz von InMemoryDatabaseRoot an UseInMemoryDatabase für alle Kontexte, die In-Memory-Datenbanken freigeben sollen (dies geschieht in der Regel durch ein statisches InMemoryDatabaseRoot-Feld).

Transaktionen

Wenn eine Transaktion gestartet wird, löst der Speicheranbieter standardmäßig eine Ausnahme aus, da Transaktionen nicht unterstützt werden. Möglicherweise möchten Sie Transaktionen still und leise ignorieren lassen, indem Sie EF Core so konfigurieren, dass InMemoryEventId.TransactionIgnoredWarning wie im obigen Beispiel ignoriert wird. Wenn Ihr Code jedoch tatsächlich auf transaktionale Semantik beruht – z. B. darauf, dass Rollbacks tatsächlich Änderungen zurücksetzen – funktioniert Ihr Test nicht.

Ansichten

Der In-Memory-Anbieter ermöglicht die Definition von Ansichten über LINQ-Abfragen mithilfe von ToInMemoryQuery:

modelBuilder.Entity<UrlResource>()
    .ToInMemoryQuery(() => context.Blogs.Select(b => new UrlResource { Url = b.Url }));