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:
- Generierte Schlüssel werden nie zugewiesen. Mit einem echten Provider gibt
Add(blog)für eine[Key] int Id { get; set; }-Spalteblog.Ideinen temporären negativen Wert, dann nachSaveChangeseinen echten Schlüssel. Ein gemockter Kontext überspringt beides. Tests, dieblog.IdnachAddlesen, sehen0, was Gleichheitsprüfungen gegen andere ungespeicherte Entitäten stillschweigend besteht. - 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 vonReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1))abhängt, funktioniert in Produktion und scheitert im Test, oder umgekehrt. SaveChangeswird zum No-Op-Verifizierer. DasSaveChanges()des Mocks gibt 0 zurück und validiert nie erforderliche Navigationen, führt nie Wertkonverter aus, feuert nie Interceptors und löst nie eineDbUpdateConcurrencyExceptionaus, 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:
- Transaktionen werden stillschweigend ignoriert.
BeginTransactiongibt einen No-Op zurück, sodass ein Test für “das schlägt mittendrin fehl und rollt zurück” unabhängig davon besteht, ob das Rollback funktioniert. SQLite In-Memory unterstützt echte Transaktionen. - Es ist nicht relational. Eindeutigkeitsbedingungen, referenzielle Integrität und die meisten provider-spezifischen Übersetzungen fehlen. Eine Abfrage, die auf SQL Server mit einem Übersetzungsfehler scheitert, läuft gegen den In-Memory-Provider problemlos.
- Rohes SQL wird nicht unterstützt. SQLite unterstützt
FromSqlRawgegen jedes SQL, das es versteht.
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:
- Wenn Ihr Code unter Test Geschäftslogik ist, die eine Repository-Methode aufruft, mocken Sie das Repository. Stehen Sie überhaupt keine Datenbank auf.
- Wenn Ihr Code unter Test die Repository-Implementierung selbst ist oder irgendetwas, das LINQ-Abfragen gegen
DbSetbaut, verwenden Sie SQLite In-Memory. - 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. - Wenn Sie sich dabei ertappen,
DbContextdirekt mocken zu wollen, um zu prüfen “habe ichSaveChangesaufgerufen?”, 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 denChangeTracker.
Die Kombination, die scheitert, ist “DbContext für Abfragen mocken”. Jede andere Kombination hat eine vertretbare Antwort.
Verwandte Beiträge und Primärquellen
- Wie Sie Code, der HttpClient nutzt, im Unit-Test prüfen deckt das parallele Muster ab, die Naht (
HttpMessageHandler) zu ersetzen, statt die Oberfläche (HttpClient) zu mocken. - EF Core 11 Preview 3 fügt RemoveDbContext für saubere Provider-Tausche im Test hinzu erklärt den Helfer, der im obigen
WebApplicationFactory-Schnipsel verwendet wird. - EF Core 11 fügt GetEntriesForState hinzu, um DetectChanges zu überspringen ist nützlicher Hintergrund, wenn Sie Audit-Interceptors testen.
- Wie Sie Records mit EF Core 11 korrekt verwenden ist einen Blick wert, wenn Ihre Blog-/Post-Entitäten Records sind, weil Record-Gleichheit auf überraschende Weise mit der Identitätsauflösung des
ChangeTrackerinteragiert. - Wie Sie IAsyncEnumerable mit EF Core 11 verwenden ist der richtige Rückgabetyp, wenn eine Repository-Methode streamen statt eine Liste materialisieren muss.
Primärquellen:
- Choosing a testing strategy auf Microsoft Learn, die maßgebliche Empfehlung gegen das Mocken von
DbSetfür Abfragen und gegen den In-Memory-Provider. - Testing without your production database system für die SQLite-In-Memory- und Repository-Beispiele, die dieser Beitrag adaptiert.
- SQLite in-memory database documentation für die Verbindungslebensdauer-Semantik, auf der das SQLite-In-Memory-Muster aufbaut.
- Testcontainers for .NET für die Integrationstest-Fluchtluke, wenn SQLite lügt.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.