前回は、EF Core とリポジトリを組み合わせて、データ操作の入口を整理しました。
今回は、データベースそのものの準備を扱います。
開発中やテストでは、次のような処理がよく必要になります。
- データベースを削除して作り直す
- マイグレーションを適用する
- サンプルデータを投入する
- テストごとにデータを初期状態へ戻す
これらを毎回手作業で行うと、環境差や手順漏れが起きやすくなります。
プログラムから安全に扱える形にしておくと、開発体験が大きく良くなります。
データベースを作り直す
EF Core には、データベースを削除したり作成したりする API があります。
public sealed class DatabaseInitializer
{
private readonly AutoLotContext _context;
public DatabaseInitializer(AutoLotContext context)
{
_context = context;
}
public async Task RecreateAsync()
{
await _context.Database.EnsureDeletedAsync();
await _context.Database.EnsureCreatedAsync();
}
}
EnsureDeletedAsync() はデータベースを削除します。
EnsureCreatedAsync() は、現在のモデルからデータベースを作成します。
この方法は簡単ですが、マイグレーション履歴を使いません。
開発用の一時データベースや、学習用のサンプルでは便利です。
一方で、マイグレーションで管理している本番相当のデータベースでは、次の方法を使う方が自然です。
マイグレーションを適用する
マイグレーションを使う場合は、MigrateAsync() を呼び出します。
public async Task ApplyMigrationsAsync()
{
await _context.Database.MigrateAsync();
}
これにより、未適用のマイグレーションが順番に適用されます。
アプリケーション起動時に実行するなら、次のようにスコープを作って呼び出せます。
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider
.GetRequiredService<DatabaseInitializer>();
await initializer.ApplyMigrationsAsync();
ただし、本番環境でアプリケーション起動時に自動適用するかどうかは、運用方針によります。
大きな変更や長時間のロックが発生する可能性があるため、本番では事前にスクリプトを確認して適用する運用も多いです。
開発環境だけで実行する
危険な初期化処理は、環境を見て制限します。
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var initializer = scope.ServiceProvider
.GetRequiredService<DatabaseInitializer>();
await initializer.RecreateAsync();
await initializer.SeedAsync();
}
削除を伴う処理を本番環境で実行しないことが重要です。
環境名だけに頼らず、接続先データベース名を確認するなど、二重の安全策を入れることもあります。
private void ThrowIfProductionDatabase()
{
var connection = _context.Database.GetDbConnection();
if (connection.Database.Contains("Production", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"本番データベースに対して初期化処理は実行できません。");
}
}
開発用の便利処理ほど、誤実行への備えが必要です。
サンプルデータを作る
初期データ投入では、まずデータを作る処理を分けておくと読みやすくなります。
private static List<Car> CreateCars()
{
return new List<Car>
{
new()
{
Make = "Toyota",
Color = "Blue",
PetName = "Aqua",
DateBuilt = new DateTime(2022, 4, 1),
IsDrivable = true
},
new()
{
Make = "Honda",
Color = "White",
PetName = "Snow",
DateBuilt = new DateTime(2021, 9, 12),
IsDrivable = true
},
new()
{
Make = "Ford",
Color = "Black",
PetName = "Night",
DateBuilt = new DateTime(2020, 11, 5),
IsDrivable = false
}
};
}
投入処理です。
public async Task SeedAsync()
{
if (await _context.Cars.AnyAsync())
{
return;
}
var cars = CreateCars();
await _context.Cars.AddRangeAsync(cars);
await _context.SaveChangesAsync();
}
すでにデータがある場合は何もしないようにしています。
開発用データを何度も重複投入しないためです。
関連データも一緒に投入する
関連データを持つ場合は、オブジェクトグラフとして作ると自然です。
private static List<Car> CreateCarsWithOrders()
{
return new List<Car>
{
new()
{
Make = "Toyota",
Color = "Blue",
PetName = "Aqua",
DateBuilt = new DateTime(2022, 4, 1),
Orders =
{
new Order
{
CustomerName = "佐藤",
OrderDate = new DateTime(2026, 8, 1)
},
new Order
{
CustomerName = "田中",
OrderDate = new DateTime(2026, 8, 2)
}
}
},
new()
{
Make = "Honda",
Color = "White",
PetName = "Snow",
DateBuilt = new DateTime(2021, 9, 12),
Orders =
{
new Order
{
CustomerName = "鈴木",
OrderDate = new DateTime(2026, 8, 3)
}
}
}
};
}
Car を追加すると、関連する Order も追加対象になります。
public async Task SeedWithOrdersAsync()
{
if (await _context.Cars.AnyAsync())
{
return;
}
await _context.Cars.AddRangeAsync(CreateCarsWithOrders());
await _context.SaveChangesAsync();
}
サンプルデータは、実際の画面で起こりそうな状態を含めると役立ちます。
正常なデータだけでなく、整備中、注文なし、複数注文あり、古いデータなどを混ぜると、画面や検索条件の確認がしやすくなります。
既存データを消してから入れ直す
テストや開発では、毎回同じ初期状態に戻したいことがあります。
public async Task ResetSampleDataAsync()
{
_context.Orders.RemoveRange(_context.Orders);
_context.Cars.RemoveRange(_context.Cars);
await _context.SaveChangesAsync();
await _context.Cars.AddRangeAsync(CreateCarsWithOrders());
await _context.SaveChangesAsync();
}
削除順に注意が必要です。
外部キー制約がある場合、子テーブルを先に削除しないと失敗することがあります。
大量データを扱う場合は、RemoveRange() よりも一括削除や SQL を使う方が効率的なこともあります。
await _context.Orders.ExecuteDeleteAsync();
await _context.Cars.ExecuteDeleteAsync();
ExecuteDeleteAsync() はデータベース側で削除を実行します。
追跡中のエンティティとは同期されないため、同じ DbContext で続けて処理する場合は注意します。
テスト用の基底クラス
統合テストでは、データベース準備を共通化すると楽になります。
public abstract class DatabaseTestBase : IAsyncLifetime
{
protected AutoLotContext Context { get; private set; } = default!;
public async Task InitializeAsync()
{
var options = new DbContextOptionsBuilder<AutoLotContext>()
.UseSqlServer(
"Server=(localdb)\\mssqllocaldb;Database=AutoLot_Test;Trusted_Connection=True;")
.Options;
Context = new AutoLotContext(options);
var initializer = new DatabaseInitializer(Context);
await initializer.RecreateAsync();
await initializer.SeedAsync();
}
public async Task DisposeAsync()
{
await Context.DisposeAsync();
}
}
テストクラスは、この基底クラスを継承します。
public sealed class CarRepositoryTests : DatabaseTestBase
{
[Fact]
public async Task GetAvailableCarsAsync_ReturnsOnlyDrivableCars()
{
var repository = new CarRepository(Context);
var cars = await repository.GetAvailableCarsAsync();
Assert.All(cars, car =>
{
Assert.Equal("販売可能", car.Status);
});
}
}
このように、テストごとに同じ状態から始められると、失敗原因を追いやすくなります。
初期化処理はアプリケーションの品質に効く
データベース初期化は、地味な処理です。
しかし、開発者が何度も使う処理でもあります。
初期化が安定していると、次のような利点があります。
- 新しい開発者が環境を作りやすい
- テストデータの状態がそろう
- 画面確認のたびに手作業でデータを直さなくてよい
- マイグレーションの問題を早く見つけやすい
- サンプルデータが仕様の説明にもなる
データアクセス層は、問い合わせや保存だけではありません。
データベースをどう準備し、どう育て、どう戻せるようにするかも大事な役割です。
今回で、EF Core を使ったデータアクセス層の基本的な構成を一通り見ました。
プロジェクト分割、モデル設計、DbContext の拡張、リポジトリ、初期化処理を組み合わせることで、アプリケーションは少しずつ保守しやすい形になります。