Zurück zu allen Beiträgen
·5 Min. Lesezeit

Integrationstests für .NET mit echter Datenbank: schnell, isoliert, verlässlich

Die Unit-Tests sind grün, aber in der Integrationsumgebung scheitert schon die erste Anfrage. Die meisten Teams erleben das mindestens einmal. Meist liegt es daran, dass die Tests einen wichtigen Teil des echten Systems nachahmen: die Datenbank. Ein In-Memory-Fake kennt weder die Constraints noch das Übersetzungsverhalten oder die Nebenläufigkeitsregeln einer echten SQL-Engine.

In diesem Beitrag beschreibe ich, wie Sie Integrationstests für ASP.NET-Core-APIs gegen eine echte Datenbank schnell, isoliert und verlässlich halten. Die Beispiele verwenden C# und EF Core, die Ideen lassen sich aber auf andere Stacks übertragen.

Warum In-Memory-Fakes nicht genügen

Der In-Memory-Provider von EF Core ist praktisch, verhält sich aber nicht wie eine relationale Datenbank. Fremdschlüssel-Constraints, Verletzungen eindeutiger Indizes, Transaktions-Isolationsstufen und die Übersetzung von LINQ-Ausdrücken in echtes SQL werden nicht auf dieselbe Weise geprüft. Eine Abfrage, die im Speicher funktioniert, kann in Produktion zur Laufzeit scheitern, weil sie sich nicht übersetzen lässt.

Tests mit In-Memory-SQLite sind einen Schritt näher dran, aber es bleibt eine andere Engine. Läuft in Produktion PostgreSQL oder SQL Server, bleibt ein Teil der Unterschiede verborgen. Die Regel ist einfach: Ein Test ist so viel wert, wie sein Vertrauen der Realität nahekommt. Wer den Datenzugriff testet, sollte dieselbe Engine verwenden.

Eine echte Datenbank im Container

Damit die Tests nicht von einem manuell installierten Server abhängen, startet man die Datenbank häufig als Container innerhalb des Testprozesses. Bibliotheken wie Testcontainers steuern das aus dem Code: Sie starten den Container, liefern die Connection-String und entfernen ihn am Ende wieder.

public sealed class DatabaseFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container =
        new PostgreSqlBuilder().Build();

    public string ConnectionString => _container.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        await using var db = CreateContext();
        await db.Database.MigrateAsync();
    }

    public Task DisposeAsync() => _container.DisposeAsync().AsTask();

    public AppDbContext CreateContext() =>
        new(new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(ConnectionString).Options);
}

Ein wichtiges Detail: Bauen Sie das Schema mit den echten Migrationen auf, nicht mit EnsureCreated. So werden die Migrationen selbst bei jedem Testlauf mitgeprüft. Einen Container zu starten ist teuer; starten Sie ihn deshalb einmal pro Testkollektion, nicht pro Test.

Ende-zu-Ende-Anfragen mit WebApplicationFactory

Um die API auf HTTP-Ebene zu testen, verwendet man WebApplicationFactory<TEntryPoint>. Die Anwendung startet im selben Prozess, und der Test sendet Anfragen über einen HttpClient. Sie müssen lediglich die Connection-String auf die Testdatenbank umleiten.

public sealed class ApiFactory(string connectionString)
    : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(o =>
                o.UseNpgsql(connectionString));
        });
    }
}

So laufen Routing, Model Binding, Validierung, Autorisierung und Datenzugriff wie in Produktion. Ersetzen Sie nur Abhängigkeiten jenseits Ihrer Systemgrenze, etwa den E-Mail-Versand oder einen HTTP-Dienst eines Drittanbieters, durch Testdoubles. Die eigene Datenbank ersetzen Sie nicht.

Testisolation: Tests dürfen sich nicht beeinflussen

Sehen Tests auf einer gemeinsamen Datenbank die Daten der anderen, hängen die Ergebnisse von der Reihenfolge ab, und die Fehlersuche wird mühsam. Es gibt drei gängige Strategien:

  • Pro Test eine Transaktion öffnen und zurückrollen. Schnell, kann aber fragil sein, wenn die Anwendung eigene Transaktionen verwaltet oder mehrere Verbindungen nutzt.
  • Die Tabellen nach jedem Test leeren. Einfach und gut nachvollziehbar. Auf die Reihenfolge der Fremdschlüssel muss man achten.
  • Pro Test ein eigenes Schema oder eine eigene Datenbank. Die stärkste Isolation, aber mit höherem Einrichtungsaufwand.

In der Praxis empfehle ich die zweite Variante. Ein kleiner Helfer, der die Tabellen leert, läuft vor jedem Test. Es gibt auch fertige Bibliotheken, die die Löschreihenfolge aus dem Schema ableiten; prüfen Sie diese, statt alles selbst zu schreiben.

public async Task ResetAsync()
{
    await using var db = CreateContext();
    await db.Database.ExecuteSqlRawAsync(
        "TRUNCATE TABLE orders, customers RESTART IDENTITY CASCADE");
}

Den Test lesbar halten

Ein häufiges Problem von Integrationstests ist, dass der Vorbereitungscode den eigentlichen Zweck überdeckt. Schreiben Sie kleine Helfer, die Testdaten erzeugen, und reduzieren Sie den Testkörper auf drei Schritte: Zustand vorbereiten, Anfrage senden, Ergebnis prüfen.

[Fact]
public async Task Bestellung_wird_angelegt_und_aufgelistet()
{
    await _db.ResetAsync();
    var kundenId = await _db.SeedCustomerAsync("Anna");

    var response = await _client.PostAsJsonAsync("/orders",
        new { customerId = kundenId, items = new[] { new { sku = "A1", qty = 2 } } });

    response.StatusCode.Should().Be(HttpStatusCode.Created);

    var list = await _client.GetFromJsonAsync<List<OrderDto>>("/orders");
    list.Should().ContainSingle(o => o.CustomerId == kundenId);
}

Beenden Sie die Prüfung nicht allein mit dem Statuscode. Bestätigen Sie mit einer zweiten Anfrage oder einer direkten Datenbankabfrage, dass die Daten wirklich geschrieben und gelesen wurden. Sonst liefert der Endpunkt 201, der Datensatz fehlt, und der Test wird trotzdem grün.

Schnell bleiben: Kosten in der CI steuern

Der Haupteinwand gegen Tests mit echter Datenbank lautet, dass sie langsam seien. Der Einwand stimmt teilweise, lässt sich aber beherrschen:

  • Starten Sie den Container über eine gemeinsame Fixture einmal pro Kollektion, nicht einmal pro Klasse.
  • Wenden Sie die Migrationen einmal an; setzen Sie zwischen den Tests nur die Daten zurück.
  • Führen Sie Tests, die sich einen Container teilen, nicht parallel aus, sondern teilen Sie sie in Kollektionen auf oder geben Sie jeder Kollektion eine eigene Datenbank.
  • Schichten Sie Ihre Tests: schnelle Unit-Tests für Geschäftsregeln, eine kleinere Zahl an Integrationstests für Abfragen und Ende-zu-Ende-Abläufe.

In der CI brauchen Sie eine Umgebung, die Container ausführen kann. Das vorherige Laden oder Cachen des Images kann den ersten Lauf spürbar verkürzen. Messen Sie die Wirkung in Ihrer eigenen Pipeline; der Gewinn hängt von der Umgebung ab.

Häufige Fehler

Der erste ist, Integrationstests für ein Werkzeug zu halten, das alles abdeckt. Für jede Geschäftsregel einen HTTP-Test zu schreiben, macht die Suite langsam. Heben Sie Integrationstests für die kritischen Abläufe auf, die zeigen, dass die Komponenten zusammen korrekt arbeiten.

Der zweite ist, Zeit und Zufall in Tests nicht zu kontrollieren. Wird DateTime.UtcNow direkt verwendet, brechen Tests zu bestimmten Uhrzeiten. Abstrahieren Sie die Uhr und fixieren Sie sie im Test.

Der dritte ist eine zu knappe Ausgabe fehlgeschlagener Tests. Fügen Sie im Fehlerfall den Antwortinhalt und die relevanten Daten zur Meldung hinzu. Wenn man einen CI-Fehler erst lokal nachstellen muss, sinkt der Wert der Suite.

Fazit

Integrationstests gegen eine echte Datenbank fangen in der Testphase Fehler ab, die Fakes verbergen: fehlende Indizes, defekte Migrationen, nicht übersetzbare Abfragen. Dafür genügen drei Gewohnheiten: dieselbe Engine wie in Produktion verwenden, jeden Test von einem sauberen Zustand aus starten und die Kosten mit gemeinsamen Fixtures niedrig halten.

Fangen Sie klein an. Schreiben Sie für Ihren kritischsten Ablauf einen einzigen WebApplicationFactory-Test, bringen Sie ihn zum Laufen und lassen Sie die Suite von dort wachsen. Vertrauen wächst nicht mit der Anzahl der Tests, sondern damit, was die Tests tatsächlich prüfen.