Start Debugging

変更追跡を壊さずに DbContext をモックする方法

DbContext を直接モックすると ChangeTracker が静かに壊れます。だからこそ Microsoft はそれを推奨していません。本ガイドは EF Core 11 で実際に機能する 2 つのパターンを示します。接続を開いたまま保持して本物の ChangeTracker を動かす SQLite インメモリと、テストから EF Core を完全に追い出すリポジトリパターンです。

データ層をユニットテストするために DbContext をモックすると、実際のデータベースに触れた瞬間に壊れるコードに対してテストが GO サインを出してしまいます。原因は ChangeTracker です。Mock<MyDbContext> は変更検出を実行せず、Add で主キーを割り当てず、ID 解決を強制せず、SaveChanges が実際に永続化する内容について静かに嘘をつきます。機能する 2 つのパターンは、本物の 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 はコーディネーターであり、データストアではありません。AddUpdateRemoveAttach、そして SaveChanges の前に走る暗黙的な検出はすべて ChangeTracker を経由し、その下には内部的な IStateManager があります。var ctx = new Mock<MyDbContext>() と書いて偽の DbSet を返すよう指示した時点で、これらすべてをバイパスします。具体的に壊れるものは 3 つです。

  1. 生成キーが一切割り当てられない。 実プロバイダーであれば、[Key] int Id { get; set; } カラムに対する Add(blog)blog.Id には一時的な負の値が入り、SaveChanges の後に本物のキーが入ります。モックされたコンテキストは両方を飛ばします。Add の後で blog.Id を読むテストは 0 を見て、保存されていない他のエンティティとの等価チェックが静かに通ってしまいます。
  2. ID 解決が消える。 EF Core は同じ主キーを 2 回読み込むと同じインメモリ インスタンスを返すことを保証します。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 は推奨します — 以下 2 つのアプローチのいずれかを。

全編で使う最小限のエンティティモデル

以下のすべてのスニペットは同じモデルを対象とします。エンティティ 2 つ、親子関係 1 つ、生成キー 1 つ、並行性トークン 1 つ。これが上記の 3 つの変更追跡の失敗をすべて表に出す最小の形だからです。

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

テスト対象システムは、ブログを 2 つの記事と一緒に追加して保存し、新しいブログ 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;
    }
}

AddSaveChangesChangeTracker を介して連携しないと、blog.Id0 のままで、テスト末尾のアサーションは間違った理由で通過します。

パターン A: SQLite インメモリで本物の ChangeTracker を保つ

ここでの目標は、BloggingContext を本番のままにして、プロバイダーだけを差し替えることです。SQLite には :memory: モードがあり、これは開いている 1 本の接続にプライベートで、接続が閉じると破棄されます。これによりファイルを管理することなくテストごとの分離が得られます。落とし穴は、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);
    }
}

注目すべきは 3 点です。第一に、接続はコンストラクターで開かれて Dispose で破棄されるので、インメモリ データベースはクラス内のすべてのテスト メソッドの間生き残りますが、クラスをまたいで漏れることはありません。第二に、テストは BloggingContext のインスタンスを 2 つ 使い、書き込み用と読み取り用に分けます。これにより EF Core は最初のコンテキストからキャッシュされたインスタンスを返すのではなく、データベースからエンティティをマテリアライズすることを強制されます。これが「SaveChanges を呼び忘れた」系のバグを捕まえてくれます。第三に、本物の ChangeTracker が動いているので、blog.Id0 から実際の整数に変わり、NotEqual(0, id) のアサーションには意味があります。

本番データベースとの最も重要な振る舞いの違い: SQLite は既定で LIKE と等価比較が大文字小文字を区別しますが、SQL Server は典型的な *_CI_AS コラレーションでは区別しません。クエリに Where(b => b.Name == "walter") がある場合、SQL Server では行が返り、SQLite では何も返りません。一般的な指針は、こうしたテストはコラレーションに依存しない振る舞い向けに保ち、残りは Testcontainers で本物のプロバイダーに対する小さめの統合テスト群で書くことです。

もう 1 つの落とし穴: SQLite は既定で一部の参照整合性チェックを強制しません。カスケード動作を SQL Server と完全に一致させたい場合は、接続を開いた後に PRAGMA foreign_keys = ON; を実行してください。EF Core 7+ では SQLite プロバイダーを使うとこれを自動でやってくれるので普段は気にする必要はありませんが、テスト内で生 SQL を書く場合は知っておく価値があります。

パターン B: リポジトリパターンで 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 の契約 (記事 2 件付きのブログを構築してリポジトリに保存を依頼した) を主張するもので、EF Core については何も主張しません。リポジトリ自体は別の小さな、本物のデータベースに当たるテスト群で動かされます。これは、テストの忠実度とテスト速度のトレードオフが速度寄りに傾く場合に Microsoft が推奨する階層化です。

代償は実在します。新しいアーキテクチャ層はコードが増え、インターフェースが増え、ファイルが増え、汎用 IRepository<T> を書きたくなる誘惑が生まれ、それは結局 DbSet の漏れた再実装になりがちです。それに抵抗してください。インターフェースはエンティティ単位ではなく、タスク単位にしてください。Get(int id) ではなく GetActiveSubscriptions(userId) です。各メソッドはドメイン上の意味のあるクエリに対応すべきです。

なぜ EF Core のインメモリ プロバイダーがこのリストにないのか

一部のチームが手を伸ばす 3 番目の選択肢が Microsoft.EntityFrameworkCore.InMemory です。公式ガイダンスはこれに対して着実に強硬になっていて、現在の Learn ページは「テストでの使用は強く非推奨」「レガシー アプリケーション向けにのみサポート」と表現しています。理由は 3 つ。

これを使う既存テスト群があってまだ痛い目に遭っていないのなら、本物より緩い振りをする偽データベースに寄りかかっている状態です。移行パスは通常、UseInMemoryDatabase("name") から上の SQLite インメモリ パターンへの一行差し替えと、接続を開いてシードするコンストラクターの追加です。

計算を変える EF Core 11 のヘルパー

最近の EF Core 11 の追加 2 つは、テスト フィクスチャでプロバイダーを差し替える際の最もうっとうしい部分 — 本番のコンポジション ルートが既に登録した内容を取り消す作業 — を取り除いてくれるので知っておく価値があります。

RemoveDbContext<TContext>() は、コンテキストとそれにバインドされた DbContextOptions を 1 回の呼び出しで IServiceCollection から取り除き、これまで脆かった RemoveAll<DbContextOptions<MyContext>>()RemoveAll(typeof(MyContext)) の手作業ダンスを置き換えます。AddPooledDbContextFactory<TContext>() の引数なしオーバーロードと組み合わせると、WebApplicationFactory<TStartup> の中で SQL Server 登録を SQLite に差し替えるのは次のようになります。

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

登録の 2 行が以前の 8 行を置き換え、後始末は EF Core がオプション パイプラインを配線する方法の将来の変更にも耐えます。このペアの完全な背景は テスト用プロバイダー差し替えをきれいにする新しい RemoveDbContext にあります。

ChangeTracker.Entries() を読む SaveChanges 前のインターセプターを書いている場合は、EF Core 11 の GetEntriesForStateDetectChanges の重複パスを避けてくれるので、こうしたインターセプターをタイトなループでテストする際のコストが下がります。

2 つのパターンの選び方

実務に耐える短い意思決定フロー:

  1. テスト対象が リポジトリ メソッドを呼び出すビジネス ロジック なら、リポジトリをモックします。データベースは一切立ち上げません。
  2. テスト対象が リポジトリの実装そのもの、または DbSet に対して LINQ クエリを構築する何か なら、SQLite インメモリを使います。
  3. クエリが プロバイダー固有の振る舞い に依存している場合 (SQL Server の JSON 関数、全文インデックス、EF.Functions.DateDiffDay、ベンダー構文を含む生 SQL) は、代わりに Testcontainers を使って本物のプロバイダーに対する統合テストを書きます。SQLite はコンパイルは通しますが実行時には嘘をつきます。
  4. SaveChanges を呼んだか?」を検証するために DbContext を直接モックしたくなったら、呼び出しサイドをより小さなインターフェース (IUnitOfWorkIBlogRepository) に依存するようリファクタし、それに対して検証してください。モックは小さくなり、テストは読みやすくなり、ChangeTracker と戦わずに済みます。

機能しない組み合わせは「クエリのために DbContext をモックする」ことです。それ以外の組み合わせには、すべて筋の通った答えがあります。

関連記事と一次資料

一次資料:

Comments

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

< 戻る