mcpField
D
dotnet-integration-testing

dotnet-integration-testing

github.com/wshaddixテスト・品質86101 回インストール35 週間前に更新
リポジトリ

dotnet-integration-testing について

.NET アプリの統合テストパターンを扱うスキルで、API や分散サービスをテストする開発者向けです。WebApplicationFactory によるインプロセステスト、Testcontainers の使い捨てインフラ、.NET Aspire テスト、データベース分離を取り上げます。

主な機能

  1. 01WebApplicationFactory によるインプロセス API テスト
  2. 02Testcontainers による使い捨てインフラ
  3. 03.NET Aspire によるマルチサービステスト
  4. 04データベースフィクスチャと分離戦略
  5. 05Redis コンテナのテストパターン

活用シーン

  1. 01デプロイせずに ASP.NET Core API をテスト
  2. 02実インフラでデータベースの動作を検証
  3. 03Aspire のサービス検出とヘルスチェックをテスト
  4. 04テスト間でのデータ残留を防止

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-integration-testing

インストール後、Claude Code で/dotnet-integration-testingを入力して読み込まれたか確認します。

詳細

dotnet-integration-testing

WebApplicationFactory、Testcontainers、.NET Aspire testing を使用した .NET アプリケーションの統合テストパターンです。プロセス内 API テスト、コンテナーを使用した破棄可能なインフラストラクチャ、データベースフィクスチャの管理、テスト分離戦略を扱います。

バージョンの前提条件: .NET 8.0 以降をベースラインとし、Testcontainers 3.x 以降、.NET Aspire 9.0 以降を想定しています。Microsoft.AspNetCore.Mvc.Testing のパッケージバージョンは、プロジェクトのターゲットフレームワークのメジャーバージョンに一致させる必要があります(例:net8.0 では 8.x、net9.0 では 9.x、net10.0 では 10.x)。以下の例では Testcontainers 4.x の API を使用していますが、名前空間の小さな違いを除き、3.x にも同じパターンを適用できます。

対象外: テストプロジェクトのひな形作成(プロジェクトやパッケージ参照の作成)は [skill:dotnet-add-testing] が担当します。テスト戦略とテストの種類の選択については、[skill:dotnet-testing-strategy] で扱います。API レスポンス構造の検証に使用するスナップショットテストについては、[skill:dotnet-snapshot-testing] で扱います。

前提条件: [skill:dotnet-add-testing] を使用してテストプロジェクトのひな形がすでに作成され、統合テスト用パッケージが参照されていること。Docker デーモンが起動していること(Testcontainers で必要です)。[skill:dotnet-version-detection] を実行して、.NET 8.0 以降がベースラインであることを確認してください。

相互参照:[skill:dotnet-testing-strategy] では統合テストが適切な場面を判断する方法、[skill:dotnet-xunit] では xUnit フィクスチャと並列実行の設定、[skill:dotnet-snapshot-testing] では Verify を使用した API レスポンス構造の検証について説明しています。


WebApplicationFactory

WebApplicationFactory<TEntryPoint> は、ASP.NET Core アプリケーション用のプロセス内テストサーバーを作成します。テストではネットワークのオーバーヘッドなしに HTTP リクエストを送信し、ミドルウェアパイプライン全体、ルーティング、モデルバインディング、シリアル化を実行します。

パッケージ

<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
基本的な使い方
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrdersApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetOrders_ReturnsOkWithJsonArray()
    {
        var response = await _client.GetAsync("/api/orders");

        response.EnsureSuccessStatusCode();
        var orders = await response.Content
            .ReadFromJsonAsync<List<OrderDto>>();
        Assert.NotNull(orders);
    }

    [Fact]
    public async Task CreateOrder_ValidPayload_Returns201()
    {
        var request = new CreateOrderRequest
        {
            CustomerId = "cust-123",
            Items = [new("SKU-001", Quantity: 2)]
        };

        var response = await _client.PostAsJsonAsync("/api/orders", request);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.NotNull(response.Headers.Location);
    }
}

重要: Program クラスはテストプロジェクトからアクセスできる必要があります。public にするか、InternalsVisibleTo 属性を追加してください。

// In the API project (e.g., Program.cs or a separate file)
[assembly: InternalsVisibleTo("MyApp.Api.IntegrationTests")]

または、csproj 内に記述します。

<ItemGroup>
  <InternalsVisibleTo Include="MyApp.Api.IntegrationTests" />
</ItemGroup>
テストサーバーのカスタマイズ

WebApplicationFactory<T>.WithWebHostBuilder を使用して、サービス、構成、またはミドルウェアをオーバーライドします。

public class CustomWebAppFactory : WebApplicationFactory<Program>
{
    // Provide connection string from test fixture (e.g., Testcontainers)
    public string ConnectionString { get; set; } = "";

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testing");

        builder.ConfigureAppConfiguration((context, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ConnectionStrings:Default"] = ConnectionString,
                ["Features:EnableNewCheckout"] = "true"
            });
        });

        builder.ConfigureTestServices(services =>
        {
            // Replace real services with test doubles
            services.RemoveAll<IEmailSender>();
            services.AddSingleton<IEmailSender, FakeEmailSender>();

            // Replace database context with test database
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(ConnectionString));
        });
    }
}
認証済みリクエスト

認証ハンドラーを構成して、認証が必要なエンドポイントをテストします。

public class AuthenticatedWebAppFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddAuthentication("Test")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                    "Test", options => { });
        });
    }
}

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
            new Claim(ClaimTypes.Name, "Test User"),
            new Claim(ClaimTypes.Role, "Admin")
        };
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Testcontainers

Testcontainers は、テスト用に実際のインフラストラクチャ(データベース、メッセージブローカー、キャッシュ)を Docker コンテナー内で起動します。テストを実行するたびに、新しく破棄可能な環境が用意されます。

パッケージ
<PackageReference Include="Testcontainers" Version="4.*" />

<PackageReference Include="Testcontainers.PostgreSql" Version="4.*" />
<PackageReference Include="Testcontainers.MsSql" Version="4.*" />
<PackageReference Include="Testcontainers.Redis" Version="4.*" />
PostgreSQL の例
public class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithDatabase("testdb")
        .WithUsername("test")
        .WithPassword("test")
        .Build();

    public string ConnectionString => _container.GetConnectionString();

    public async ValueTask InitializeAsync()
    {
        await _container.StartAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _container.DisposeAsync();
    }
}

[CollectionDefinition("Postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }

[Collection("Postgres")]
public class OrderRepositoryTests
{
    private readonly PostgresFixture _postgres;

    public OrderRepositoryTests(PostgresFixture postgres)
    {
        _postgres = postgres;
    }

    [Fact]
    public async Task Insert_ValidOrder_CanBeRetrieved()
    {
        await using var context = CreateContext(_postgres.ConnectionString);
        await context.Database.EnsureCreatedAsync();

        var order = new Order { CustomerId = "cust-1", Total = 99.99m };
        context.Orders.Add(order);
        await context.SaveChangesAsync();

        var retrieved = await context.Orders.FindAsync(order.Id);
        Assert.NotNull(retrieved);
        Assert.Equal(99.99m, retrieved.Total);
    }

    private static AppDbContext CreateContext(string connectionString)
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(connectionString)
            .Options;
        return new AppDbContext(options);
    }
}
SQL Server の例
public class SqlServerFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _container = new MsSqlBuilder()
        .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
        .Build();

    public string ConnectionString => _container.GetConnectionString();

    public async ValueTask InitializeAsync()
    {
        await _container.StartAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _container.DisposeAsync();
    }
}
WebApplicationFactory と Testcontainers の組み合わせ

最も一般的なパターンは、データベースに Testcontainers、API に WebApplicationFactory を使用する方法です。

public class ApiTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(_postgres.GetConnectionString()));
        });
    }

    public async ValueTask InitializeAsync()
    {
        await _postgres.StartAsync();
    }

    public new async ValueTask DisposeAsync()
    {
        await _postgres.DisposeAsync();
        await base.DisposeAsync();
    }
}

public class OrdersApiIntegrationTests : IClassFixture<ApiTestFactory>
{
    private readonly HttpClient _client;
    private readonly ApiTestFactory _factory;

    public OrdersApiIntegrationTests(ApiTestFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task CreateAndRetrieveOrder_RoundTrip()
    {
        // Ensure schema exists
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.EnsureCreatedAsync();

        // Create
        var createResponse = await _client.PostAsJsonAsync("/api/orders",
            new { CustomerId = "cust-1", Items = new[] { new { Sku = "SKU-1", Quantity = 2 } } });
        createResponse.EnsureSuccessStatusCode();
        var location = createResponse.Headers.Location!.ToString();

        // Retrieve
        var getResponse = await _client.GetAsync(location);
        getResponse.EnsureSuccessStatusCode();
        var order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();

        Assert.Equal("cust-1", order!.CustomerId);
    }
}

.NET Aspire Testing

.NET Aspire は、Aspire でオーケストレーションされた複数サービスのアプリケーションをテストするための DistributedApplicationTestingBuilder を提供します。サービス検出、構成、ヘルスチェックを含む実際の分散トポロジーをテストします。

パッケージ
<PackageReference Include="Aspire.Hosting.Testing" Version="9.*" />
Aspire の基本的なテスト
public class AspireIntegrationTests
{
    [Fact]
    public async Task ApiService_ReturnsHealthy()
    {
        var builder = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.MyApp_AppHost>();

        await using var app = await builder.BuildAsync();
        await app.StartAsync();

        var httpClient = app.CreateHttpClient("api-service");

        var response = await httpClient.GetAsync("/health");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    [Fact]
    public async Task ApiService_WithDatabase_ReturnsOrders()
    {
        var builder = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.MyApp_AppHost>();

        await using var app = await builder.BuildAsync();
        await app.StartAsync();

        // Wait for resources to be healthy
        var resourceNotification = app.Services
            .GetRequiredService<ResourceNotificationService>();
        await resourceNotification
            .WaitForResourceHealthyAsync("api-service")
            .WaitAsync(TimeSpan.FromSeconds(60));

        var httpClient = app.CreateHttpClient("api-service");
        var response = await httpClient.GetAsync("/api/orders");

        response.EnsureSuccessStatusCode();
    }
}
サービスをオーバーライドした Aspire

テスト用に Aspire アプリモデル内のサービスを置き換えます。

[Fact]
public async Task ApiService_WithMockedExternalDependency()
{
    var builder = await DistributedApplicationTestingBuilder
        .CreateAsync<Projects.MyApp_AppHost>();

    // Override configuration for the API service
    builder.Services.ConfigureHttpClientDefaults(http =>
    {
        http.AddStandardResilienceHandler();
    });

    await using var app = await builder.BuildAsync();
    await app.StartAsync();

    var httpClient = app.CreateHttpClient("api-service");
    var response = await httpClient.GetAsync("/api/orders");

    response.EnsureSuccessStatusCode();
}

データベースフィクスチャのパターン

トランザクションによるテストごとの分離

トランザクションスコープを使用して、各テストの変更をロールバックします。

public class TransactionalTestBase : IClassFixture<PostgresFixture>, IAsyncLifetime
{
    private readonly PostgresFixture _postgres;
    private AppDbContext _context = null!;
    private IDbContextTransaction _transaction = null!;

    public TransactionalTestBase(PostgresFixture postgres)
    {
        _postgres = postgres;
    }

    protected AppDbContext Context => _context;

    public async ValueTask InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_postgres.ConnectionString)
            .Options;
        _context = new AppDbContext(options);
        await _context.Database.EnsureCreatedAsync();
        _transaction = await _context.Database.BeginTransactionAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _transaction.RollbackAsync();
        await _transaction.DisposeAsync();
        await _context.DisposeAsync();
    }
}

public class OrderTests : TransactionalTestBase
{
    public OrderTests(PostgresFixture postgres) : base(postgres) { }

    [Fact]
    public async Task Insert_ValidOrder_Persists()
    {
        Context.Orders.Add(new Order { CustomerId = "cust-1", Total = 50m });
        await Context.SaveChangesAsync();

        var count = await Context.Orders.CountAsync();
        Assert.Equal(1, count);
        // Transaction rolls back after test -- database stays clean
    }
}
Respawn によるテストごとの分離

トランザクションをロールバックする代わりにデータを削除して、テスト間でデータベースの状態をリセットするには Respawn を使用します。トランザクションのロールバックが実行できない場合(例:独自にトランザクションをコミットするコードのテスト)に便利です。

// NuGet: Respawn
// Combined fixture: owns the container AND the respawner
public class RespawnablePostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    private Respawner _respawner = null!;
    private NpgsqlConnection _connection = null!;

    public string ConnectionString => _container.GetConnectionString();

    public async ValueTask InitializeAsync()
    {
        await _container.StartAsync();

        _connection = new NpgsqlConnection(ConnectionString);
        await _connection.OpenAsync();

        // Run migrations or EnsureCreated before creating respawner
        // so it knows which tables to clean
        _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            TablesToIgnore = ["__EFMigrationsHistory"]
        });
    }

    public async Task ResetDatabaseAsync()
    {
        await _respawner.ResetAsync(_connection);
    }

    public async ValueTask DisposeAsync()
    {
        await _connection.DisposeAsync();
        await _container.DisposeAsync();
    }
}

テスト分離戦略

戦略の比較
戦略速度分離性複雑さ最適な用途
トランザクションのロールバック最速高低単一の DbContext を使用するテスト
Respawn(データ削除)高速高中コードが独自にトランザクションをコミットするテスト
クラスごとに新しいコンテナーを使用低速最高低スキーマを変更するテスト、または完全な分離が必要なテスト
コンテナーを共有してクリーンアップ中程度中中多数のクラスでインフラストラクチャを共有するテストスイート
コンテナーのライフサイクルに関する推奨事項
Per-test:       Too slow. Never spin up a container per test.
Per-class:      Good isolation, acceptable speed with ICollectionFixture.
Per-collection: Best balance -- share one container across related test classes.
Per-assembly:   Fastest but requires careful cleanup between tests.

複数のテストクラスで単一のコンテナーを共有するには ICollectionFixture<T>([skill:dotnet-xunit]を参照)を使用し、データの競合を避けるために、それらのクラスを順番に実行します。


Redis を使用したテスト

public class RedisFixture : IAsyncLifetime
{
    private readonly RedisContainer _container = new RedisBuilder()
        .WithImage("redis:7-alpine")
        .Build();

    public string ConnectionString => _container.GetConnectionString();

    public async ValueTask InitializeAsync() => await _container.StartAsync();
    public async ValueTask DisposeAsync() => await _container.DisposeAsync();
}

[CollectionDefinition("Redis")]
public class RedisCollection : ICollectionFixture<RedisFixture> { }

[Collection("Redis")]
public class CacheServiceTests
{
    private readonly RedisFixture _redis;

    public CacheServiceTests(RedisFixture redis) => _redis = redis;

    [Fact]
    public async Task SetAndGet_RoundTrip_ReturnsOriginalValue()
    {
        var multiplexer = await ConnectionMultiplexer.ConnectAsync(
            _redis.ConnectionString);
        var cache = new RedisCacheService(multiplexer);

        await cache.SetAsync("key-1", new Order { Id = 1, Total = 99m });
        var result = await cache.GetAsync<Order>("key-1");

        Assert.NotNull(result);
        Assert.Equal(99m, result.Total);
    }
}

主な原則

  • API テストには WebApplicationFactory を使用します。 デプロイ済みのインスタンスに対するテストよりも高速で、信頼性が高く、決定性があります。
  • 実際のインフラストラクチャには Testcontainers を使用します。 DbContext をモックせず、実際のデータベースに対してテストし、LINQ-to-SQL の変換と制約の適用を検証します。
  • テストクラス間でコンテナーを共有します。 ICollectionFixture を使用して、クラスごとに新しいコンテナーを起動するオーバーヘッドを避けます。
  • 適切な分離戦略を選びます。 トランザクションのロールバックは最速かつ最も簡単です。トランザクション境界を制御できない場合は Respawn を使用します。
  • テストデータは必ずクリーンアップします。 あるテストの残存データが、別のテストで不安定な失敗を引き起こします。トランザクションのロールバック、Respawn、または新しいコンテナーを使用します。
  • Microsoft.AspNetCore.Mvc.Testing のバージョンを TFM に合わせます。 誤ったバージョンを使用すると、実行時のバインドエラーが発生します。

エージェントが陥りやすい問題

  1. Microsoft.AspNetCore.Mvc.Testing のバージョンをハードコードしないでください。 パッケージのバージョンは、プロジェクトのターゲットフレームワークのメジャーバージョンと一致する必要があります。たとえば Version="8.0.0" を指定すると、net9.0 プロジェクトで問題が発生します。
  2. Program クラスの InternalsVisibleTo を忘れないでください。 これがないと、WebApplicationFactory<Program> がエントリーポイントにアクセスできず、コンパイル時にテストが失敗します。
  3. Respawn と併せて EnsureCreated() を使用しないでください。 EnsureCreated() はマイグレーションを追跡しません。本番用スキーマには Database.MigrateAsync() を使用し、EnsureCreated() はシンプルなテスト用スキーマにのみ使用します。
  4. HttpClient より先に WebApplicationFactory を破棄しないでください。 ファクトリはテストサーバーを所有しているため、これを破棄するとすべてのクライアントが無効になります。破棄処理は IClassFixture を通じて xUnit に任せます。
  5. Testcontainers で localhost のポートを使用しないでください。 Testcontainers は、ランダムなホストポートをコンテナポートに割り当てます。必ずコンテナーオブジェクトから取得した接続文字列(例:_container.GetConnectionString())を使用し、ポートをハードコードしないでください。
  6. CI で Docker の利用可否チェックを省略しないでください。 Testcontainers には、実行中の Docker デーモンが必要です。CI 環境で Docker が利用可能であることを確認するか、Docker が利用できない場合にテストを条件付きでスキップしてください。

参考資料

コメント