How to mock DbContext without breaking change tracking
Mocking DbContext directly silently breaks ChangeTracker, which is why Microsoft discourages it. This guide shows the two patterns that actually work in EF Core 11: SQLite in-memory with a kept-open connection so the real ChangeTracker runs, and the repository pattern that lifts EF Core out of the test entirely.
If you mock DbContext to unit-test your data layer, the test green-lights code that breaks the moment it touches a real database. The reason is the ChangeTracker: a Mock<MyDbContext> does not run change detection, does not assign primary keys on Add, does not enforce identity resolution, and silently lies about what SaveChanges would actually persist. The two patterns that work are: keep a real DbContext and swap the provider for SQLite in-memory, or wrap EF Core behind a repository interface and mock the interface instead. This guide walks both, with .NET 11 and EF Core 11 (Microsoft.EntityFrameworkCore 11.0.0, C# 14, xUnit 2.9), plus the EF Core 11 helpers that make the swap cleaner.
Why mocking DbContext breaks ChangeTracker
DbContext is a coordinator, not a data store. Add, Update, Remove, Attach, and the implicit detection that runs before SaveChanges all flow through ChangeTracker, which sits on top of an internal IStateManager. When you do var ctx = new Mock<MyDbContext>() and tell it to return a fake DbSet, you bypass all of that. Three concrete things break:
- Generated keys never get assigned. With a real provider,
Add(blog)for a[Key] int Id { get; set; }column givesblog.Ida temporary negative value, then a real key afterSaveChanges. A mocked context skips both. Tests that readblog.IdafterAddsee0, which silently passes equality checks against other unsaved entities. - Identity resolution disappears. EF Core guarantees that loading the same primary key twice returns the same in-memory instance. A mock backed by a
List<T>.AsQueryable()returns whatever LINQ-to-objects returns, which is usually a fresh anonymous projection, so reference equality breaks. Code that depends onReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1))works in production and fails in the test, or vice versa. SaveChangesbecomes a no-op verifier. The mock’sSaveChanges()returns 0 and never validates required navigations, never runs value converters, never fires interceptors, never raisesDbUpdateConcurrencyExceptionwhen the row vector says the row changed. Concurrency tokens are not even read.
Microsoft’s testing guidance puts it bluntly: mocking DbContext is appropriate only for verifying non-query side effects (did my code call Add? did it call SaveChanges?), and even there you are mostly testing that you wrote the line you wrote. For anything that depends on the result of a query, Microsoft recommends one of the two approaches below.
The minimal entity model used throughout
Every snippet below targets the same model. Two entities, one parent-child relationship, a generated key, and a concurrency token, because that is the smallest shape that surfaces all three change-tracking failures above.
// .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>();
}
The system under test is a small service that adds a blog with two posts, saves, and returns the new blog id:
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;
}
}
If Add and SaveChanges do not coordinate through ChangeTracker, blog.Id is 0 and the assertion at the end of the test passes for the wrong reason.
Pattern A: SQLite in-memory keeps the real ChangeTracker
The goal here is to keep BloggingContext exactly as it is in production and only swap the provider. SQLite has an :memory: mode that is private to a single open connection and is destroyed when the connection closes, which gives you per-test isolation without managing files. The trap is that EF Core opens and closes connections aggressively, so the in-memory database vanishes between calls. The fix is to open a SqliteConnection in the test fixture and pass that exact instance to UseSqlite, so the connection stays alive for the lifetime of the test class.
// .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);
}
}
Three things to notice. First, the connection is opened in the constructor and disposed in Dispose, so the in-memory database survives for every test method in the class but does not leak across classes. Second, the test uses two BloggingContext instances, one for the write and one for the read, which forces EF Core to materialize the entity from the database rather than hand back the cached instance from the first context. That is what catches “I forgot to call SaveChanges” bugs. Third, because the real ChangeTracker is in play, blog.Id actually changes from 0 to a real integer, and the NotEqual(0, id) assertion is meaningful.
The behavioural difference from your production database that matters most: SQLite is case-sensitive in LIKE and equality by default, while SQL Server is case-insensitive under the typical *_CI_AS collations. If your query has Where(b => b.Name == "walter"), it returns rows on SQL Server and no rows on SQLite. The general guidance is to keep these tests for behaviour that does not depend on collation, and to write a smaller integration suite against the real provider with Testcontainers for the rest.
A second gotcha: SQLite does not enforce some referential integrity checks by default. If you need cascade behaviour to match SQL Server exactly, run PRAGMA foreign_keys = ON; after opening the connection. EF Core 7+ does this for you when you use the SQLite provider, so you usually do not have to think about it, but it is worth knowing if you write raw SQL in tests.
Pattern B: repository pattern lifts EF Core out of the test
If your queries are complex enough that a SQLite swap will lie to you (provider-specific functions, JSON columns, full-text search, raw SQL), the cleanest way to unit-test is to put EF Core behind an interface that returns materialized data. You move the LINQ into a thin wrapper, mock the wrapper, and the unit tests stop knowing about 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);
}
The critical detail is the return type: IReadOnlyList<Blog> and Task<Blog?>, never IQueryable<Blog>. The instant you expose IQueryable, callers can .Where(...) on it, and now your test has to evaluate that Where against something, which puts you back in the original problem. Materialize at the boundary.
The service now depends on the interface:
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);
}
}
And the test mocks the interface, not 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);
}
The test now asserts the contract of BlogService (it built a blog with two posts and asked the repository to save it) without claiming anything about EF Core. The repository itself is then exercised by a separate, smaller suite that hits a real database. This is the layering Microsoft recommends when the tradeoff between test fidelity and test speed leans toward speed.
The price is real. A new architectural layer means more code, more interfaces, more files, and the temptation to write a generic IRepository<T> that ends up being a leaky reimplementation of DbSet. Resist that. Make the interfaces task-based, not entity-based: GetActiveSubscriptions(userId), not Get(int id). Each method should correspond to a meaningful query in your domain.
Why the EF Core in-memory provider is not on this list
The third option some teams reach for is Microsoft.EntityFrameworkCore.InMemory. The official guidance has been steadily firming up against it, and the current Learn page calls its use for testing “strongly discouraged” and “supported only for legacy applications”. Three reasons:
- Transactions are silently ignored.
BeginTransactionreturns a no-op, so a test for “this fails partway and rolls back” passes regardless of whether the rollback works. SQLite in-memory supports real transactions. - It is not relational. Unique constraints, referential integrity, and most provider-specific translations are absent. A query that fails on SQL Server with a translation error happily runs against the in-memory provider.
- Raw SQL is unsupported. SQLite supports
FromSqlRawagainst any SQL it understands.
If you have an existing test suite that uses it and you have not been bitten yet, you are leaning on a fake database that pretends to be more lenient than the real one. The migration path is usually a one-line swap from UseInMemoryDatabase("name") to the SQLite-in-memory pattern above, plus a constructor that opens the connection and seeds.
EF Core 11 helpers that change the math
Two recent EF Core 11 additions are worth knowing about because they remove the most annoying part of swapping providers in a test fixture, which is undoing whatever the production composition root already registered.
RemoveDbContext<TContext>() strips the context and its bound DbContextOptions from an IServiceCollection in one call, replacing the manual RemoveAll<DbContextOptions<MyContext>>() plus RemoveAll(typeof(MyContext)) dance that used to be brittle. Combined with the parameterless AddPooledDbContextFactory<TContext>() overload, swapping a SQL Server registration for a SQLite one inside WebApplicationFactory<TStartup> becomes:
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();
}
}
Two lines of registration replace the old eight, and the cleanup survives any future change to how EF Core wires up its options pipeline. The full background on this pair is in the new RemoveDbContext for clean test provider swaps.
If you are also writing pre-SaveChanges interceptors that read ChangeTracker.Entries(), EF Core 11’s GetEntriesForState avoids the duplicate DetectChanges pass, which makes those interceptors cheaper to test in a tight loop.
Picking between the two patterns
A short decision flow that holds up in practice:
- If your code under test is business logic that calls a repository method, mock the repository. Do not stand up a database at all.
- If your code under test is the repository implementation itself, or anything that constructs LINQ queries against
DbSet, use SQLite in-memory. - If your query depends on provider-specific behaviour (SQL Server JSON functions, full-text indexes,
EF.Functions.DateDiffDay, raw SQL with vendor syntax), write an integration test against the real provider with Testcontainers instead. SQLite will compile-pass but lie at runtime. - If you find yourself wanting to mock
DbContextdirectly to verify “did I callSaveChanges”, refactor the call site to depend on a smaller interface (IUnitOfWork,IBlogRepository) and verify against that. The mock will be smaller, the test will read better, and you will not be fightingChangeTracker.
The combination that fails is “mock DbContext for queries”. Every other combination has a defensible answer.
Related posts and primary sources
- How to unit-test code that uses HttpClient covers the parallel pattern of substituting the seam (
HttpMessageHandler) instead of mocking the surface (HttpClient). - EF Core 11 Preview 3 adds RemoveDbContext for clean test provider swaps explains the helper used in the
WebApplicationFactorysnippet above. - EF Core 11 adds GetEntriesForState to skip DetectChanges is useful background when you are testing audit interceptors.
- How to use records with EF Core 11 correctly is worth a look if your blog/post entities are records, because record equality interacts with
ChangeTrackeridentity resolution in surprising ways. - How to use IAsyncEnumerable with EF Core 11 is the right return type when a repository method needs to stream rather than materialize a list.
Primary sources:
- Choosing a testing strategy on Microsoft Learn, which is the authoritative guidance against mocking
DbSetfor queries and against the in-memory provider. - Testing without your production database system for the SQLite-in-memory and repository samples this post adapts.
- SQLite in-memory database documentation for the connection-lifetime semantics that the SQLite-in-memory pattern relies on.
- Testcontainers for .NET for the integration-test escape hatch when SQLite lies.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.