前回は、データアクセス層をテストするためのプロジェクトと共通基底クラスを準備しました。
今回は、その土台を使って問い合わせ処理をテストします。
問い合わせのテストでは、単に「例外が出ない」だけでは不十分です。
期待した条件で絞り込めているか、並び順は正しいか、関連データは必要な形で取得できているかを確認します。
一覧取得をテストする
まず、利用可能な車だけを返すリポジトリを考えます。
public sealed class CarRepository
{
private readonly AutoLotContext _context;
public CarRepository(AutoLotContext context)
{
_context = context;
}
public async Task<List<CarListItem>> GetAvailableCarsAsync()
{
return await _context.Cars
.AsNoTracking()
.Where(car => car.IsDrivable)
.OrderBy(car => car.Make)
.Select(car => new CarListItem
{
Id = car.Id,
Name = car.Make + " " + car.Color + " " + car.PetName,
State = "販売可能"
})
.ToListAsync();
}
}
テストです。
public sealed class CarQueryTests : DatabaseTestBase
{
[Fact]
public async Task 販売可能な車だけを取得できる()
{
var repository = new CarRepository(Context);
var cars = await repository.GetAvailableCarsAsync();
Assert.NotEmpty(cars);
Assert.All(cars, car => Assert.Equal("販売可能", car.State));
}
}
このテストでは、取得結果の全件が販売可能であることを確認しています。
件数だけを見ると、初期データが変わったときに壊れやすくなります。
意図を表す条件も一緒に確認すると、テストの意味が明確になります。
並び順をテストする
一覧画面では、並び順も仕様の一部です。
[Fact]
public async Task メーカー名の昇順で取得できる()
{
var repository = new CarRepository(Context);
var cars = await repository.GetAvailableCarsAsync();
var expected = cars
.OrderBy(car => car.Name)
.Select(car => car.Id)
.ToList();
var actual = cars
.Select(car => car.Id)
.ToList();
Assert.Equal(expected, actual);
}
ただし、表示名で並べるのか、メーカー名で並べるのかはリポジトリの実装と合わせる必要があります。
より厳密にするなら、テストデータを固定し、期待する ID の順番を明示します。
Assert.Equal(new[] { 2, 1 }, cars.Select(car => car.Id));
この書き方は意図が強く出ます。
一方で、初期データを変えると壊れやすくなります。
どちらを選ぶかは、その並び順がどれほど重要かで決めます。
条件検索をテストする
検索条件を受け取るメソッドを用意します。
public sealed class CarSearchCondition
{
public string? Make { get; init; }
public string? Color { get; init; }
public bool? IsDrivable { get; init; }
}
public async Task<List<CarListItem>> SearchAsync(CarSearchCondition condition)
{
var query = _context.Cars
.AsNoTracking()
.AsQueryable();
if (!string.IsNullOrWhiteSpace(condition.Make))
{
query = query.Where(car => car.Make == condition.Make);
}
if (!string.IsNullOrWhiteSpace(condition.Color))
{
query = query.Where(car => car.Color == condition.Color);
}
if (condition.IsDrivable is not null)
{
query = query.Where(car => car.IsDrivable == condition.IsDrivable);
}
return await query
.OrderBy(car => car.Make)
.Select(car => new CarListItem
{
Id = car.Id,
Name = car.Make + " " + car.Color + " " + car.PetName,
State = car.IsDrivable ? "販売可能" : "整備中"
})
.ToListAsync();
}
テストでは、条件ごとに期待を確認します。
[Fact]
public async Task メーカー名で検索できる()
{
var repository = new CarRepository(Context);
var cars = await repository.SearchAsync(new CarSearchCondition
{
Make = "Toyota"
});
Assert.NotEmpty(cars);
Assert.All(cars, car => Assert.Contains("Toyota", car.Name));
}
複数条件も確認します。
[Fact]
public async Task メーカー名と色で検索できる()
{
var repository = new CarRepository(Context);
var cars = await repository.SearchAsync(new CarSearchCondition
{
Make = "Toyota",
Color = "Blue"
});
Assert.Single(cars);
Assert.Contains("Toyota Blue", cars[0].Name);
}
検索条件のテストでは、条件を指定しない場合、1 条件の場合、複数条件の場合を分けると抜けが減ります。
詳細取得をテストする
関連データを含む詳細取得を確認します。
public async Task<CarDetail?> GetDetailAsync(int id)
{
return await _context.Cars
.AsNoTracking()
.Where(car => car.Id == id)
.Select(car => new CarDetail
{
Id = car.Id,
Make = car.Make,
Color = car.Color,
PetName = car.PetName,
Orders = car.Orders
.OrderByDescending(order => order.OrderDate)
.Select(order => new OrderSummary
{
Id = order.Id,
CustomerName = order.CustomerName,
OrderDate = order.OrderDate
})
.ToList()
})
.SingleOrDefaultAsync();
}
テストです。
[Fact]
public async Task 詳細取得で注文情報も取得できる()
{
var car = await Context.Cars
.Include(car => car.Orders)
.SingleAsync(car => car.Orders.Any());
var repository = new CarRepository(Context);
var detail = await repository.GetDetailAsync(car.Id);
Assert.NotNull(detail);
Assert.NotEmpty(detail.Orders);
}
ここでは、初期データの中から注文を持つ車を探して、その詳細を取得しています。
固定 ID に依存しないので、初期データの順番変更に少し強くなります。
集計をテストする
集計処理は、件数や合計値のずれが起きやすい場所です。
public async Task<int> CountOrdersAsync(int carId)
{
return await _context.Orders
.CountAsync(order => order.CarId == carId);
}
テストです。
[Fact]
public async Task 車ごとの注文数を数えられる()
{
var car = await Context.Cars
.Include(car => car.Orders)
.SingleAsync(car => car.Orders.Any());
var repository = new CarRepository(Context);
var count = await repository.CountOrdersAsync(car.Id);
Assert.Equal(car.Orders.Count, count);
}
期待値を別の問い合わせで作る場合は、実装と同じロジックをなぞりすぎないようにします。
同じ間違いをテスト側にも書くと、テストが意味を失うためです。
存在確認をテストする
Any() を使った存在確認もよく出てきます。
public async Task<bool> ExistsAsync(int id)
{
return await _context.Cars.AnyAsync(car => car.Id == id);
}
テストです。
[Fact]
public async Task 存在する車は存在確認で真になる()
{
var car = await Context.Cars.FirstAsync();
var repository = new CarRepository(Context);
var exists = await repository.ExistsAsync(car.Id);
Assert.True(exists);
}
存在しない場合も確認します。
[Fact]
public async Task 存在しない車は存在確認で偽になる()
{
var repository = new CarRepository(Context);
var exists = await repository.ExistsAsync(-1);
Assert.False(exists);
}
正常系だけでなく、見つからないケースを確認することで、呼び出し側の分岐も安心して書けます。
SQL を使う問い合わせをテストする
生 SQL を使う問い合わせも、実際のデータベースで動かして確認します。
public async Task<List<Car>> GetByMakeUsingSqlAsync(string make)
{
return await _context.Cars
.FromSqlInterpolated(
$"SELECT * FROM Inventory WHERE Make = {make}")
.AsNoTracking()
.ToListAsync();
}
テストです。
[Fact]
public async Task SQLを使ってメーカー名で検索できる()
{
var repository = new CarRepository(Context);
var cars = await repository.GetByMakeUsingSqlAsync("Toyota");
Assert.NotEmpty(cars);
Assert.All(cars, car => Assert.Equal("Toyota", car.Make));
}
FromSqlInterpolated() を使うと、値はパラメーターとして扱われます。
文字列連結で SQL を作らないことも、テスト対象の設計として大切です。
履歴問い合わせをテストする
履歴テーブルを使う場合は、過去の状態が取得できることを確認します。
[Fact]
public async Task 更新前の履歴を取得できる()
{
var car = await Context.Cars.FirstAsync();
car.Rename(car.Make, "Red", car.PetName);
await Context.SaveChangesAsync();
var repository = new CarHistoryRepository(Context);
var history = await repository.GetHistoryAsync(car.Id);
Assert.NotEmpty(history);
Assert.Contains(history, item => item.Color != "Red");
}
履歴テーブルのテストは、データベース機能に依存します。
そのため、インメモリの代替ではなく、実際の SQL Server に近い環境で確認する価値があります。
問い合わせテストの見方
問い合わせテストでは、次の観点を意識します。
- 件数
- 条件
- 並び順
- 関連データ
- 見つからない場合
- 集計結果
- 生 SQL の安全な利用
- 履歴やビューなどデータベース固有機能
テスト名は、日本語でも英語でも構いません。
大切なのは、失敗したときに「何が壊れたのか」がすぐわかることです。
次回は、レコード追加のテストを書きます。