前回は、EF Core の追加処理をテストしました。
今回は、更新と削除をテストします。
ここには、データアクセス層で特に壊れやすい要素が集まっています。
- 追跡中のエンティティを更新する
- 追跡していないエンティティを更新する
- 一部の列だけ更新する
- 関連データを持つ行を削除する
- 削除制約の失敗を扱う
- 同時実行の競合を検出する
問い合わせや追加よりも、データの破壊につながりやすい処理です。
テストで動作を固定しておく価値があります。
追跡ありの更新をテストする
まず、コンテキストで取得したエンティティを変更して保存する例です。
public async Task RenameAsync(int id, string color, string petName)
{
var car = await _context.Cars
.SingleAsync(car => car.Id == id);
car.Rename(car.Make, color, petName);
await _context.SaveChangesAsync();
}
テストです。
public sealed class CarUpdateTests : DatabaseTestBase
{
[Fact]
public async Task 追跡中の車を更新できる()
{
var car = await Context.Cars.FirstAsync();
var repository = new CarRepository(Context);
await repository.RenameAsync(car.Id, "Purple", "Violet");
await using var verificationContext =
new AutoLotContext(TestDbContextOptions.Create());
var saved = await verificationContext.Cars
.AsNoTracking()
.SingleAsync(item => item.Id == car.Id);
Assert.Equal("Purple", saved.Color);
Assert.Equal("Violet", saved.PetName);
}
}
別の DbContext で読み直しているため、追跡中の値ではなく、保存された値を確認できます。
追跡なしの更新をテストする
画面や API から更新内容だけが返ってくる場合、エンティティを追跡していない状態で更新することがあります。
public async Task UpdateDetachedAsync(UpdateCarRequest request)
{
var car = new Car(
request.Make,
request.Color,
request.PetName);
_context.Attach(car);
_context.Entry(car).Property("Id").CurrentValue = request.Id;
_context.Entry(car).State = EntityState.Modified;
await _context.SaveChangesAsync();
}
この例は概念を示すためのものです。
実際には、主キーを設定できるコンストラクターや内部メソッドを用意するなど、エンティティ設計に合わせて調整します。
追跡なし更新では、意図しない列まで更新しないようにすることが大切です。
一部の列だけを更新するなら、次のように明示します。
public async Task ChangePetNameAsync(int id, string petName)
{
var car = new CarPatch { Id = id, PetName = petName };
_context.Attach(car);
_context.Entry(car).Property(item => item.PetName).IsModified = true;
await _context.SaveChangesAsync();
}
テストでは、変更した列と変更していない列の両方を確認します。
[Fact]
public async Task 愛称だけを更新できる()
{
var car = await Context.Cars.AsNoTracking().FirstAsync();
var repository = new CarRepository(Context);
await repository.ChangePetNameAsync(car.Id, "NewName");
await using var verificationContext =
new AutoLotContext(TestDbContextOptions.Create());
var saved = await verificationContext.Cars
.AsNoTracking()
.SingleAsync(item => item.Id == car.Id);
Assert.Equal("NewName", saved.PetName);
Assert.Equal(car.Make, saved.Make);
Assert.Equal(car.Color, saved.Color);
}
部分更新のテストでは、「変わるべきもの」と「変わってはいけないもの」を両方見るのがコツです。
削除をテストする
基本的な削除メソッドです。
public async Task DeleteAsync(int id)
{
var car = await _context.Cars
.SingleAsync(car => car.Id == id);
_context.Cars.Remove(car);
await _context.SaveChangesAsync();
}
テストです。
[Fact]
public async Task 車を削除できる()
{
var car = await Context.Cars
.Where(car => !car.Orders.Any())
.FirstAsync();
var repository = new CarRepository(Context);
await repository.DeleteAsync(car.Id);
var exists = await Context.Cars.AnyAsync(item => item.Id == car.Id);
Assert.False(exists);
}
注文を持たない車を選んでいるのは、外部キー制約による失敗を避けるためです。
削除テストでは、関連データの有無を意識する必要があります。
削除制約の失敗をテストする
注文を持つ車を削除しようとすると、外部キー制約で失敗する設計にしている場合があります。
[Fact]
public async Task 注文を持つ車を削除すると失敗する()
{
var car = await Context.Cars
.Include(car => car.Orders)
.FirstAsync(car => car.Orders.Any());
var repository = new CarRepository(Context);
await Assert.ThrowsAsync<DataStoreException>(() =>
repository.DeleteAsync(car.Id));
}
リポジトリ側では、EF Core の例外を独自例外に変換します。
public async Task DeleteAsync(int id)
{
try
{
var car = await _context.Cars.SingleAsync(car => car.Id == id);
_context.Cars.Remove(car);
await _context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
throw new DataStoreException(
"関連データが存在するため削除できません。", ex);
}
}
テストで確認したいのは、データベースの細かいエラー番号ではありません。
アプリケーションとして、削除できない状態を適切な例外に変換できているかです。
論理削除をテストする
物理削除ではなく、削除済みフラグを立てる設計もあります。
public async Task SoftDeleteAsync(int id)
{
var car = await _context.Cars.SingleAsync(car => car.Id == id);
car.MarkAsDeleted();
await _context.SaveChangesAsync();
}
テストでは、通常の一覧から消えることと、データ自体は残っていることを確認します。
[Fact]
public async Task 論理削除した車は通常の問い合わせから除外される()
{
var car = await Context.Cars.FirstAsync();
var repository = new CarRepository(Context);
await repository.SoftDeleteAsync(car.Id);
var visible = await Context.Cars.AnyAsync(item => item.Id == car.Id);
var existsIgnoringFilter = await Context.Cars
.IgnoreQueryFilters()
.AnyAsync(item => item.Id == car.Id);
Assert.False(visible);
Assert.True(existsIgnoringFilter);
}
グローバルフィルターを使っている場合、IgnoreQueryFilters() で実データの存在を確認できます。
更新時の同時実行をテストする
同時実行制御では、2 つの DbContext を使って競合を再現します。
[Fact]
public async Task 他の利用者が更新した場合は競合として扱う()
{
var carId = await Context.Cars
.Select(car => car.Id)
.FirstAsync();
await using var firstContext =
new AutoLotContext(TestDbContextOptions.Create());
await using var secondContext =
new AutoLotContext(TestDbContextOptions.Create());
var firstCar = await firstContext.Cars.SingleAsync(car => car.Id == carId);
var secondCar = await secondContext.Cars.SingleAsync(car => car.Id == carId);
firstCar.Rename(firstCar.Make, "Red", firstCar.PetName);
await firstContext.SaveChangesAsync();
secondCar.Rename(secondCar.Make, "Blue", secondCar.PetName);
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() =>
secondContext.SaveChangesAsync());
}
このテストが動くには、エンティティに同時実行トークンが設定されている必要があります。
builder.Property(car => car.RowVersion)
.IsRowVersion();
競合は、同じデータを別々のコンテキストで読み、それぞれが保存しようとしたときに起きます。
テストでも、その状況を作る必要があります。
例外変換までテストする
リポジトリで同時実行例外を独自例外へ変換しているなら、そこまで確認します。
public async Task SaveChangesAsync()
{
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
throw new RecordChangedException(
"他の利用者によってデータが変更されています。", ex);
}
}
テストでは、独自例外が投げられることを確認します。
[Fact]
public async Task 同時実行の競合は独自例外に変換される()
{
var carId = await Context.Cars
.Select(car => car.Id)
.FirstAsync();
await using var firstContext =
new AutoLotContext(TestDbContextOptions.Create());
await using var secondContext =
new AutoLotContext(TestDbContextOptions.Create());
var firstRepository = new CarRepository(firstContext);
var secondRepository = new CarRepository(secondContext);
var firstCar = await firstContext.Cars.SingleAsync(car => car.Id == carId);
var secondCar = await secondContext.Cars.SingleAsync(car => car.Id == carId);
firstCar.Rename(firstCar.Make, "Red", firstCar.PetName);
await firstRepository.SaveChangesAsync();
secondCar.Rename(secondCar.Make, "Blue", secondCar.PetName);
await Assert.ThrowsAsync<RecordChangedException>(() =>
secondRepository.SaveChangesAsync());
}
呼び出し側が依存するのは、EF Core の例外ではなく、アプリケーションの例外です。
その境界をテストしておくと、内部実装を変えても外側の振る舞いを保ちやすくなります。
更新と削除のテストで見ること
更新と削除では、次の観点を確認します。
- 変更した値が保存される
- 変更していない値が壊れない
- 別の
DbContextから保存結果を確認できる - 削除できるデータは削除できる
- 削除できないデータは適切に失敗する
- 論理削除なら通常問い合わせから除外される
- 同時実行の競合を再現できる
- EF Core の例外をアプリケーションの例外へ変換できる
データアクセス層のテストは、少し準備が重いです。
しかし、保存や削除のような重要処理をテストしておくと、後からリファクタリングするときの安心感が大きくなります。
ここまでで、データアクセス層を作るだけでなく、実際に動くことを確認する流れまで見てきました。
テストは、設計の付録ではありません。設計を長く保つための支えです。