Start Debugging

Wie Sie DbContext mocken, ohne das Change Tracking zu zerstören

Das direkte Mocken von DbContext bricht den ChangeTracker stillschweigend, weshalb Microsoft davon abrät. Diese Anleitung zeigt die zwei Muster, die in EF Core 11 wirklich funktionieren: SQLite In-Memory mit einer offen gehaltenen Verbindung, damit der echte ChangeTracker läuft, und das Repository-Muster, das EF Core ganz aus dem Test heraushebt.

Wenn Sie DbContext mocken, um Ihre Datenschicht im Unit-Test zu prüfen, gibt der Test grünes Licht für Code, der in dem Moment bricht, in dem er eine echte Datenbank berührt. Der Grund ist der ChangeTracker: Ein Mock<MyDbContext> führt keine Änderungserkennung aus, weist bei Add keine Primärschlüssel zu, erzwingt keine Identitätsauflösung und lügt stillschweigend darüber, was SaveChanges tatsächlich persistieren würde. Die zwei Muster, die funktionieren, sind: einen echten DbContext behalten und den Provider gegen SQLite In-Memory austauschen, oder EF Core hinter einem Repository-Interface verpacken und stattdessen das Interface mocken. Diese Anleitung geht beide Wege durch, mit .NET 11 und EF Core 11 (Microsoft.EntityFrameworkCore 11.0.0, C# 14, xUnit 2.9), plus den EF-Core-11-Helfern, die den Tausch sauberer machen.

Warum das Mocken von DbContext den ChangeTracker bricht

DbContext ist ein Koordinator, kein Datenspeicher. Add, Update, Remove, Attach und die implizite Erkennung, die vor SaveChanges läuft, fließen alle durch den ChangeTracker, der auf einem internen IStateManager aufsitzt. Wenn Sie var ctx = new Mock<MyDbContext>() schreiben und ihm sagen, ein gefälschtes DbSet zurückzugeben, umgehen Sie das alles. Drei konkrete Dinge brechen:

  1. Generierte Schlüssel werden nie zugewiesen. Mit einem echten Provider gibt Add(blog) für eine [Key] int Id { get; set; }-Spalte blog.Id einen temporären negativen Wert, dann nach SaveChanges einen echten Schlüssel. Ein gemockter Kontext überspringt beides. Tests, die blog.Id nach Add lesen, sehen 0, was Gleichheitsprüfungen gegen andere ungespeicherte Entitäten stillschweigend besteht.
  2. Die Identitätsauflösung verschwindet. EF Core garantiert, dass das zweimalige Laden desselben Primärschlüssels dieselbe In-Memory-Instanz zurückgibt. Ein Mock, der von einem List<T>.AsQueryable() gestützt wird, gibt zurück, was LINQ-to-Objects zurückgibt, in der Regel eine frische anonyme Projektion, sodass Referenzgleichheit bricht. Code, der von ReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1)) abhängt, funktioniert in Produktion und scheitert im Test, oder umgekehrt.
  3. SaveChanges wird zum No-Op-Verifizierer. Das SaveChanges() des Mocks gibt 0 zurück und validiert nie erforderliche Navigationen, führt nie Wertkonverter aus, feuert nie Interceptors und löst nie eine DbUpdateConcurrencyException aus, wenn der Zeilenvektor sagt, dass sich die Zeile geändert hat. Concurrency-Token werden nicht einmal gelesen.

Microsofts Test-Leitlinie sagt es unverblümt: Das Mocken von DbContext ist nur dann angemessen, wenn Nicht-Query-Seiteneffekte verifiziert werden sollen (hat mein Code Add aufgerufen? hat er SaveChanges aufgerufen?), und selbst dort testen Sie meist, dass Sie die Zeile geschrieben haben, die Sie geschrieben haben. Für alles, was vom Ergebnis einer Abfrage abhängt, empfiehlt Microsoft einen der zwei Ansätze unten.

Das minimale Entitätsmodell, das durchgehend verwendet wird

Jeder Code-Schnipsel unten zielt auf dasselbe Modell. Zwei Entitäten, eine Eltern-Kind-Beziehung, ein generierter Schlüssel und ein Concurrency-Token, weil das die kleinste Form ist, die alle drei oben genannten Change-Tracking-Fehler zum Vorschein bringt.

// .NET 11, EF Core 11, C# 14
public class Blog
{
    public int Id { get; set; }                 // generated by the provider
    public required string Name { get; set; }
    public List<Post> Posts { get; set; } = new();
    [Timestamp] public byte[] RowVersion { get; set; } = default!;
}

public class Post
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public int BlogId { get; set; }
    public Blog Blog { get; set; } = default!;
}

public class BloggingContext(DbContextOptions<BloggingContext> options) : DbContext(options)
{
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post> Posts => Set<Post>();
}

Das System unter Test ist ein kleiner Service, der einen Blog mit zwei Posts hinzufügt, speichert und die ID des neuen Blogs zurückgibt:

public class BlogService(BloggingContext context)
{
    public async Task<int> CreateBlogAsync(string name, IEnumerable<string> postTitles)
    {
        var blog = new Blog { Name = name };
        blog.Posts.AddRange(postTitles.Select(t => new Post { Title = t }));
        context.Blogs.Add(blog);
        await context.SaveChangesAsync();
        return blog.Id;
    }
}

Wenn Add und SaveChanges sich nicht über den ChangeTracker koordinieren, ist blog.Id gleich 0, und die Assertion am Ende des Tests besteht aus dem falschen Grund.

Muster A: SQLite In-Memory behält den echten ChangeTracker

Das Ziel hier ist, BloggingContext exakt so zu lassen, wie er in Produktion ist, und nur den Provider auszutauschen. SQLite hat einen :memory:-Modus, der auf eine einzelne offene Verbindung beschränkt ist und zerstört wird, wenn die Verbindung schließt, was Ihnen Test-Isolation pro Test ohne Dateiverwaltung gibt. Die Falle ist, dass EF Core Verbindungen aggressiv öffnet und schließt, sodass die In-Memory-Datenbank zwischen Aufrufen verschwindet. Die Lösung ist, eine SqliteConnection im Test-Fixture zu öffnen und genau diese Instanz an UseSqlite zu übergeben, damit die Verbindung über die Lebenszeit der Testklasse hinweg am Leben bleibt.

// .NET 11, EF Core 11.0.0, Microsoft.EntityFrameworkCore.Sqlite 11.0.0
public sealed class BlogServiceTests : IDisposable
{
    private readonly SqliteConnection _connection;
    private readonly DbContextOptions<BloggingContext> _options;

    public BlogServiceTests()
    {
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();

        _options = new DbContextOptionsBuilder<BloggingContext>()
            .UseSqlite(_connection)
            .Options;

        using var ctx = new BloggingContext(_options);
        ctx.Database.EnsureCreated();
    }

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

    private BloggingContext CreateContext() => new(_options);

    [Fact]
    public async Task CreateBlogAsync_assigns_a_real_key_and_persists_posts()
    {
        await using var write = CreateContext();
        var sut = new BlogService(write);

        var id = await sut.CreateBlogAsync("Walter", new[] { "Hello", "World" });

        Assert.NotEqual(0, id);

        await using var read = CreateContext();
        var blog = await read.Blogs.Include(b => b.Posts).SingleAsync(b => b.Id == id);
        Assert.Equal("Walter", blog.Name);
        Assert.Equal(2, blog.Posts.Count);
    }
}

Drei Dinge fallen auf. Erstens wird die Verbindung im Konstruktor geöffnet und in Dispose freigegeben, sodass die In-Memory-Datenbank für jede Testmethode in der Klasse überlebt, aber nicht klassenübergreifend leakt. Zweitens verwendet der Test zwei BloggingContext-Instanzen, eine zum Schreiben und eine zum Lesen, was EF Core zwingt, die Entität aus der Datenbank zu materialisieren, statt die gecachte Instanz aus dem ersten Kontext zurückzugeben. Genau das fängt die Bugs der Sorte “Ich habe vergessen, SaveChanges aufzurufen”. Drittens ändert sich blog.Id tatsächlich von 0 auf eine echte Ganzzahl, weil der echte ChangeTracker im Spiel ist, und die Assertion NotEqual(0, id) ist aussagekräftig.

Der Verhaltensunterschied zu Ihrer Produktionsdatenbank, der am meisten ins Gewicht fällt: SQLite ist bei LIKE und Gleichheit standardmäßig case-sensitiv, während SQL Server unter den typischen *_CI_AS-Kollationen case-insensitiv ist. Wenn Ihre Abfrage Where(b => b.Name == "walter") enthält, gibt sie auf SQL Server Zeilen zurück und auf SQLite keine. Die allgemeine Empfehlung ist, diese Tests für Verhalten zu nutzen, das nicht von der Kollation abhängt, und für den Rest eine kleinere Integrationssuite gegen den echten Provider mit Testcontainers zu schreiben.

Eine zweite Stolperfalle: SQLite erzwingt einige Prüfungen referenzieller Integrität standardmäßig nicht. Wenn Sie wollen, dass das Cascade-Verhalten exakt SQL Server entspricht, führen Sie nach dem Öffnen der Verbindung PRAGMA foreign_keys = ON; aus. EF Core 7+ erledigt das für Sie, wenn Sie den SQLite-Provider verwenden, sodass Sie sich darüber meist keine Gedanken machen müssen, aber es ist gut zu wissen, wenn Sie rohes SQL in Tests schreiben.

Muster B: Das Repository-Muster hebt EF Core aus dem Test heraus

Wenn Ihre Abfragen so komplex sind, dass ein SQLite-Tausch Sie anlügen würde (provider-spezifische Funktionen, JSON-Spalten, Volltextsuche, rohes SQL), ist der sauberste Weg für Unit-Tests, EF Core hinter ein Interface zu legen, das materialisierte Daten zurückgibt. Sie verschieben das LINQ in einen dünnen Wrapper, mocken den Wrapper, und die Unit-Tests wissen nichts mehr über EF Core.

public interface IBlogRepository
{
    Task<int> AddBlogAsync(Blog blog, CancellationToken ct = default);
    Task<Blog?> GetBlogByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<Blog>> GetAllBlogsAsync(CancellationToken ct = default);
}

public sealed class BlogRepository(BloggingContext context) : IBlogRepository
{
    public async Task<int> AddBlogAsync(Blog blog, CancellationToken ct = default)
    {
        context.Blogs.Add(blog);
        await context.SaveChangesAsync(ct);
        return blog.Id;
    }

    public Task<Blog?> GetBlogByIdAsync(int id, CancellationToken ct = default)
        => context.Blogs.Include(b => b.Posts).FirstOrDefaultAsync(b => b.Id == id, ct);

    public async Task<IReadOnlyList<Blog>> GetAllBlogsAsync(CancellationToken ct = default)
        => await context.Blogs.AsNoTracking().ToListAsync(ct);
}

Das entscheidende Detail ist der Rückgabetyp: IReadOnlyList<Blog> und Task<Blog?>, niemals IQueryable<Blog>. In dem Moment, in dem Sie IQueryable exponieren, können Aufrufer .Where(...) darauf anwenden, und nun muss Ihr Test dieses Where gegen etwas auswerten, was Sie wieder beim Ursprungsproblem landen lässt. Materialisieren Sie an der Grenze.

Der Service hängt nun vom Interface ab:

public class BlogService(IBlogRepository blogs)
{
    public async Task<int> CreateBlogAsync(string name, IEnumerable<string> postTitles)
    {
        var blog = new Blog { Name = name };
        blog.Posts.AddRange(postTitles.Select(t => new Post { Title = t }));
        return await blogs.AddBlogAsync(blog);
    }
}

Und der Test mockt das Interface, nicht DbContext:

[Fact]
public async Task CreateBlogAsync_returns_id_from_repository()
{
    var repo = new Mock<IBlogRepository>();
    repo.Setup(r => r.AddBlogAsync(It.IsAny<Blog>(), default))
        .Callback<Blog, CancellationToken>((b, _) => b.Id = 42)
        .ReturnsAsync(42);

    var sut = new BlogService(repo.Object);

    var id = await sut.CreateBlogAsync("Walter", new[] { "Hello", "World" });

    Assert.Equal(42, id);
    repo.Verify(r => r.AddBlogAsync(It.Is<Blog>(b => b.Posts.Count == 2), default), Times.Once);
}

Der Test bestätigt nun den Vertrag von BlogService (er hat einen Blog mit zwei Posts gebaut und das Repository gebeten, ihn zu speichern), ohne irgendetwas über EF Core zu behaupten. Das Repository selbst wird dann von einer separaten, kleineren Suite ausgeübt, die eine echte Datenbank trifft. Das ist die Schichtung, die Microsoft empfiehlt, wenn der Trade-off zwischen Test-Treue und Test-Geschwindigkeit zur Geschwindigkeit hin tendiert.

Der Preis ist real. Eine neue Architekturschicht bedeutet mehr Code, mehr Interfaces, mehr Dateien und die Versuchung, ein generisches IRepository<T> zu schreiben, das am Ende eine löchrige Neuimplementierung von DbSet ist. Widerstehen Sie dem. Machen Sie die Interfaces aufgabenbasiert, nicht entitätsbasiert: GetActiveSubscriptions(userId), nicht Get(int id). Jede Methode sollte einer bedeutsamen Abfrage in Ihrer Domäne entsprechen.

Warum der EF-Core-In-Memory-Provider nicht auf dieser Liste steht

Die dritte Option, nach der manche Teams greifen, ist Microsoft.EntityFrameworkCore.InMemory. Die offizielle Empfehlung verschärft sich stetig dagegen, und die aktuelle Learn-Seite bezeichnet seine Verwendung für Tests als “stark abgeraten” und “nur für Legacy-Anwendungen unterstützt”. Drei Gründe:

Wenn Sie eine bestehende Test-Suite haben, die ihn nutzt, und es Sie noch nicht gebissen hat, lehnen Sie sich an eine gefälschte Datenbank an, die vorgibt, nachsichtiger zu sein als die echte. Der Migrationspfad ist meist ein Einzeilen-Tausch von UseInMemoryDatabase("name") auf das oben gezeigte SQLite-In-Memory-Muster, plus ein Konstruktor, der die Verbindung öffnet und Daten einsetzt.

EF-Core-11-Helfer, die die Rechnung verändern

Zwei kürzliche EF-Core-11-Erweiterungen sind erwähnenswert, weil sie den lästigsten Teil des Provider-Tausches in einem Test-Fixture beseitigen, nämlich das Rückgängigmachen dessen, was die Produktions-Composition-Root bereits registriert hat.

RemoveDbContext<TContext>() entfernt den Kontext und seine gebundenen DbContextOptions in einem Aufruf aus einer IServiceCollection und ersetzt damit den manuellen Tanz aus RemoveAll<DbContextOptions<MyContext>>() plus RemoveAll(typeof(MyContext)), der früher fragil war. Kombiniert mit der parameterlosen Überladung von AddPooledDbContextFactory<TContext>() wird der Tausch einer SQL-Server-Registrierung gegen eine SQLite-Registrierung innerhalb von WebApplicationFactory<TStartup> zu:

public class TestWebFactory : WebApplicationFactory<Program>
{
    private readonly SqliteConnection _connection = new("Filename=:memory:");

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        _connection.Open();
        builder.ConfigureTestServices(services =>
        {
            services.RemoveDbContext<BloggingContext>();
            services.AddDbContext<BloggingContext>(o => o.UseSqlite(_connection));
        });
    }

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        if (disposing) _connection.Dispose();
    }
}

Zwei Zeilen Registrierung ersetzen die alten acht, und das Aufräumen überlebt jede zukünftige Änderung daran, wie EF Core seine Optionen-Pipeline verdrahtet. Der vollständige Hintergrund zu diesem Paar ist in dem neuen RemoveDbContext für saubere Provider-Tausche im Test.

Wenn Sie zusätzlich Pre-SaveChanges-Interceptors schreiben, die ChangeTracker.Entries() lesen, vermeidet GetEntriesForState aus EF Core 11 den doppelten DetectChanges-Durchlauf, was diese Interceptors günstiger zu testen macht, wenn man sie in einer engen Schleife laufen lässt.

Auswahl zwischen den zwei Mustern

Ein kurzer Entscheidungsfluss, der sich in der Praxis bewährt:

  1. Wenn Ihr Code unter Test Geschäftslogik ist, die eine Repository-Methode aufruft, mocken Sie das Repository. Stehen Sie überhaupt keine Datenbank auf.
  2. Wenn Ihr Code unter Test die Repository-Implementierung selbst ist oder irgendetwas, das LINQ-Abfragen gegen DbSet baut, verwenden Sie SQLite In-Memory.
  3. Wenn Ihre Abfrage von provider-spezifischem Verhalten abhängt (SQL-Server-JSON-Funktionen, Volltext-Indizes, EF.Functions.DateDiffDay, rohes SQL mit Hersteller-Syntax), schreiben Sie stattdessen einen Integrationstest gegen den echten Provider mit Testcontainers. SQLite kompiliert sauber, lügt aber zur Laufzeit.
  4. Wenn Sie sich dabei ertappen, DbContext direkt mocken zu wollen, um zu prüfen “habe ich SaveChanges aufgerufen?”, refaktorisieren Sie die Aufrufstelle, sodass sie von einer kleineren Schnittstelle (IUnitOfWork, IBlogRepository) abhängt, und prüfen Sie dagegen. Der Mock wird kleiner, der Test liest sich besser, und Sie kämpfen nicht gegen den ChangeTracker.

Die Kombination, die scheitert, ist “DbContext für Abfragen mocken”. Jede andere Kombination hat eine vertretbare Antwort.

Verwandte Beiträge und Primärquellen

Primärquellen:

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück