本文へスキップ

テスト ​

コードを変更するたびに curl を手動で送ると、エラーケースを見落としやすくなります。この章では「タスクを作成し、読み直して確かめる」操作を統合テスト(integration test)にします。リクエストをルーティング、検証、認証、ハンドラー、SQLite に通し、結果を確認します。

まず、最初のテストファイル全体を見ます。API は第 20 章のものを引き継ぎ、本章で Tests プロジェクトを追加します。

Tests/CreateTodoTests.cs
cs
using System.Net;
using System.Net.Http.Json;

public class CreateTodoTests
{
    [Fact]
    public async Task Create_then_read_returns_saved_todo()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;

        var created = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 1 }, ct);

        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        Assert.Equal("/todos/1", created.Headers.Location?.ToString());
        var saved = await client.GetFromJsonAsync<TodoResponse>(created.Headers.Location, ct);
        Assert.NotNull(saved);
        Assert.Equal(new TodoResponse(1, "Buy milk", false, 1), saved);
    }
}

このファイルが必要とするテストファクトリーとプロジェクト構成は、リポジトリに含まれています。

テストファクトリーとプロジェクト構成の全体
cs
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
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;
using Microsoft.IdentityModel.Tokens;

public sealed class TodoApiFactory : WebApplicationFactory<Program>
{
    private readonly SqliteConnection _connection = new("Data Source=:memory:");
    private readonly SymmetricSecurityKey _key = new(RandomNumberGenerator.GetBytes(32));

    public TodoApiFactory() => _connection.Open();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Development");
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<TodoDbContext>>();
            services.RemoveAll<IDbContextOptionsConfiguration<TodoDbContext>>();
            services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
            services.PostConfigure<JwtBearerOptions>("Bearer", options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = "test-issuer",
                    ValidateAudience = true,
                    ValidAudience = "test-api",
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = _key,
                    ClockSkew = TimeSpan.Zero
                };
            });
        });
    }

    public HttpClient CreateUserClient(bool editor = false)
    {
        var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
        if (editor) claims.Add(new Claim(ClaimTypes.Role, "editor"));
        var token = new JwtSecurityToken("test-issuer", "test-api", claims,
            expires: DateTime.UtcNow.AddMinutes(5),
            signingCredentials: new SigningCredentials(_key, SecurityAlgorithms.HmacSha256));
        var client = CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
            "Bearer", new JwtSecurityTokenHandler().WriteToken(token));
        return client;
    }

    public override async ValueTask DisposeAsync()
    {
        await base.DisposeAsync();
        await _connection.DisposeAsync();
    }
}
xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsTestProject>true</IsTestProject>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
    <PackageReference Include="xunit.v3.mtp-v2" Version="4.0.1" />
    <Using Include="Xunit" />
    <ProjectReference Include="../Testing.csproj" />
  </ItemGroup>
</Project>
json
{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
cs
// Lets the separate test project reference the entry-point type generated from top-level statements.
public partial class Program;
xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>aspnetcore-first-steps-21-testing</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <Compile Remove="Tests/**/*.cs" />
    <Content Remove="Tests/**" />
    <None Remove="Tests/**" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.17.10" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
  </ItemGroup>
</Project>

実行して確認する ​

リポジトリのルートから実行します。

bash
cd samples/21-testing
dotnet test --project Tests/TodoApi.Tests.csproj

事前に dotnet run したり、開発用 JWT を生成したりする必要はありません。概要には次のような結果が表示されます。実行時間と完全なパスは環境によって異なります。CLI の表示言語によって文言も変わります。以下は英語表示の例です。

text
Test run summary: Passed!
  total: 11
  failed: 0
  succeeded: 11
  skipped: 0

この例では xUnit テストフレームワークを使い、本章の global.json で .NET 10 の Microsoft Testing Platform(MTP) テストランナーを選んでいます。そのため --project でテストプロジェクトを指定します。SDK がこの構成を見つけられるよう、本章のディレクトリから実行してください。xUnit パッケージ名の v3 は製品シリーズ名で、パッケージのバージョン番号と必ずしも同じではありません。xUnit の入門

1 つのテストで何を確認するか ​

[Fact] は 1 つのテストを表します。メソッド名で確認対象の動作を示しています。作成が成功すると、保存されたデータを読み取れることです。メソッドは次の 3 段階で構成されています。

  1. テストアプリを作り、editor ID を持つ HttpClient を取得します。
  2. /todos に JSON リクエストを送ります。
  3. アサーション(assertion)でステータスコード、Location、再取得したタスクの内容を確認します。

PostAsJsonAsync はオブジェクトを JSON にシリアライズし、リクエストの Content-Type を設定します。GetFromJsonAsync<TodoResponse> はレスポンスを指定型にデシリアライズします。ここでは既存の DTO を使うため、JSON 文字列を手動で解析する必要はありません。

201 だけを確認しないのはなぜでしょうか。ハンドラーが成功を返していても、タスクを保存していなかったり、Location が間違った ID を指していたりする可能性があります。再び読み取ることで、クライアントが受け取った URL が実際に使えることを確かめます。

TestContext.Current.CancellationToken はテストランナーから渡され、テストがキャンセルされたときに未完了の HTTP 操作を中断できます。using と await using はテスト終了後にクライアント、テストアプリ、データベース接続を解放します。

WebApplicationFactory の役割 ​

WebApplicationFactory<Program> はテストホストを作り、HttpClient のリクエストをテストサーバーで処理します。実際のポート 5080 は使用しません。Program はアプリケーションのエントリーポイントを表します。TestAccess.cs で宣言した公開部分により、HTTP エンドポイントを増やさずに別プロジェクトからこの型を参照できます。

名前に Mvc が含まれる Microsoft.AspNetCore.Mvc.Testing は Minimal API のテストにも使え、コントローラーを追加する必要はありません。ASP.NET Core の統合テスト

テストファクトリーでは 2 つの構成を差し替えます。

構成テストでの扱い理由
データベースファクトリーごとに別の SQLite インメモリ接続を開く練習用データベースファイルを読み書きせず、テスト間で ID が競合しないようにする
JWTファクトリーごとにランダム署名キーを生成し、短期テストトークンを作るローカルの User Secrets や外部 ID サービスに依存しない

SQLite の実際のプロバイダーと JWT の検証ハンドラーはそのまま使います。データベースの場所と信頼する発行者の構成だけを変え、「アクセス許可」を固定しているわけではありません。ファクトリー内のトークン発行コードはテストプロジェクトにだけあり、ログイン API ではありません。

技術詳細

SQLite のインメモリデータベースは接続が閉じると消えるため、ファクトリーは最初に接続を開き、テストアプリを解放してから閉じます。古い DbContextOptions とその構成登録も削除し、2 種類の接続構成が同時に有効にならないようにする必要があります。同じファクトリー内のリクエストはこのテスト DB を共有します。この例では各テストが個別のファクトリーを作り、順番にリクエストを送ります。

エラーケースも確認する ​

残りのテストを含むファイル全体は次のとおりです。

検証、認証、ロール、更新・削除のテスト
Tests/TodoApiTests.cs
cs
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

public class TodoApiTests
{
    [Theory]
    [InlineData("")]
    [InlineData(" ")]
    public async Task Invalid_title_does_not_insert(string title)
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var response = await client.PostAsJsonAsync("/todos", new { title, categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        var items = await client.GetFromJsonAsync<TodoResponse[]>("/todos", ct);
        Assert.NotNull(items);
        Assert.Empty(items);
    }

    [Fact]
    public async Task Unknown_category_does_not_insert()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var response = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 99 }, ct);
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Empty((await client.GetFromJsonAsync<TodoResponse[]>("/todos", ct))!);
    }

    [Fact]
    public async Task Missing_todo_returns_404()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient();
        var response = await client.GetAsync("/todos/99", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task Anonymous_request_returns_401()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateClient();
        var response = await client.GetAsync("/todos", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task Invalid_token_returns_401()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateClient();
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid");
        var response = await client.GetAsync("/todos", TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Theory]
    [InlineData("POST")]
    [InlineData("PUT")]
    [InlineData("DELETE")]
    public async Task Reader_cannot_write(string method)
    {
        await using var app = new TodoApiFactory();
        using var editor = app.CreateUserClient(editor: true);
        using var reader = app.CreateUserClient();
        var ct = TestContext.Current.CancellationToken;
        var created = await editor.PostAsJsonAsync("/todos", new { title = "Keep me", categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        using var request = new HttpRequestMessage(new HttpMethod(method), method == "POST" ? "/todos" : "/todos/1")
        {
            Content = JsonContent.Create(new { title = "Changed", done = true, categoryId = 2 })
        };
        var response = await reader.SendAsync(request, ct);
        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        var saved = await reader.GetFromJsonAsync<TodoResponse>("/todos/1", ct);
        Assert.Equal(new TodoResponse(1, "Keep me", false, 1), saved);
        Assert.Single((await reader.GetFromJsonAsync<TodoResponse[]>("/todos", ct))!);
    }

    [Fact]
    public async Task Editor_can_replace_and_delete()
    {
        await using var app = new TodoApiFactory();
        using var client = app.CreateUserClient(editor: true);
        var ct = TestContext.Current.CancellationToken;
        var created = await client.PostAsJsonAsync("/todos", new { title = "Buy milk", categoryId = 1 }, ct);
        Assert.Equal(HttpStatusCode.Created, created.StatusCode);
        var updated = await client.PutAsJsonAsync("/todos/1", new { title = "Bought milk", done = true, categoryId = 2 }, ct);
        Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
        Assert.Equal(new TodoResponse(1, "Bought milk", true, 2), await client.GetFromJsonAsync<TodoResponse>("/todos/1", ct));
        Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync("/todos/1", ct)).StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/todos/1", ct)).StatusCode);
    }
}

[Theory] と [InlineData] を組み合わせると、同じテストコードで複数の入力を検証できます。空のタイトルには 2 ケースがあり、書き込み禁止の確認には POST、PUT、DELETE の 3 ケースがあります。そのためテストメソッド数と最終ケース数は一致しません。

これらのテストはステータスコードだけではなく、次の内容も調べます。

  • タイトルが空、またはカテゴリが存在しない場合は 400 が返り、一覧は空のままです。
  • 存在しない ID には 404 が返ります。
  • トークンがない、または無効な場合は 401 が返ります。
  • 読み取り専用ユーザーが作成、更新、削除を試みると 403 が返り、タスクと件数は変わりません。
  • editor が更新した後に再取得し、削除してから再検索することで、保存の成功と 404 を順番に確認します。

故意に誤りを入れてみる ​

本章の Program.cs で POST の登録から RequireAuthorization("CanWriteTodos") を一時的に外し、グループの認証要件は残してテストを実行します。

Reader_cannot_write の POST ケースは失敗するはずです。期待値は Forbidden(403)ですが、実際は Created(201)になります。これで読み取りユーザーに書き込み権限が与えられたことが分かります。ポリシーの呼び出しを戻すと、11 ケースすべてが再び成功します。

この確認があれば、第 22 章でファイルを分割するときに判断できます。ファイルの場所を変えても、クライアントから見た動作は維持されるべきです。

注意

これは API の統合テストであり、ブラウザーが CORS を実行するか、リバースプロキシ、TLS、外部 ID サービスのログイン手順が正しく動くかは検証しません。ブラウザーやデプロイ環境に関わる動作は、それぞれの環境で確認してください。

FastAPI との比較

pytest と TestClient で FastAPI アプリを呼び出し、ステータスコードと JSON をアサートする方法に似ています。ここでは WebApplicationFactory がテストアプリを作り、テストファクトリーがデータベースと認証の構成を置き換えます。

まとめ ​

  • 統合テストは複数のコンポーネントを通したリクエストを検証します。API を手動で起動する必要はありません。
  • [Fact] は単一ケース、[Theory] は同じコードで複数の入力を検証します。
  • 成功レスポンスでは内容と再取得結果を確認し、書き込みの拒否ではデータが変わっていないことも確認します。
  • 各テストで独立したデータベースと署名キーを使うため、実行順に依存しません。
  • リファクタリング前後でテストを実行し、レスポンスと権限の変化をすぐに検出します。

次章:機能ごとにプロジェクトを整理する——同じ機能のコードをまとめます。前章:CORS。

.NET 10 と Minimal API を使用 · 各章に実行可能なサンプルを用意