Start Debugging

Как мокать DbContext, не ломая отслеживание изменений

Прямое мокание DbContext незаметно ломает ChangeTracker, поэтому Microsoft не рекомендует так делать. Это руководство показывает два паттерна, которые действительно работают в EF Core 11: SQLite в памяти с удерживаемым открытым соединением, чтобы запускался настоящий ChangeTracker, и паттерн репозитория, полностью убирающий EF Core из теста.

Если вы мокаете DbContext для модульных тестов слоя данных, тест даёт зелёный свет коду, который ломается в тот момент, когда касается реальной базы данных. Причина в ChangeTracker: Mock<MyDbContext> не запускает обнаружение изменений, не назначает первичные ключи на Add, не применяет разрешение идентичности и тихо лжёт о том, что на самом деле сохранил бы SaveChanges. Два паттерна, которые работают: оставить настоящий DbContext и поменять провайдер на SQLite в памяти, либо обернуть EF Core за интерфейсом репозитория и мокать сам интерфейс. Это руководство проходит оба, с .NET 11 и EF Core 11 (Microsoft.EntityFrameworkCore 11.0.0, C# 14, xUnit 2.9), плюс хелперы EF Core 11, которые делают замену чище.

Почему мокание DbContext ломает ChangeTracker

DbContext — это координатор, а не хранилище данных. Add, Update, Remove, Attach и неявное обнаружение, которое запускается перед SaveChanges, проходят через ChangeTracker, который опирается на внутренний IStateManager. Когда вы пишете var ctx = new Mock<MyDbContext>() и говорите ему вернуть фальшивый DbSet, вы обходите всё это. Конкретно ломаются три вещи:

  1. Сгенерированные ключи никогда не назначаются. С настоящим провайдером Add(blog) для столбца [Key] int Id { get; set; } присваивает blog.Id временное отрицательное значение, а после SaveChanges — настоящий ключ. Замоканный контекст пропускает оба шага. Тесты, читающие blog.Id после Add, видят 0, что тихо проходит проверки на равенство с другими несохранёнными сущностями.
  2. Разрешение идентичности исчезает. EF Core гарантирует, что повторная загрузка одного и того же первичного ключа возвращает тот же экземпляр в памяти. Мок, основанный на List<T>.AsQueryable(), возвращает то, что возвращает LINQ-to-objects, обычно свежую анонимную проекцию, поэтому ссылочное равенство ломается. Код, зависящий от ReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1)), работает в продакшене и падает в тесте, или наоборот.
  3. SaveChanges становится no-op-верификатором. SaveChanges() мока возвращает 0 и никогда не валидирует обязательные навигации, никогда не запускает конвертеры значений, никогда не вызывает интерцепторы, никогда не выбрасывает DbUpdateConcurrencyException, когда вектор строки говорит, что строка изменилась. Токены параллелизма даже не читаются.

Рекомендации Microsoft по тестированию говорят прямо: мокать DbContext уместно только для проверки незапросных побочных эффектов (вызвал ли мой код Add? вызвал ли он SaveChanges?), и даже там вы в основном проверяете, что написали ту строку, которую написали. Для всего, что зависит от результата запроса, Microsoft рекомендует один из двух подходов ниже.

Минимальная модель сущностей, используемая повсюду

Каждый фрагмент ниже нацелен на одну и ту же модель. Две сущности, одна связь родитель-потомок, сгенерированный ключ и токен параллелизма, потому что это минимальная форма, которая проявляет все три провала отслеживания изменений выше.

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

Тестируемая система — маленький сервис, добавляющий блог с двумя постами, сохраняющий их и возвращающий идентификатор нового блога:

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

Если Add и SaveChanges не координируются через ChangeTracker, то blog.Id равен 0, и утверждение в конце теста проходит по неправильной причине.

Паттерн A: SQLite в памяти сохраняет настоящий ChangeTracker

Цель здесь — оставить BloggingContext точно таким, каким он есть в продакшене, и заменить только провайдер. У SQLite есть режим :memory:, который приватен для одного открытого соединения и уничтожается при его закрытии, что даёт изоляцию по тесту без управления файлами. Подвох в том, что EF Core агрессивно открывает и закрывает соединения, поэтому база в памяти исчезает между вызовами. Решение — открыть SqliteConnection в фикстуре теста и передать именно этот экземпляр в UseSqlite, чтобы соединение оставалось живым на протяжении жизни тестового класса.

// .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);
    }
}

Стоит обратить внимание на три вещи. Во-первых, соединение открывается в конструкторе и освобождается в Dispose, поэтому база в памяти переживает все методы тестов в классе, но не утекает между классами. Во-вторых, тест использует два экземпляра BloggingContext, один для записи, один для чтения, что заставляет EF Core материализовать сущность из базы данных, а не возвращать кешированный экземпляр из первого контекста. Именно это ловит баги вида “забыл вызвать SaveChanges”. В-третьих, поскольку настоящий ChangeTracker в игре, blog.Id действительно меняется с 0 на настоящее целое, и утверждение NotEqual(0, id) имеет смысл.

Поведенческое отличие от вашей продакшен-базы, которое имеет наибольшее значение: SQLite по умолчанию чувствителен к регистру в LIKE и в равенстве, тогда как SQL Server при типичных коллациях *_CI_AS нечувствителен. Если в вашем запросе есть Where(b => b.Name == "walter"), на SQL Server он вернёт строки, а на SQLite — ни одной. Общая рекомендация — держать такие тесты для поведения, не зависящего от коллации, а для остального писать меньший набор интеграционных тестов против настоящего провайдера с Testcontainers.

Вторая ловушка: SQLite по умолчанию не применяет некоторые проверки ссылочной целостности. Если вам нужно, чтобы каскадное поведение точно совпадало с SQL Server, выполните PRAGMA foreign_keys = ON; после открытия соединения. EF Core 7+ делает это за вас при использовании провайдера SQLite, поэтому обычно об этом думать не нужно, но знать стоит, если пишете сырой SQL в тестах.

Паттерн Б: паттерн репозитория убирает EF Core из теста

Если ваши запросы достаточно сложны, чтобы замена на SQLite вам солгала (специфичные для провайдера функции, JSON-столбцы, полнотекстовый поиск, сырой SQL), самый чистый способ модульного тестирования — поместить EF Core за интерфейс, возвращающий материализованные данные. Вы переносите LINQ в тонкую обёртку, мокаете обёртку, и модульные тесты перестают знать о 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);
}

Критическая деталь — тип возвращаемого значения: IReadOnlyList<Blog> и Task<Blog?>, никогда не IQueryable<Blog>. В тот момент, как вы выставляете IQueryable, вызывающие могут применить к нему .Where(...), и теперь вашему тесту придётся вычислять этот Where против чего-то, что возвращает вас к исходной проблеме. Материализуйте на границе.

Сервис теперь зависит от интерфейса:

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

А тест мокает интерфейс, а не 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);
}

Тест теперь утверждает контракт BlogService (он построил блог с двумя постами и попросил репозиторий сохранить его), не утверждая ничего о EF Core. Сам репозиторий затем проверяется отдельным, меньшим набором тестов, который обращается к реальной базе данных. Это та слоистость, которую рекомендует Microsoft, когда компромисс между точностью теста и его скоростью склоняется в сторону скорости.

Цена реальна. Новый архитектурный слой означает больше кода, больше интерфейсов, больше файлов и соблазн написать обобщённый IRepository<T>, который в итоге окажется протекающей реимплементацией DbSet. Сопротивляйтесь этому. Сделайте интерфейсы ориентированными на задачи, а не на сущности: GetActiveSubscriptions(userId), а не Get(int id). Каждый метод должен соответствовать осмысленному запросу в вашей предметной области.

Почему провайдера EF Core in-memory нет в этом списке

Третий вариант, к которому тянутся некоторые команды, — Microsoft.EntityFrameworkCore.InMemory. Официальные рекомендации устойчиво ужесточаются против него, и текущая страница Learn описывает его использование для тестов как “настоятельно не рекомендуется” и “поддерживается только для устаревших приложений”. Три причины:

Если у вас есть существующий набор тестов, использующий его, и вы пока не обожглись, вы опираетесь на поддельную базу, притворяющуюся более снисходительной, чем настоящая. Путь миграции обычно — однострочная замена UseInMemoryDatabase("name") на паттерн SQLite-в-памяти выше, плюс конструктор, открывающий соединение и засеивающий данные.

Хелперы EF Core 11, меняющие расклад

Два недавних дополнения EF Core 11 стоит знать, потому что они убирают самую раздражающую часть замены провайдеров в тестовой фикстуре — отмену того, что уже зарегистрировал композиционный корень продакшена.

RemoveDbContext<TContext>() удаляет контекст и связанные с ним DbContextOptions из IServiceCollection одним вызовом, заменяя ручной танец из RemoveAll<DbContextOptions<MyContext>>() плюс RemoveAll(typeof(MyContext)), который раньше был хрупким. В сочетании с беспараметровой перегрузкой AddPooledDbContextFactory<TContext>() замена регистрации SQL Server на SQLite внутри WebApplicationFactory<TStartup> становится:

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

Две строки регистрации заменяют прежние восемь, и очистка переживает любые будущие изменения в том, как EF Core связывает свой пайплайн опций. Полный контекст по этой паре — в новом RemoveDbContext для чистой замены провайдеров в тестах.

Если вы также пишете интерцепторы перед SaveChanges, читающие ChangeTracker.Entries(), GetEntriesForState из EF Core 11 избегает повторного прохода DetectChanges, что делает такие интерцепторы дешевле для тестирования в плотном цикле.

Выбор между двумя паттернами

Короткая схема принятия решения, выдерживающая практику:

  1. Если ваш тестируемый код — бизнес-логика, вызывающая метод репозитория, мокайте репозиторий. Не поднимайте базу данных вообще.
  2. Если ваш тестируемый код — сама реализация репозитория или что-либо, строящее LINQ-запросы против DbSet, используйте SQLite в памяти.
  3. Если ваш запрос зависит от специфичного для провайдера поведения (JSON-функции SQL Server, полнотекстовые индексы, EF.Functions.DateDiffDay, сырой SQL с вендорным синтаксисом), напишите вместо этого интеграционный тест против настоящего провайдера с Testcontainers. SQLite скомпилируется, но соврёт в рантайме.
  4. Если вам хочется мокать DbContext напрямую, чтобы проверить “вызвал ли я SaveChanges”, отрефакторьте место вызова, чтобы оно зависело от меньшего интерфейса (IUnitOfWork, IBlogRepository), и проверяйте против него. Мок будет меньше, тест будет читаться лучше, и вы не будете бороться с ChangeTracker.

Сочетание, которое не работает, — “мокать DbContext для запросов”. У любого другого сочетания есть оправданный ответ.

Связанные посты и первоисточники

Первоисточники:

Comments

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

< Назад