コンテンツにスキップ

付録 EF Core 6:テスト

このページは 第8章:データベースアクセスと ORM (Entity Framework Core) の付録です。EF Core を使うコードのテストを扱います。

本編を先に読んでから、必要な項目をここで参照してください。

第8章のほかの付録


  1. データベースを使ったテスト
  2. 参考ドキュメント

1. データベースを使ったテスト

Section titled “1. データベースを使ったテスト”

実データベースに対するテスト

Section titled “実データベースに対するテスト”

xUnit では、テスト用のセットアップに フィクスチャ を使います。次は、クラスフィクスチャのインスタンスはテストクラスごとに生成し、同じテストプロセス内でデータベースの初期化を 1 回だけ行う例です。本編で BloggingContext 用のマイグレーションを作成済みで、それを適用できる構成を前提とします。 Migrate() はモデルから新しいマイグレーションを生成する処理ではありません。

public class TestDatabaseFixture
{
private const string ConnectionString =
@"Server=(localdb)\mssqllocaldb;Database=BloggingTest;Trusted_Connection=True";
private static readonly object Lock = new();
private static bool _databaseInitialized;
public TestDatabaseFixture()
{
lock (Lock)
{
if (_databaseInitialized)
{
return;
}
using var context = CreateContext();
context.Database.EnsureDeleted();
context.Database.Migrate();
SeedData(context);
_databaseInitialized = true;
}
}
public BloggingContext CreateContext()
=> new(new DbContextOptionsBuilder<BloggingContext>()
.UseSqlServer(ConnectionString)
.Options);
private static void SeedData(BloggingContext context)
{
context.Blogs.AddRange(
new Blog { Name = "Blog A", Url = "https://a.example.com" },
new Blog { Name = "Blog B", Url = "https://b.example.com" });
context.SaveChanges();
}
}

読み取りのテストはそのまま書けます。

public class BlogQueryTests(TestDatabaseFixture fixture) : IClassFixture<TestDatabaseFixture>
{
[Fact]
public async Task Blogs_are_ordered_by_name()
{
await using var context = fixture.CreateContext();
var blogs = await context.Blogs
.AsNoTracking()
.OrderBy(b => b.Name)
.ToListAsync();
Assert.Equal(["Blog A", "Blog B"], blogs.Select(b => b.Name));
}
}

書き込みを伴うテストは、トランザクションを開始してコミットしないことで、そのトランザクションに参加する変更をロールバックできます。テストが別の接続でコミットした変更や外部への副作用まで取り消すものではありません。

[Fact]
public async Task Can_add_blog()
{
await using var context = fixture.CreateContext();
await using var transaction = await context.Database
.BeginTransactionAsync();
context.Blogs.Add(new Blog { Name = "Blog C", Url = "https://c.example.com" });
await context.SaveChangesAsync();
var count = await context.Blogs.CountAsync();
Assert.Equal(3, count);
// コミットしないため、破棄時にロールバックされる
}

製品コードがトランザクションを使うテスト

Section titled “製品コードがトランザクションを使うテスト”

公式のトランザクションを明示管理するテストでは、データベースは通常入れ子のトランザクションをサポートしないため、製品コードが明示的にトランザクションを管理する場合は、テストを外側のトランザクションで分離できないと説明しています。

SQL Server 2022 で、テスト側のトランザクション内から BeginTransactionAsync(IsolationLevel.Serializable) を呼ぶ確認例では、次の例外を確認できています。

InvalidOperationException: The connection is already in a transaction and cannot
participate in another transaction.

この種のテストは、公式によれば次のように扱う必要があります。

必要なこと理由
テストごとにデータベースを元の状態へ戻す変更が実際にコミットされるため
並列実行を無効にするテスト同士が干渉するため
他のテストとは別のデータベースを使うフィクスチャを用意する既存のテストに影響を与えないため

xUnit では、複数のテストクラスでフィクスチャを共有する場合、クラスフィクスチャではなくコレクションフィクスチャを使います。同じクラス内のテストは xUnit が並列化しないため、単一のクラスでしか使わないならクラスフィクスチャのままで構いません。

クリーンアップで全行の読み込みを避ける

Section titled “クリーンアップで全行の読み込みを避ける”

コミットを伴うテストでは、テスト間に変更を残さないクリーンアップが必要です。公式のテストガイドは、読み込み後の RemoveRange による削除に対し、生の SQL の DELETE を選択肢として挙げています。ExecuteDelete の公式説明も、エンティティを読み込まず削除する利点を示しています。生 SQL と EF Core API 全般の速さを比較する話ではありません。

次は、SQL Server 2022 で 1,000 行を削除する 2 方式の確認例です。

方法所要時間
RemoveRange + SaveChangesAsync443 ms
ExecuteSqlRawAsync("DELETE FROM [Blogs]")9 ms

この条件の所要時間は 443 ms と 9 ms です。比較対象は全行を読み込む RemoveRange + SaveChanges と生 SQL で、ExecuteDelete の結果を含みません。比率や順位は他の条件へ一般化できません。

削除だけでは初期データまでなくなります。初期データを前提とするテストでは、コミットするテスト専用のフィクスチャにも前述の SeedData と同じ初期化処理を用意し、削除後に再投入してください。次のメソッドをそのフィクスチャに追加します。上の所要時間は削除処理だけのもので、再投入の時間は含みません。

public async Task CleanupAsync()
{
await using var context = CreateContext();
await context.Database.ExecuteSqlRawAsync("DELETE FROM [Blogs]");
SeedData(context);
}

テストで内部サービスを差し替えるとき

Section titled “テストで内部サービスを差し替えるとき”

EF Core は、クエリのコンパイルやモデルの構築に使う内部サービスプロバイダーを、構成が同じ DbContext のあいだで再利用(キャッシュ)します。テストで ReplaceService を使ってサービスを差し替える場合、この共有が問題になることがあります。EnableServiceProviderCaching(false) を指定すると、内部サービスプロバイダーのキャッシュが無効になります。「テストごと」ではなく、Context が内部サービスを必要とする際の構成に関わる指定です。

optionsBuilder
.EnableServiceProviderCaching(false)
.UseSqlServer(connectionString);

SQLite インメモリを使ったテスト

Section titled “SQLite インメモリを使ったテスト”

より軽量に済ませたい場合は、SQLite のインメモリモードが選択肢になります。リレーショナルデータベースとして動作するため、InMemory プロバイダーより挙動が本番に近くなります。

public sealed class SqliteContextFactory : IDisposable
{
private readonly SqliteConnection _connection;
public SqliteContextFactory()
{
// 接続を開いている間だけデータベースが存在する
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
using var context = CreateContext();
context.Database.EnsureCreated();
}
public BloggingContext CreateContext()
=> new(new DbContextOptionsBuilder<BloggingContext>()
.UseSqlite(_connection)
.Options);
public void Dispose() => _connection.Dispose();
}

SQLite プロバイダーの制限を把握する

Section titled “SQLite プロバイダーの制限を把握する”

本番が SQL Server なら SQLite のテスト成功は本番の保証ではありません。 公式の SQLite 制限は、スキーマ・シーケンス・データベース生成の同時実行トークンの非対応を説明しています。一方、decimal の現行の翻訳は公式の関数マッピングと EF Core 10 の新機能も参照してください。次は EF Core 10.0.11 / SQLite での確認結果です。例外の型や DDL の形はこの構成での観測です。

検証した内容結果
decimal 列の型TEXT にマップされる
decimal の等価比較WHERE "Price" = '10.5' としてそのまま翻訳される
decimal の大小比較WHERE ef_compare("Price", '5.0') > 0 に翻訳される
decimal の並べ替えORDER BY "Price" COLLATE EF_DECIMAL に翻訳される
DateTimeOffset の大小比較InvalidOperationException(翻訳できない)
TimeSpan の大小比較InvalidOperationException(翻訳できない)
ToTable("Items", "sales")スキーマが無視されて CREATE TABLE "Items" になる
HasSequence<int>()NotSupportedException: SQLite does not support sequences.
IsRowVersion()例外にならず "RowVersion" BLOB NOT NULL が作られる

特に、スキーマ指定と IsRowVersion() は、テーブルを作成できたことだけでは本番と同じ動作を確認できません。 HasSequence<int>() は非対応の例外になるため、区別してください。

  • スキーマ指定は警告が出たうえで無視されるので、SQL Server で sales.Items と dbo.Items を使い分けている設計は SQLite 上では区別されません。
  • SQLite は SQL Server の rowversion のようにトークンを自動生成・更新しません。SQL Server の自動生成まで含む同時実行制御の代用にはなりません。 この確認例の BLOB NOT NULL 列に既定値・トリガーなしで挿入する条件では、NOT NULL constraint failed を内部例外とする DbUpdateException を確認できています。

公式のアプリケーション管理トークンは、自動更新型を持たない SQLite でも利用できると説明しています。次は Guid に IsConcurrencyToken() を設定した確認例です(EF Core 10.0.11、SQLite インメモリ)。更新時のトークン再生成の有無を分けています。

先に保存する側の処理古いトークンを持つ側の処理結果
データとトークンを更新更新して保存DbUpdateConcurrencyException
データとトークンを更新削除して保存DbUpdateConcurrencyException
データだけ更新し、トークンは変更しない更新して保存競合を検出せず、後の値で上書き

表は、2 つの DbContext で同じ行を読み、先行更新の後に古いトークンで保存する確認例です。トークンの再生成はアプリケーションの責任です。 本番の rowversion の自動更新は SQL Server で検証してください。設定と競合解決は付録4の「楽観的同時実行制御」で扱います。

Math のメソッドはサーバー側で評価される

Section titled “Math のメソッドはサーバー側で評価される”

公式の EF Core 8 の破壊的変更では、SQLite に対応する数学関数を持つ Math メソッドの翻訳が拡張されたと説明しています。EF Core 7 までは Abs / Max / Min / Round だけが翻訳され、それ以外は最終投影でクライアント評価されていました。現行の対応は公式の関数マッピングを参照してください。

var q = db.Ms.Select(x => new
{
P = Math.Pow(x.V, 2),
Sq = Math.Sqrt(x.V),
L = Math.Log(x.V),
E = Math.Exp(x.V),
});

この確認例では、4 つとも次の SQLite 関数への翻訳を確認できています。Math.Log は ln に対応します。

SELECT pow("m"."V", 2.0) AS "P", sqrt("m"."V") AS "Sq",
ln("m"."V") AS "L", exp("m"."V") AS "E"
FROM "Ms" AS "m"

公式の EF Core 9 の破壊的変更は、SQLite の unhex が不正な入力に NULL を返すため、EF.Functions.Unhex() の戻り値注釈を byte[]? に修正したと説明しています。16 進数の変換結果が null になる場合を考慮してください。

var q = db.Hexes.Select(x => EF.Functions.Unhex(x.S));
// SELECT unhex("h"."S") FROM "Hexes" AS "h"

次は 48656C6C6F(Hello)と不正な 16 進文字列の 2 行を使う確認例です。後者の null を確認できています。

結果: Hello
結果: null

EF Core 9 以降の宣言は byte[]? です。以前の byte[] という注釈と実際の null の可能性の食い違いを直す変更であり、観測結果から戻り値の契約を決めるものではありません。

// 正しい 16 進数だと確信できる場合
var data = await db.Hexes.Select(b => EF.Functions.Unhex(b.S)!).ToListAsync();
// そうでない場合は null チェックを入れる

先行書き込みログ (WAL) が有効かを確認する

Section titled “先行書き込みログ (WAL) が有効かを確認する”

公式の WAL の案内では、EF Core が作成するデータベースは既定で WAL 有効と説明されています。ここで対象にするのはファイルデータベースです。 SQLite 自体の公式仕様では、インメモリデータベースのジャーナルモードは MEMORY または OFF に限られ、WAL には切り替えられません。エラー処理の説明は、Microsoft.Data.Sqlite が busy / locked エラーを成功またはタイムアウトまで再試行するとしています。これは EF Core の実行戦略とは別の層です。

補足として、EF Core 10.0.11 / SQLite、WAL 無効、別接続の書き込みロックを 300 ミリ秒後に解除する条件では、RETURNING 付きの保存成功を確認できています。EF の実行戦略は再試行無効です。この結果をすべてのロック競合の回復保証や、WAL 不要の根拠にはしません。

次は作成方法ごとの journal_mode の確認例です。インメモリの行は EF Core 10.0.11 / Data Source=:memory: での確認で、PRAGMA journal_mode = 'wal' を実行しても memory のままです。

作成方法PRAGMA journal_mode
EF Core が作成したファイルデータベースwal
SqliteConnection で直接作成したファイルデータベースdelete
EF Core が作成したインメモリデータベースmemory

既存のファイルを引き継ぐ場合は、作成経路だけで設定を仮定せず、journal_mode を確認してください。WAL を有効にするには、公式が示す次の PRAGMA を使います。

PRAGMA journal_mode = 'wal';

公式の SQLite マイグレーション制限は、対応する一部の変更をテーブルの再構築で行うと説明しています。次は Note 列を削除して Rank 列を追加するマイグレーションの SQL から、テーブルの変更部分を抜粋した確認例です。マイグレーション履歴への記録などは省略しています。

BEGIN TRANSACTION;
ALTER TABLE "Blogs" ADD "Rank" INTEGER NOT NULL DEFAULT 0;
CREATE TABLE "ef_temp_Blogs" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Blogs" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NOT NULL,
"Rank" INTEGER NOT NULL
);
INSERT INTO "ef_temp_Blogs" ("Id", "Name", "Rank")
SELECT "Id", "Name", "Rank" FROM "Blogs";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "Blogs";
ALTER TABLE "ef_temp_Blogs" RENAME TO "Blogs";
COMMIT;
PRAGMA foreign_keys = 1;

この変更に対して生成されたスクリプトには、全行のコピーと外部キー検査の一時停止が含まれます。すべての列削除で必ず同じスクリプトになるという意味ではありません。行数の多いテーブルでは所要時間と一時的なディスク使用量に注意してください。

また、公式の冪等スクリプトの制限のとおり、SQLite は必要な条件分岐の手続き言語を持たず、冪等スクリプトを生成できません。次は本編の生成コマンドに対応するエラー確認例で、末尾の案内を省略した抜粋です。

Generating idempotent scripts for migrations is not currently supported for SQLite.

WebApplicationFactory で API ごとテストする

Section titled “WebApplicationFactory で API ごとテストする”

ASP.NET Core の統合テストでは、WebApplicationFactory<TEntryPoint> でアプリケーション全体を起動し、テスト用のデータベースに差し替えます。

using System.Data.Common;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
public class BloggingApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 既定の DbContext 構成を取り除く
// IDbContextOptionsConfiguration<T> まで消さないとプロバイダーが重複する
services.RemoveAll<DbContextOptions<BloggingContext>>();
services.RemoveAll<DbContextOptions>();
services.RemoveAll<IDbContextOptionsConfiguration<BloggingContext>>();
// 接続を開いたままにしてインメモリデータベースを保持する
services.AddSingleton<DbConnection>(_ =>
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
return connection;
});
services.AddDbContext<BloggingContext>((serviceProvider, options) =>
{
var connection = serviceProvider.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
});
builder.UseEnvironment("Testing");
}
}

テスト側では HttpClient を取得してエンドポイントを呼び出します。

public class BlogsApiTests(BloggingApiFactory factory) : IClassFixture<BloggingApiFactory>
{
[Fact]
public async Task Get_blogs_returns_ok()
{
using var scope = factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<BloggingContext>();
await context.Database.EnsureCreatedAsync();
var client = factory.CreateClient();
var response = await client.GetAsync("/api/blogs");
response.EnsureSuccessStatusCode();
}
}

データベースをまったく使わずにビジネスロジックだけをテストしたい場合は、データアクセスをインターフェイスの背後に隠します。

public interface IBlogRepository
{
Task<IReadOnlyList<Blog>> GetPopularBlogsAsync(
int minRating,
CancellationToken cancellationToken);
Task AddAsync(Blog blog, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}
public class BlogRepository(BloggingContext context) : IBlogRepository
{
public async Task<IReadOnlyList<Blog>> GetPopularBlogsAsync(
int minRating, CancellationToken cancellationToken)
=> await context.Blogs
.AsNoTracking()
.Where(b => b.Rating >= minRating)
.OrderByDescending(b => b.Rating)
.ToListAsync(cancellationToken);
public Task AddAsync(Blog blog, CancellationToken cancellationToken)
{
context.Blogs.Add(blog);
return Task.CompletedTask;
}
public async Task SaveChangesAsync(CancellationToken cancellationToken)
=> await context.SaveChangesAsync(cancellationToken);
}

テストではインターフェイスの実装を差し替えます。

public class FakeBlogRepository : IBlogRepository
{
private readonly List<Blog> _blogs = [];
public Task<IReadOnlyList<Blog>> GetPopularBlogsAsync(
int minRating,
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<Blog>>(
_blogs.Where(b => b.Rating >= minRating).OrderByDescending(b => b.Rating).ToList());
public Task AddAsync(Blog blog, CancellationToken cancellationToken)
{
_blogs.Add(blog);
return Task.CompletedTask;
}
public Task SaveChangesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}