skills/kevintsengtw/dotnet-testing-agent-skills/dotnet-testing-advanced-aspnet-integration-testing

dotnet-testing-advanced-aspnet-integration-testing

SKILL.md

ASP.NET Core 整合測試指南

核心概念

整合測試的兩種定義

定義一:多物件協作測試

將兩個以上的類別做整合,並且測試它們之間的運作是不是正確的,測試案例一定是跨類別物件的

定義二:外部資源整合測試

會使用到外部資源,例如資料庫、外部服務、檔案、需要對測試環境進行特別處理等

為什麼需要整合測試?

  • 確保多個模組在整合運作後,能夠正確工作
  • 單元測試無法涵蓋的整合點:Routing、Middleware、Request/Response Pipeline
  • WebApplication 做了太多的整合與設定,單元測試無法確認到全部
  • 確認是否完善異常處理,減少更多問題的發生

測試金字塔定位

測試類型 測試範圍 執行速度 維護成本 建議比例
單元測試 單一類別/方法 很快 70%
整合測試 多個元件 中等 中等 20%
端對端測試 完整流程 10%

原則一:使用 WebApplicationFactory 建立測試環境

基本使用方式

public class BasicIntegrationTest : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public BasicIntegrationTest(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task Get_首頁_應回傳成功()
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync("/");

        // Assert
        response.EnsureSuccessStatusCode();
    }
}

自訂 WebApplicationFactory

public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> 
    where TProgram : class
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // 移除原本的資料庫設定
            services.RemoveAll(typeof(DbContextOptions<AppDbContext>));
            
            // 加入記憶體資料庫
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase("TestDatabase");
            });

            // 替換外部服務為測試版本
            services.Replace(ServiceDescriptor.Scoped<IEmailService, TestEmailService>());
        });

        // 設定測試環境
        builder.UseEnvironment("Testing");
    }
}

原則二:使用 AwesomeAssertions.Web 驗證 HTTP 回應

HTTP 狀態碼斷言

response.Should().Be200Ok();          // HTTP 200
response.Should().Be201Created();     // HTTP 201
response.Should().Be204NoContent();   // HTTP 204
response.Should().Be400BadRequest();  // HTTP 400
response.Should().Be404NotFound();    // HTTP 404
response.Should().Be500InternalServerError();  // HTTP 500

Satisfy 強型別驗證

[Fact]
public async Task GetShipper_當貨運商存在_應回傳成功結果()
{
    // Arrange
    await CleanupDatabaseAsync();
    var shipperId = await SeedShipperAsync("順豐速運", "02-2345-6789");

    // Act
    var response = await Client.GetAsync($"/api/shippers/{shipperId}");

    // Assert
    response.Should().Be200Ok()
            .And
            .Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
            {
                result.Status.Should().Be("Success");
                result.Data.Should().NotBeNull();
                result.Data!.ShipperId.Should().Be(shipperId);
                result.Data.CompanyName.Should().Be("順豐速運");
                result.Data.Phone.Should().Be("02-2345-6789");
            });
}

與傳統方式的比較

// ❌ 傳統方式 - 冗長且容易出錯
response.IsSuccessStatusCode.Should().BeTrue();
var content = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<SuccessResultOutputModel<ShipperOutputModel>>(content,
    new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
result.Should().NotBeNull();
result!.Status.Should().Be("Success");

// ✅ 使用 Satisfy<T> - 簡潔且直觀
response.Should().Be200Ok()
        .And
        .Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
        {
            result.Status.Should().Be("Success");
            result.Data!.CompanyName.Should().Be("測試公司");
        });

原則三:使用 System.Net.Http.Json 簡化 JSON 操作

PostAsJsonAsync 簡化 POST 請求

// ❌ 傳統方式
var createParameter = new ShipperCreateParameter { CompanyName = "測試公司", Phone = "02-1234-5678" };
var jsonContent = JsonSerializer.Serialize(createParameter);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/shippers", content);

// ✅ 現代化方式
var createParameter = new ShipperCreateParameter { CompanyName = "測試公司", Phone = "02-1234-5678" };
var response = await client.PostAsJsonAsync("/api/shippers", createParameter);

ReadFromJsonAsync 簡化回應讀取

// ❌ 傳統方式
var responseContent = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<SuccessResultOutputModel<ShipperOutputModel>>(responseContent,
    new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

// ✅ 現代化方式
var result = await response.Content.ReadFromJsonAsync<SuccessResultOutputModel<ShipperOutputModel>>();

三個層級的整合測試策略

Level 1:簡單的 WebApi 專案

特色

  • 沒有資料庫、Service 與 Repository 依賴
  • 最簡單、基本的 WebApi 網站專案
  • 直接使用 WebApplicationFactory<Program> 進行測試

測試重點

  • 各個 API 的輸入輸出驗證
  • HTTP 動詞和路由正確性
  • 模型綁定和序列化
  • 狀態碼和回應格式驗證
public class BasicApiControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

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

    [Fact]
    public async Task GetStatus_應回傳OK()
    {
        // Act
        var response = await _client.GetAsync("/api/status");

        // Assert
        response.Should().Be200Ok();
    }
}

Level 2:相依 Service 的 WebApi 專案

特色

  • 沒有資料庫,但有 Service 依賴
  • 使用 NSubstitute 建立 Service stub
  • 在測試中配置依賴注入
public class ServiceStubWebApplicationFactory : WebApplicationFactory<Program>
{
    private readonly IExampleService _serviceStub;

    public ServiceStubWebApplicationFactory(IExampleService serviceStub)
    {
        _serviceStub = serviceStub;
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.RemoveAll<IExampleService>();
            services.AddScoped(_ => _serviceStub);
        });
    }
}

public class ServiceDependentControllerTests
{
    [Fact]
    public async Task GetData_應回傳服務資料()
    {
        // Arrange
        var serviceStub = Substitute.For<IExampleService>();
        serviceStub.GetDataAsync().Returns("測試資料");
        
        var factory = new ServiceStubWebApplicationFactory(serviceStub);
        var client = factory.CreateClient();

        // Act
        var response = await client.GetAsync("/api/data");

        // Assert
        response.Should().Be200Ok();
    }
}

Level 3:完整的 WebApi 專案

特色

  • 完整的 Solution 架構
  • 包含真實的資料庫操作
  • 使用 InMemory 或真實測試資料庫
public class FullDatabaseWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // 移除原本的資料庫設定
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null)
            {
                services.Remove(descriptor);
            }

            // 加入記憶體資料庫
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase("TestDatabase");
            });

            // 建立資料庫並加入測試資料
            var serviceProvider = services.BuildServiceProvider();
            using var scope = serviceProvider.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            
            context.Database.EnsureCreated();
        });
    }
}

測試基底類別模式

建立可重用的測試基底類別

public abstract class IntegrationTestBase : IDisposable
{
    protected readonly CustomWebApplicationFactory Factory;
    protected readonly HttpClient Client;

    protected IntegrationTestBase()
    {
        Factory = new CustomWebApplicationFactory();
        Client = Factory.CreateClient();
    }

    protected async Task<int> SeedShipperAsync(string companyName, string phone = "02-12345678")
    {
        using var scope = Factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        
        var shipper = new Shipper
        {
            CompanyName = companyName,
            Phone = phone,
            CreatedAt = DateTime.UtcNow
        };
        
        context.Shippers.Add(shipper);
        await context.SaveChangesAsync();
        
        return shipper.ShipperId;
    }

    protected async Task CleanupDatabaseAsync()
    {
        using var scope = Factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        
        context.Shippers.RemoveRange(context.Shippers);
        await context.SaveChangesAsync();
    }

    public void Dispose()
    {
        Client?.Dispose();
        Factory?.Dispose();
    }
}

CRUD 操作測試範例

完整的 CRUD 操作測試程式碼(GET、POST、驗證錯誤、集合查詢)請參考 CRUD 操作測試完整範例


專案結構建議

tests/
├── Sample.WebApplication.UnitTests/           # 單元測試
├── Sample.WebApplication.Integration.Tests/   # 整合測試
│   ├── Controllers/                           # 控制器整合測試
│   │   └── ShippersControllerTests.cs
│   ├── Infrastructure/                        # 測試基礎設施
│   │   └── CustomWebApplicationFactory.cs
│   ├── IntegrationTestBase.cs                 # 測試基底類別
│   └── GlobalUsings.cs
└── Sample.WebApplication.E2ETests/            # 端對端測試

套件相容性故障排除

常見錯誤

error CS1061: 'ObjectAssertions' 未包含 'Be200Ok' 的定義

解決方案

基礎斷言庫 正確的套件
FluentAssertions < 8.0.0 FluentAssertions.Web
FluentAssertions >= 8.0.0 FluentAssertions.Web.v8
AwesomeAssertions >= 8.0.0 AwesomeAssertions.Web
<!-- 正確:使用 AwesomeAssertions 應該安裝 AwesomeAssertions.Web -->
<PackageReference Include="AwesomeAssertions" Version="9.1.0" />
<PackageReference Include="AwesomeAssertions.Web" Version="1.9.6" />

最佳實踐

應該做的 1. 獨立測試專案:整合測試專案應與單元測試分離

  1. 測試資料隔離:每個測試案例有獨立的資料準備和清理
  2. 使用基底類別:共用的設定和輔助方法放在基底類別
  3. 明確的命名:使用三段式命名法(方法_情境_預期)
  4. 適當的測試範圍:專注於整合點,不要過度測試

應該避免的 1. 混合測試類型:不要將單元測試和整合測試放在同一專案

  1. 測試相依性:每個測試應該獨立,不依賴其他測試的執行順序
  2. 過度模擬:整合測試應該盡量使用真實的元件
  3. 忽略清理:測試完成後要清理測試資料
  4. 硬編碼資料:使用工廠方法或 Builder 模式建立測試資料

相關技能

  • unit-test-fundamentals - 單元測試基礎
  • nsubstitute-mocking - 使用 NSubstitute 進行模擬
  • awesome-assertions-guide - AwesomeAssertions 流暢斷言
  • testcontainers-database - 使用 Testcontainers 進行容器化資料庫測試

輸出格式

  • 產生 CustomWebApplicationFactory.cs,配置測試用 DI 容器與資料庫替換
  • 產生 IntegrationTestBase.cs 測試基底類別,包含資料準備與清理方法
  • 產生控制器測試類別(*ControllerTests.cs),涵蓋 CRUD 操作驗證
  • 修改測試專案 .csproj,加入 Microsoft.AspNetCore.Mvc.TestingAwesomeAssertions.Web
  • 確保主專案 Program.cs 包含 public partial class Program { } 以支援測試存取

參考資源

原始文章

本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:

官方文件

Weekly Installs
35
GitHub Stars
18
First Seen
Jan 24, 2026
Installed on
gemini-cli30
opencode29
codex28
claude-code27
github-copilot26
cursor25