はじめに
ASP.NET Core Minimal APIの概要はこちらに書かれていますので割愛します。
今回は、Minimal APIをよりテストしやすいように分割して書いていくことを目指していきます。
何らかのデータアクセスがあるEntityFramework CoreでデータアクセスしCRUDするようなAPIを想定して作っていきます。
モデルを作る
まずは、データモデルを作っていきます。
イメージしやすいように、本を表すモデルを作ります。
public class Books
{
public int Id { get; set; }
public string? Title { get; set; }
public string? Author { get; set; }
public string? Description { get; set; }
public DateTime PublishedDate { get; set; }
public string? Genre { get; set; }
public int Price { get; set; }
}
このモデルについてDbContextを作っておきましょう。
今回はEntityFramework Coreの解説ではありませんのでこの辺りは割愛していきます。
リポジトリを作る
まず、インターフェイスを作っていきます
public interface IBookRepository
{
Task<List<Books>> GetAllBooksAsync(WebApplication20Context db);
Task<Books?> GetBookByIdAsync(int id, WebApplication20Context db);
Task<int> UpdateBookAsync(int id, Books books, WebApplication20Context db);
Task<Books> CreateBookAsync(Books books, WebApplication20Context db);
Task<int> DeleteBookAsync(int id, WebApplication20Context db);
}
対応する実装を作っていきます。
public class BookRepository : IBookRepository
{
public async Task<Books> CreateBookAsync(Books books, WebApplication20Context db)
{
var addedBook = db.Books.Add(books);
await db.SaveChangesAsync();
return addedBook.Entity;
}
public async Task<int> DeleteBookAsync(int id, WebApplication20Context db)
{
return await db.Books.Where(model => model.Id == id).ExecuteDeleteAsync();
}
public async Task<List<Books>> GetAllBooksAsync(WebApplication20Context db)
{
return await db.Books.ToListAsync();
}
public async Task<Books?> GetBookByIdAsync(int id, WebApplication20Context db)
{
return await db.Books.AsNoTracking()
.FirstOrDefaultAsync(model => model.Id == id);
}
public async Task<int> UpdateBookAsync(int id, Books books, WebApplication20Context db)
{
return await db.Books
.Where(model => model.Id == id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(m => m.Id, books.Id)
.SetProperty(m => m.Title, books.Title)
.SetProperty(m => m.Author, books.Author)
.SetProperty(m => m.Description, books.Description)
.SetProperty(m => m.PublishedDate, books.PublishedDate)
.SetProperty(m => m.Genre, books.Genre)
.SetProperty(m => m.Price, books.Price));
}
}
ロジックを作る
次に、ロジック本体を作っていきます。
リポジトリーがインターフェイスになっているのでリポジトリー部分をスタブに置き換えてあげればこのロジックが容易にテスト可能な形になります。
今回は、単純にリポジトリーから取得したデータをそのまま返しているだけで実質ロジックがないので恩恵がありませんが複数のリポジトリーからデータアクセスしてAPI用のモデルを作って返すなどという場合にはこのロジックがテスト可能なように作っておくことが重要になります。
今回は、そのオブジェクト間の構造を示しただけですので有効なようには見えませんが、このロジックにある程度のロジックが書かれていて様々なパターンのテストをしたくなった場合このやり方が有効です。
また、テスト用に必ずしもDbContextを与えなくともよいように設計してあります。リポジトリーに対してDbContextを渡す必要があるのは本来の実装クラスのみでテスト用のスタブには不要のはずです。ですので、テスト時はここはNullでよいとします。
public static class BooksLogic
{
public static async Task<List<model.Books>> GetAllBooksAsync(IBookRepository book, WebApplication20Context db = default!)
{
return await book.GetAllBooksAsync(db);
}
public static async Task<model.Books?> GetBookByIdAsync(int id, IBookRepository book, WebApplication20Context db)
{
return await book.GetBookByIdAsync(id, db);
}
public static async Task<int> UpdateBookAsync(int id, model.Books books, IBookRepository book, WebApplication20Context db = default!)
{
var affected = await book.UpdateBookAsync(id, books, db);
return affected;
}
public static async Task<model.Books> CreateBookAsync(model.Books books, IBookRepository book, WebApplication20Context db = default!)
{
return await book.CreateBookAsync(books, db);
}
public static async Task<int> DeleteBookAsync(int id, IBookRepository book, WebApplication20Context db = default!)
{
return await book.DeleteBookAsync(id, db);
}
}
エンドポイントのクラスを作る
Minimal APIは何でもかんでもProgram.csに詰め込みがちですが拡張メソッドの形で実装することでとても便利に実装していくことができます。
public static class BooksEndpoints
{
public static void MapBooksEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/Books");
group.MapGet("/", async (IBookRepository book, WebApplication20Context db) => await BooksLogic.GetAllBooksAsync(book, db))
.WithName("GetAllBooks")
.Produces<List<Books>>(StatusCodes.Status200OK);
group.MapGet("/{id}", async (int id,IBookRepository book, WebApplication20Context db) =>
await BooksLogic.GetBookByIdAsync(id, book, db) is Books books
? Results.Ok(books)
: Results.NotFound()
)
.WithName("GetBooksById")
.Produces<Books>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{id}", async (int id, Books books,IBookRepository book, WebApplication20Context db) =>
{
if (await BooksLogic.UpdateBookAsync(id, books,book, db) == 1)
{
Results.Ok();
}
else
{
Results.NotFound();
}
})
.WithName("UpdateBooks")
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status204NoContent);
group.MapPost("/", async (Books books,IBookRepository book, WebApplication20Context db) =>
{
return Results.Created($"/api/Books/{books.Id}", await BooksLogic.CreateBookAsync(books, book, db));
})
.WithName("CreateBooks")
.Produces<Books>(StatusCodes.Status201Created);
group.MapDelete("/{id}", async (int id, IBookRepository book, WebApplication20Context db) =>
{
if (await BooksLogic.DeleteBookAsync(id, book, db) == 1)
{
Results.Ok();
}
else
{
Results.NotFound();
}
})
.WithName("DeleteBooks")
.Produces<Books>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
}
}
Program.csに追加
最後にProgram.csにエンドポイント定義を追加して以下のようにします。
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<WebApplication20Context>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("WebApplication20Context1") ?? throw new InvalidOperationException("Connection string 'WebApplication20Context' not found.")));
builder.Services.AddSingleton<IBookRepository, BookRepository>();
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapBooksEndpoints();
app.Run();
まとめ
Minimal APIも簡単に書こうと思えばProgram.csに展開する形で書くこともできますがこれまで通りコントローラーベースを踏襲したようなクラス構造を作ってロジックのテストを意識した形で記述することも可能です。
今回は、Staticな形で書きましたが、インスタンス化するような形で書くことでDIをきちんと使用したパターンで書き直すこともかのうで