iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Software Development

重啟挑戰:老派軟體工程師的測試修練系列 第 23

Day 23 – 整合測試實戰:WebApi 服務的整合測試

  • 分享至 

  • xImage
  •  

前言

經過前 22 天的基礎學習,我們已經掌握了單元測試、xUnit 框架、測試替身、Mock 物件等核心概念。今天,我們要將這些知識整合起來,實作一個完整的 WebApi 整合測試。

整合測試不同於單元測試,它測試的是整個系統組件之間的協作,包括資料庫、快取、HTTP 請求等真實的外部相依性。這讓我們能夠驗證系統在真實環境下的行為。

本篇學習內容

本篇將示範完整的 WebApi 整合測試實作,內容涵蓋:

  • Clean Architecture 專案的整合測試架構:從 Domain 到 API 層的完整測試策略
  • Testcontainers 多容器管理:PostgreSQL + Redis 的完整整合
  • ExceptionHandler 與 ValidationProblemDetails:ASP.NET Core 9 的現代異常處理模式
  • Flurl 與 AwesomeAssertions:簡化 HTTP 測試的工具組合

範例專案架構

本次範例是一個簡單的產品管理 WebApi 服務,使用 Clean Architecture 設計:

Day23/
├── src/
│   ├── Day23.Api/                          # WebApi 層
│   ├── Day23.Application/                  # 應用服務層  
│   ├── Day23.Domain/                       # 領域模型
│   └── Day23.Infrastructure/               # 基礎設施層
└── tests/
    └── Day23.Tests.Integration/            # 整合測試

技術棧

  • API: ASP.NET Core 9 WebApi (Controllers)
  • 資料庫: PostgreSQL + Dapper
  • 快取: Redis
  • 驗證: FluentValidation
  • 測試: xUnit + Testcontainers + Flurl + AwesomeAssertions

核心實體與服務介面

領域模型設計

我們的 Product 實體很簡潔,符合整潔架構的設計原則:

/// <summary>
/// 產品實體
/// </summary>
public class Product
{
    /// <summary>
    /// 產品唯一識別碼
    /// </summary>
    public Guid Id { get; set; }

    /// <summary>
    /// 產品名稱
    /// </summary>
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// 產品價格
    /// </summary>
    public decimal Price { get; set; }

    /// <summary>
    /// 建立時間
    /// </summary>
    public DateTimeOffset CreatedAt { get; set; }

    /// <summary>
    /// 最後更新時間
    /// </summary>
    public DateTimeOffset UpdatedAt { get; set; }
}

產品服務介面

應用層定義的服務介面設計得很實用:

/// <summary>
/// 產品服務介面
/// </summary>
public interface IProductService
{
    /// <summary>
    /// 建立產品
    /// </summary>
    Task<ProductResponse> CreateAsync(ProductCreateRequest request, CancellationToken cancellationToken = default);

    /// <summary>
    /// 根據 ID 取得產品
    /// </summary>
    Task<ProductResponse?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    /// <summary>
    /// 查詢產品列表
    /// </summary>
    Task<PagedResult<ProductResponse>> QueryAsync(
        string? keyword = null,
        int page = 1,
        int pageSize = 20,
        string sort = "createdAt",
        string direction = "desc",
        CancellationToken cancellationToken = default);

    /// <summary>
    /// 更新產品
    /// </summary>
    Task UpdateAsync(Guid id, ProductUpdateRequest request, CancellationToken cancellationToken = default);

    /// <summary>
    /// 刪除產品
    /// </summary>
    Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}

ExceptionHandler 整合測試 - 現代錯誤處理機制

為什麼選擇 IExceptionHandler

ASP.NET Core 8 引入的 IExceptionHandler 介面提供了比傳統 middleware 更優雅的錯誤處理方式:

統一的錯誤處理介面:所有異常處理器都實作同一個介面,提供一致的處理模式。

更好的可測試性:每個處理器都是獨立的服務,可以單獨進行單元測試。

型別安全:透過強型別的介面,避免了 middleware 中的型別轉換問題。

標準化回應格式:內建支援 ProblemDetails 標準,確保 API 錯誤回應的一致性。

與傳統 ExceptionHandlingMiddleware 的差異

傳統的異常處理 middleware 需要在一個方法中處理所有類型的異常,容易造成程式碼複雜且難以維護。IExceptionHandler 讓我們可以針對不同類型的異常建立專門的處理器:

// 傳統 middleware 方式 - 所有邏輯集中在一處
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    try
    {
        await next(context);
    }
    catch (ValidationException ex)
    {
        // 處理驗證異常
    }
    catch (KeyNotFoundException ex)
    {
        // 處理找不到資源異常
    }
    // ... 更多異常處理
}

// IExceptionHandler 方式 - 職責分離,更清晰
public class FluentValidationExceptionHandler : IExceptionHandler { }
public class GlobalExceptionHandler : IExceptionHandler { }

全域異常處理器實作

我們的 GlobalExceptionHandler 負責處理所有未被特定處理器處理的異常:

/// <summary>
/// 全域異常處理器
/// </summary>
public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "發生未處理的異常: {Message}", exception.Message);

        var problemDetails = CreateProblemDetails(exception);

        httpContext.Response.StatusCode = problemDetails.Status ?? (int)HttpStatusCode.InternalServerError;
        httpContext.Response.ContentType = "application/problem+json";

        var json = JsonSerializer.Serialize(problemDetails, new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        });

        await httpContext.Response.WriteAsync(json, cancellationToken);

        return true;
    }

    private static ProblemDetails CreateProblemDetails(Exception exception)
    {
        return exception switch
        {
            ArgumentException => new ProblemDetails
            {
                Type = "https://httpstatuses.com/400",
                Title = "參數錯誤",
                Status = (int)HttpStatusCode.BadRequest,
                Detail = exception.Message
            },
            KeyNotFoundException => new ProblemDetails
            {
                Type = "https://httpstatuses.com/404",
                Title = "資源不存在",
                Status = (int)HttpStatusCode.NotFound,
                Detail = exception.Message
            },
            UnauthorizedAccessException => new ProblemDetails
            {
                Type = "https://httpstatuses.com/401",
                Title = "未授權",
                Status = (int)HttpStatusCode.Unauthorized,
                Detail = "您沒有權限執行此操作"
            },
            TimeoutException => new ProblemDetails
            {
                Type = "https://httpstatuses.com/408",
                Title = "請求超時",
                Status = (int)HttpStatusCode.RequestTimeout,
                Detail = "操作執行超時,請稍後再試"
            },
            InvalidOperationException => new ProblemDetails
            {
                Type = "https://httpstatuses.com/422",
                Title = "操作無效",
                Status = (int)HttpStatusCode.UnprocessableEntity,
                Detail = exception.Message
            },
            _ => new ProblemDetails
            {
                Type = "https://httpstatuses.com/500",
                Title = "內部伺服器錯誤",
                Status = (int)HttpStatusCode.InternalServerError,
                Detail = "發生未預期的錯誤,請聯絡系統管理員"
            }
        };
    }
}

ProblemDetails 標準格式

在實作異常處理器之前,需要了解 ProblemDetails 類別。這個類別實作了 RFC 7807 標準,提供了統一的錯誤回應格式:

  • Type:問題類型的 URI,用於識別錯誤類別
  • Title:簡短的錯誤描述,通常是人類可讀的摘要
  • Status:HTTP 狀態碼
  • Detail:詳細的錯誤說明
  • Instance:發生問題的特定實例 URI

這種標準化的錯誤格式讓 API 消費者能夠一致地處理各種錯誤情況,提升了 API 的可用性和互操作性。

FluentValidationExceptionHandler - FluentValidation 專用異常處理器

專門處理 FluentValidation 驗證錯誤的處理器,展示了如何針對特定異常類型進行處理:

/// <summary>
/// FluentValidation 專用異常處理器
/// </summary>
public class FluentValidationExceptionHandler : IExceptionHandler
{
    private readonly ILogger<FluentValidationExceptionHandler> _logger;

    public FluentValidationExceptionHandler(ILogger<FluentValidationExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not ValidationException validationException)
        {
            return false;
        }

        _logger.LogWarning(validationException, "驗證失敗: {Message}", validationException.Message);

        var problemDetails = new ValidationProblemDetails
        {
            Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
            Title = "One or more validation errors occurred.",
            Status = (int)HttpStatusCode.BadRequest,
            Detail = "輸入的資料包含驗證錯誤,請檢查後重新提交。",
            Instance = httpContext.Request.Path
        };

        foreach (var error in validationException.Errors)
        {
            var propertyName = error.PropertyName;
            var errorMessage = error.ErrorMessage;

            if (problemDetails.Errors.ContainsKey(propertyName))
            {
                var existingErrors = problemDetails.Errors[propertyName].ToList();
                existingErrors.Add(errorMessage);
                problemDetails.Errors[propertyName] = existingErrors.ToArray();
            }
            else
            {
                problemDetails.Errors.Add(propertyName, new[] { errorMessage });
            }
        }

        httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
        httpContext.Response.ContentType = "application/problem+json";

        var json = JsonSerializer.Serialize(problemDetails, new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        });

        await httpContext.Response.WriteAsync(json, cancellationToken);

        return true;
    }
}

註冊配置與順序重要性

Program.cs 中的正確配置方式,註冊順序決定了處理的優先順序

// Add ProblemDetails
builder.Services.AddProblemDetails();

// Add Exception Handler - 順序很重要!
builder.Services.AddExceptionHandler<FluentValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

為什麼 FluentValidationExceptionHandler 必須先註冊?

異常處理器會按照註冊順序依次嘗試處理異常。FluentValidationExceptionHandler 只處理 ValidationException,如果無法處理就回傳 false,讓下一個處理器接手。GlobalExceptionHandler 會處理所有異常並回傳 true,因此必須放在最後作為後備處理器。

middleware pipeline 中的正確位置:

// Use Exception Handler
app.UseExceptionHandler();

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

ValidationProblemDetails 的結構化優勢

ValidationProblemDetails 是 ASP.NET Core 專門為驗證錯誤設計的標準化回應格式:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "detail": "輸入的資料包含驗證錯誤,請檢查後重新提交。",
  "instance": "/products",
  "errors": {
    "Name": ["產品名稱不能為空"],
    "Price": ["產品價格必須大於 0"]
  }
}

這種格式的優勢:

  • 結構化錯誤:每個欄位的錯誤獨立列出,方便前端針對特定欄位顯示錯誤
  • 標準化:遵循 RFC 7807 Problem Details 標準,確保 API 的一致性
  • 易於解析:前端可以輕易處理並顯示錯誤,提升使用者體驗
  • 支援多重錯誤:同一個欄位可以有多個驗證錯誤,全部都會回傳

整合測試基礎設施

Collection Fixture 模式

延續 Day21、Day22 的經驗,我們使用 Collection Fixture 來共享昂貴的容器資源:

/// <summary>
/// 整合測試集合定義
/// </summary>
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
    /// <summary>
    /// 集合名稱常數
    /// </summary>
    public const string Name = "Integration Tests";

    // 這個類別不需要任何實作
    // 它只是用來定義 Collection Fixture
}

測試基底類別

設計一個實用的基底類別,提供所有整合測試需要的功能:

/// <summary>
/// 整合測試基底類別 - 使用 Collection Fixture 共享容器
/// </summary>
[Collection("Integration Tests")]
public abstract class IntegrationTestBase : IAsyncLifetime
{
    protected readonly TestWebApplicationFactory Factory;
    protected readonly HttpClient HttpClient;
    protected readonly DatabaseManager DatabaseManager;
    protected readonly IFlurlClient FlurlClient;

    protected IntegrationTestBase(TestWebApplicationFactory factory)
    {
        Factory = factory;
        HttpClient = factory.CreateClient();
        DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());

        // 設定 Flurl 用戶端
        FlurlClient = new FlurlClient(HttpClient);
    }

    public virtual async Task InitializeAsync()
    {
        // 初始化資料庫結構
        await DatabaseManager.InitializeDatabaseAsync();
    }

    public virtual async Task DisposeAsync()
    {
        // 清理資料庫資料
        await DatabaseManager.CleanDatabaseAsync();

        FlurlClient.Dispose();
    }

    /// <summary>
    /// 重設時間為測試開始時間
    /// </summary>
    protected void ResetTime()
    {
        Factory.TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
    }

    /// <summary>
    /// 前進時間
    /// </summary>
    protected void AdvanceTime(TimeSpan timeSpan)
    {
        Factory.TimeProvider.Advance(timeSpan);
    }

    /// <summary>
    /// 設定特定時間
    /// </summary>
    protected void SetTime(DateTimeOffset time)
    {
        Factory.TimeProvider.SetUtcNow(time);
    }
}

SQL 指令碼外部化

跟隨 Day21 的最佳實務,DatabaseManager 將 SQL 指令碼外部化管理:

/// <summary>
/// 確保資料表存在,使用外部 SQL 指令碼建立
/// 實作第 Day 21 介紹的 SQL 指令碼外部化策略
/// </summary>
private async Task EnsureTablesExistAsync(NpgsqlConnection connection)
{
    var scriptDirectory = Path.Combine(AppContext.BaseDirectory, "SqlScripts");
    if (!Directory.Exists(scriptDirectory))
    {
        throw new DirectoryNotFoundException($"SQL 指令碼目錄不存在: {scriptDirectory}");
    }

    // 按照依賴順序執行表格建立腳本
    var orderedScripts = new[]
    {
        "Tables/CreateProductsTable.sql"
    };

    foreach (var scriptPath in orderedScripts)
    {
        var fullPath = Path.Combine(scriptDirectory, scriptPath);
        if (File.Exists(fullPath))
        {
            var script = await File.ReadAllTextAsync(fullPath);
            await using var command = new NpgsqlCommand(script, connection);
            await command.ExecuteNonQueryAsync();
        }
        else
        {
            throw new FileNotFoundException($"SQL 指令碼檔案不存在: {fullPath}");
        }
    }
}

對應的 SQL 檔案會建立完整的資料表結構和觸發器:

-- Products 資料表
CREATE TABLE IF NOT EXISTS products
(
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(200) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 建立更新時間觸發器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

-- 移除現有觸發器(如果存在)
DROP TRIGGER IF EXISTS update_products_updated_at ON products;

-- 建立觸發器
CREATE TRIGGER update_products_updated_at
    BEFORE UPDATE ON products
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

服務層架構重點分析

在 Clean Architecture 中,Application 層的服務是整合測試的核心焦點。我們的 IProductService 定義了標準的 CRUD 操作,但整合測試的價值在於驗證這些操作在真實環境下的運作方式。

整合測試中我們重點關注:

  1. 跨層級協作:Controller → Service → Repository → Database 的完整資料流
  2. 快取整合:Cache-Aside 模式下,資料庫與 Redis 的一致性維護
  3. 異常處理:各層級的例外如何正確傳遞和處理
  4. 效能表現:真實資料庫和快取的回應時間

整合測試核心要素

測試環境配置

在整合測試中,我們需要確保測試環境能夠模擬真實的運行環境。專案使用 Testcontainers 來提供獨立的 PostgreSQL 和 Redis 實例:

public class IntegrationTestBase : IAsyncLifetime
{
    protected readonly PostgreSqlContainer PostgreSqlContainer;
    protected readonly RedisContainer RedisContainer;
    protected HttpClient HttpClient = null!;
    protected WebApplicationFactory<Program> Factory = null!;

    public IntegrationTestBase()
    {
        PostgreSqlContainer = new PostgreSqlBuilder()
            .WithImage("postgres:16-alpine")
            .WithDatabase("testdb")
            .WithUsername("test")
            .WithPassword("test123")
            .Build();

        RedisContainer = new RedisBuilder()
            .WithImage("redis:7-alpine")
            .Build();
    }
}

建立產品測試 - 驗證完整業務流程

[Fact]
public async Task CreateProduct_使用有效資料_應成功建立產品()
{
    // Arrange
    var request = TestHelpers.CreateProductRequest("新產品", 299.99m);

    // Act
    var response = await HttpClient.PostAsJsonAsync("/products", request);

    // Assert
    response.Should().Be201Created()
            .And.Satisfy<ProductResponse>(product =>
            {
                product.Id.Should().NotBeEmpty();
                product.Name.Should().Be("新產品");
                product.Price.Should().Be(299.99m);
                product.CreatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
                product.UpdatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
            });
}

這個測試展現了整合測試的幾個重要特點:

  1. 端對端流程:從 HTTP 請求到回應的完整路徑
  2. 業務邏輯驗證:確認產品建立的核心邏輯

驗證失敗測試 - ValidationProblemDetails 整合

[Fact]
public async Task CreateProduct_當產品名稱為空_應回傳400BadRequest()
{
    // Arrange
    var invalidRequest = new ProductCreateRequest
    {
        Name = "",
        Price = 100.00m
    };

    // Act
    var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);

    // Assert
    response.Should().Be400BadRequest()
            .And.Satisfy<ValidationProblemDetails>(problem =>
            {
                problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
                problem.Title.Should().Be("One or more validation errors occurred.");
                problem.Status.Should().Be(400);
                problem.Errors.Should().ContainKey("Name");
                problem.Errors["Name"].Should().Contain("產品名稱不能為空");
            });
}

分頁查詢測試 - 使用 Flurl 建構複雜 URL

[Fact]
public async Task GetProducts_使用分頁參數_應回傳正確的分頁結果()
{
    // Arrange
    await TestHelpers.SeedProductsAsync(DatabaseManager, 15);

    // Act - 使用 Flurl 建構 QueryString
    var url = "/products"
              .SetQueryParam("pageSize", 5)
              .SetQueryParam("page", 2);

    var response = await HttpClient.GetAsync(url);

    // Assert
    response.Should().Be200Ok()
            .And.Satisfy<PagedResult<ProductResponse>>(result =>
            {
                result.Total.Should().Be(15);
                result.PageSize.Should().Be(5);
                result.Page.Should().Be(2);
                result.Items.Should().HaveCount(5);
            });
}

測試基礎設施 - 共享容器與資料管理

整合測試的成功關鍵在於建立可靠且高效的測試基礎設施。專案使用 Collection Fixture 模式來共享 Testcontainers:

/// <summary>
/// 整合測試基底類別 - 使用 Collection Fixture 共享容器
/// </summary>
[Collection("Integration Tests")]
public abstract class IntegrationTestBase : IAsyncLifetime
{
    protected readonly TestWebApplicationFactory Factory;
    protected readonly HttpClient HttpClient;
    protected readonly DatabaseManager DatabaseManager;
    protected readonly IFlurlClient FlurlClient;

    protected IntegrationTestBase(TestWebApplicationFactory factory)
    {
        Factory = factory;
        HttpClient = factory.CreateClient();
        DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());

        // 設定 Flurl 用戶端
        FlurlClient = new FlurlClient(HttpClient);
    }

    public virtual async Task InitializeAsync()
    {
        // 初始化資料庫結構
        await DatabaseManager.InitializeDatabaseAsync();
    }

    public virtual async Task DisposeAsync()
    {
        // 清理資料庫資料
        await DatabaseManager.CleanDatabaseAsync();
        FlurlClient.Dispose();
    }
}

整合測試的關鍵實戰技巧

1. 使用 AwesomeAssertions 進行精確驗證

在整合測試中,AwesomeAssertions 提供了豐富的斷言方法,讓我們能夠精確驗證 HTTP 回應:

// 驗證成功回應和資料結構
response.Should().Be200Ok()
        .And.Satisfy<PagedResult<ProductResponse>>(result =>
        {
            result.Total.Should().Be(15);
            result.Items.Should().HaveCount(5);
            result.Items.Should().AllSatisfy(product =>
            {
                product.Id.Should().NotBeEmpty();
                product.Name.Should().NotBeNullOrEmpty();
                product.Price.Should().BeGreaterThan(0);
            });
        });

// 驗證錯誤回應的詳細結構
response.Should().Be400BadRequest()
        .And.Satisfy<ValidationProblemDetails>(problem =>
        {
            problem.Status.Should().Be(400);
            problem.Errors.Should().ContainKey("Name");
            problem.Errors["Name"].Should().Contain("產品名稱不能為空");
        });

2. 測試資料管理策略

整合測試需要有效的測試資料管理。專案使用 TestHelpers 提供一致的測試資料:

public static class TestHelpers
{
    public static ProductCreateRequest CreateProductRequest(
        string name = "測試產品",
        decimal price = 100.00m)
    {
        return new ProductCreateRequest
        {
            Name = name,
            Price = price
        };
    }

    public static async Task SeedProductsAsync(DatabaseManager dbManager, int count)
    {
        var tasks = new List<Task>();
        for (var i = 1; i <= count; i++)
        {
            tasks.Add(SeedSpecificProductAsync(dbManager, $"產品 {i:D2}", i * 10.0m));
        }

        await Task.WhenAll(tasks);
    }
}

3. 容器生命週期管理

使用 Collection Fixture 模式有效管理 Testcontainers 的生命週期:

[CollectionDefinition(Name)]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
    public const string Name = "Integration Tests";
}

這種設計確保:

  • 容器重用:所有測試共享同一組容器
  • 成本控制:避免為每個測試類別建立新容器
  • 測試隔離:透過資料清理確保測試間的獨立性
public class IntegrationTestBase : IAsyncLifetime
{
    protected readonly TestWebApplicationFactory Factory;
    protected readonly HttpClient HttpClient;
    protected readonly DatabaseManager DatabaseManager;
    protected readonly IFlurlClient FlurlClient;

    protected IntegrationTestBase(TestWebApplicationFactory factory)
    {
        Factory = factory;
        HttpClient = factory.CreateClient();
        DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());

        // 設定 Flurl 用戶端
        FlurlClient = new FlurlClient(HttpClient);
    }
}

整合測試最佳實踐總結

1. 測試結構設計原則

  • 單一職責:每個測試專注於一個特定的業務場景
  • 3A 模式:清楚區分 Arrange、Act、Assert 三個階段
  • 可讀性優先:測試方法名稱要能清楚表達測試意圖

2. 資料管理策略

// 每個測試前初始化資料庫結構
public async Task InitializeAsync()
{
    await DatabaseManager.InitializeDatabaseAsync();
}

// 使用 TestHelpers 建立一致的測試資料
var request = TestHelpers.CreateProductRequest("測試產品", 199.99m);

3. 錯誤處理驗證重點

整合測試必須驗證應用程式的錯誤處理機制:

  • ValidationProblemDetails:模型驗證失敗的回應格式
  • ExceptionHandler:全域例外處理的行為
  • HTTP 狀態碼:正確的狀態碼回傳

4. 效能考量

  • 容器共享:使用 Collection Fixture 避免重複建立容器
  • 資料清理:每次測試後只清理資料,不重建容器
  • 並行執行:確保測試間的獨立性,支援並行執行

實務開發建議

測試覆蓋重點

  1. API 端點:所有 HTTP 方法和路由
  2. 資料驗證:模型驗證和業務規則
  3. 錯誤情境:各種異常狀況的處理
  4. 整合流程:跨層級的資料流

技術債務避免

  1. 避免測試相依性:每個測試都應該能獨立執行
  2. 避免硬編碼:使用設定或常數管理測試資料
  3. 避免過度測試:專注於業務邏輯,不要測試框架本身

這種整合測試方法確保了我們能夠在真實環境中驗證應用程式的行為,同時保持測試的可維護性和執行效率。

TestWebApplicationFactory 實作詳解

專案的 TestWebApplicationFactory 負責管理測試環境的容器和服務配置:

public class TestWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private PostgreSqlContainer? _postgresContainer;
    private RedisContainer? _redisContainer;
    private FakeTimeProvider? _timeProvider;

    public PostgreSqlContainer PostgresContainer => _postgresContainer
                                                    ?? throw new InvalidOperationException("PostgreSQL container 尚未初始化");

    public RedisContainer RedisContainer => _redisContainer
                                            ?? throw new InvalidOperationException("Redis container 尚未初始化");

    public FakeTimeProvider TimeProvider => _timeProvider
                                            ?? throw new InvalidOperationException("TimeProvider 尚未初始化");

    public async Task InitializeAsync()
    {
        // 建立 PostgreSQL container
        _postgresContainer = new PostgreSqlBuilder()
                             .WithImage("postgres:16-alpine")
                             .WithDatabase("day23_test")
                             .WithUsername("testuser")
                             .WithPassword("testpass")
                             .WithCleanUp(true)
                             .Build();

        // 建立 Redis container
        _redisContainer = new RedisBuilder()
                          .WithImage("redis:7-alpine")
                          .WithCleanUp(true)
                          .Build();

        // 建立 FakeTimeProvider
        _timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));

        // 啟動容器
        await _postgresContainer.StartAsync();
        await _redisContainer.StartAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureAppConfiguration(config =>
        {
            // 移除現有的設定來源
            config.Sources.Clear();

            // 添加測試專用設定
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ConnectionStrings:DefaultConnection"] = PostgresContainer.GetConnectionString(),
                ["ConnectionStrings:Redis"] = RedisContainer.GetConnectionString(),
                ["Logging:LogLevel:Default"] = "Warning",
                ["Logging:LogLevel:System"] = "Warning",
                ["Logging:LogLevel:Microsoft"] = "Warning"
            });
        });

        builder.ConfigureServices(services =>
        {
            // 替換 TimeProvider 為 FakeTimeProvider
            services.Remove(services.Single(d => d.ServiceType == typeof(TimeProvider)));
            services.AddSingleton<TimeProvider>(TimeProvider);
        });

        builder.UseEnvironment("Testing");
    }

    public new async Task DisposeAsync()
    {
        if (_postgresContainer != null)
        {
            await _postgresContainer.DisposeAsync();
        }

        if (_redisContainer != null)
        {
            await _redisContainer.DisposeAsync();
        }

        await base.DisposeAsync();
    }
}

這個 TestWebApplicationFactory 的設計重點:

  1. 容器管理:使用屬性公開容器,提供更好的類型安全
  2. FakeTimeProvider 整合:讓時間相關的測試變得可預測
  3. 配置覆寫:在 ConfigureWebHost 中完全控制測試環境的配置
  4. 環境隔離:每次測試執行都使用獨立的容器實例

進階測試基底類別

增強的測試基底類別提供更多功能:

/// <summary>
/// 整合測試基底類別 - 使用 Collection Fixture 共享容器
/// </summary>
[Collection("Integration Tests")]
public abstract class IntegrationTestBase : IAsyncLifetime
{
    protected readonly TestWebApplicationFactory Factory;
    protected readonly HttpClient HttpClient;
    protected readonly DatabaseManager DatabaseManager;
    protected readonly IFlurlClient FlurlClient;

    protected IntegrationTestBase(TestWebApplicationFactory factory)
    {
        Factory = factory;
        HttpClient = factory.CreateClient();
        DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());

        // 設定 Flurl 用戶端
        FlurlClient = new FlurlClient(HttpClient);
    }

    public virtual async Task InitializeAsync()
    {
        // 初始化資料庫結構
        await DatabaseManager.InitializeDatabaseAsync();
    }

    public virtual async Task DisposeAsync()
    {
        // 清理資料庫資料
        await DatabaseManager.CleanDatabaseAsync();

        FlurlClient.Dispose();
    }

    /// <summary>
    /// 重設時間為測試開始時間
    /// </summary>
    protected void ResetTime()
    {
        Factory.TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
    }

    /// <summary>
    /// 前進時間
    /// </summary>
    protected void AdvanceTime(TimeSpan timeSpan)
    {
        Factory.TimeProvider.Advance(timeSpan);
    }

    /// <summary>
    /// 設定特定時間
    /// </summary>
    protected void SetTime(DateTimeOffset time)
    {
        Factory.TimeProvider.SetUtcNow(time);
    }
}

SQL 指令碼外部化策略

跟隨 Day21 建立的最佳實務,將 SQL 指令碼外部化:

tests/Day23.Tests.Integration/
└── SqlScripts/
    └── Tables/
        └── CreateProductsTable.sql
-- CreateProductsTable.sql
CREATE TABLE IF NOT EXISTS products (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(200) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 建立更新時間觸發器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

DROP TRIGGER IF EXISTS update_products_updated_at ON products;
CREATE TRIGGER update_products_updated_at
    BEFORE UPDATE ON products
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

DatabaseManager 會自動載入這些指令碼:

/// <summary>
/// 確保資料表存在,使用外部 SQL 指令碼建立
/// 實作第 Day 21 介紹的 SQL 指令碼外部化策略
/// </summary>
private async Task EnsureTablesExistAsync(NpgsqlConnection connection)
{
    var scriptDirectory = Path.Combine(AppContext.BaseDirectory, "SqlScripts");
    if (!Directory.Exists(scriptDirectory))
    {
        throw new DirectoryNotFoundException($"SQL 指令碼目錄不存在: {scriptDirectory}");
    }

    // 按照依賴順序執行表格建立腳本
    var orderedScripts = new[]
    {
        "Tables/CreateProductsTable.sql"
    };

    foreach (var scriptPath in orderedScripts)
    {
        var fullPath = Path.Combine(scriptDirectory, scriptPath);
        if (File.Exists(fullPath))
        {
            var script = await File.ReadAllTextAsync(fullPath);
            await using var command = new NpgsqlCommand(script, connection);
            await command.ExecuteNonQueryAsync();
        }
        else
        {
            throw new FileNotFoundException($"SQL 指令碼檔案不存在: {fullPath}");
        }
    }
}

Flurl 整合應用

Flurl 是一個強大的 HTTP 客戶端函式庫,在整合測試中特別有用。它提供了流暢的 API 來建構複雜的 URL 和查詢參數,讓測試程式碼更加簡潔和可讀。

簡化 QueryString 建立

傳統的查詢參數建構方式容易出錯,Flurl 提供了型別安全的方式:

[Fact]
public async Task GetProducts_使用分頁參數_應回傳正確的分頁結果()
{
    // Arrange
    await TestHelpers.SeedProductsAsync(DatabaseManager, 15);

    // Act - 使用 Flurl 建構 QueryString
    var url = "/products"
              .SetQueryParam("pageSize", 5)
              .SetQueryParam("page", 2);

    var response = await HttpClient.GetAsync(url);

    // Assert
    response.Should().Be200Ok()
            .And.Satisfy<PagedResult<ProductResponse>>(result =>
            {
                result.Total.Should().Be(15);
                result.PageSize.Should().Be(5);
                result.Page.Should().Be(2);
                result.PageCount.Should().Be(3);
                result.Items.Should().HaveCount(5);
            });
}

搜尋功能測試

[Fact]
public async Task GetProducts_使用搜尋參數_應回傳符合條件的產品()
{
    // Arrange
    await TestHelpers.SeedProductsAsync(DatabaseManager, 5);
    await TestHelpers.SeedSpecificProductAsync(DatabaseManager, "特殊產品", 199.99m);

    // Act - 使用 Flurl 建構複雜查詢
    var url = "/products"
              .SetQueryParam("keyword", "特殊")
              .SetQueryParam("pageSize", 10);

    var response = await HttpClient.GetAsync(url);

    // Assert
    response.Should().Be200Ok()
            .And.Satisfy<PagedResult<ProductResponse>>(result =>
            {
                result.Total.Should().Be(1);
                result.Items.Should().HaveCount(1);
                
                var product = result.Items.First();
                product.Name.Should().Be("特殊產品");
                product.Price.Should().Be(199.99m);
            });
}

ExceptionHandler 整合測試策略

測試設計原則

整合測試的異常處理驗證需要涵蓋完整的錯誤處理流程,從異常拋出到最終的 HTTP 回應格式。我們的測試策略包括:

不同類型錯誤回應的驗證:確保各種異常都能正確轉換為對應的 HTTP 狀態碼和 ProblemDetails 結構。

ProblemDetails 結構完整性測試:驗證錯誤回應包含所有必要的標準欄位。

ValidationProblemDetails 多欄位錯誤驗證:測試複雜的驗證場景,確保所有驗證錯誤都能正確回傳。

全域異常處理器測試

使用實際專案中的測試案例,驗證 GlobalExceptionHandler 的行為:

[Fact]
public async Task GetById_當產品不存在_應回傳404且包含ProblemDetails()
{
    // Arrange
    var nonExistentId = Guid.NewGuid();

    // Act
    var response = await HttpClient.GetAsync($"/Products/{nonExistentId}");

    // Assert
    response.Should().Be404NotFound()
            .And.Satisfy<ProblemDetails>(problem =>
            {
                problem.Type.Should().Be("https://httpstatuses.com/404");
                problem.Title.Should().Be("產品不存在");
                problem.Status.Should().Be(404);
                problem.Detail.Should().Contain($"找不到 ID 為 {nonExistentId} 的產品");
            });
}

這個測試展示了幾個重要的驗證重點:

  1. HTTP 狀態碼驗證:使用 Be404NotFound() 確保回傳正確的狀態碼
  2. ProblemDetails 結構驗證:透過 Satisfy<ProblemDetails> 驗證錯誤回應的完整結構
  3. 動態內容驗證:確保錯誤訊息包含實際的 ID 值

另一個測試案例驗證刪除不存在產品的情境:

[Fact]
public async Task Delete_當產品不存在_應回傳404且包含ProblemDetails()
{
    // Arrange
    var nonExistentId = Guid.NewGuid();

    // Act
    var response = await HttpClient.DeleteAsync($"/Products/{nonExistentId}");

    // Assert
    response.Should().Be404NotFound();

    var content = await response.Content.ReadAsStringAsync();
    var problemDetails = JsonSerializer.Deserialize<JsonElement>(content);

    // 檢查 ProblemDetails 結構
    problemDetails.GetProperty("type").GetString().Should().Be("https://httpstatuses.com/404");
    problemDetails.GetProperty("title").GetString().Should().Be("產品不存在");
    problemDetails.GetProperty("status").GetInt32().Should().Be(404);
    problemDetails.GetProperty("detail").GetString().Should().Contain($"找不到 ID 為 {nonExistentId} 的產品");
}

ValidationProblemDetails 測試策略

單一欄位驗證錯誤測試

驗證單一欄位的驗證失敗情境:

[Fact]
public async Task CreateProduct_當產品名稱為空_應回傳400BadRequest()
{
    // Arrange
    var invalidRequest = new ProductCreateRequest
    {
        Name = "",
        Price = 100.00m
    };

    // Act
    var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);

    // Assert
    response.Should().Be400BadRequest()
            .And.Satisfy<ValidationProblemDetails>(problem =>
            {
                problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
                problem.Title.Should().Be("One or more validation errors occurred.");
                problem.Status.Should().Be(400);
                problem.Errors.Should().ContainKey("Name");
                problem.Errors["Name"].Should().Contain("產品名稱不能為空");
            });
}

多欄位同時驗證錯誤測試

測試多個欄位同時發生驗證錯誤的情境:

[Fact]
public async Task CreateProduct_當產品名稱和價格都無效_應回傳400BadRequest()
{
    // Arrange
    var invalidRequest = new ProductCreateRequest
    {
        Name = "",
        Price = -10.00m
    };

    // Act
    var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);

    // Assert
    response.Should().Be400BadRequest()
            .And.Satisfy<ValidationProblemDetails>(problem =>
            {
                problem.Status.Should().Be(400);
                problem.Errors.Should().ContainKey("Name");
                problem.Errors.Should().ContainKey("Price");
                problem.Errors["Name"].Should().Contain("產品名稱不能為空");
                problem.Errors["Price"].Should().Contain("產品價格必須大於 0");
            });
}

複雜驗證規則的測試覆蓋

測試產品名稱長度限制的情境:

[Fact]
public async Task CreateProduct_當產品名稱超過長度限制_應回傳400BadRequest()
{
    // Arrange
    var invalidRequest = new ProductCreateRequest
    {
        Name = new string('A', 201), // 超過 200 字元限制
        Price = 100.00m
    };

    // Act
    var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);

    // Assert
    response.Should().Be400BadRequest()
            .And.Satisfy<ValidationProblemDetails>(problem =>
            {
                problem.Status.Should().Be(400);
                problem.Errors.Should().ContainKey("Name");
                problem.Errors["Name"].Should().Contain("產品名稱長度不能超過 200 個字元");
            });
}

測試最佳實務

錯誤情境的完整覆蓋策略

  1. 正常流程測試:確保正確的請求能正常處理
  2. 驗證錯誤測試:涵蓋所有可能的驗證失敗情境
  3. 業務邏輯錯誤測試:測試業務規則違反的情況
  4. 系統異常測試:模擬系統層級的異常情況

測試案例的可讀性設計

清楚的測試命名:測試方法名稱要能清楚表達測試情境和期望結果

CreateProduct_當產品名稱為空_應回傳400BadRequest()
GetById_當產品不存在_應回傳404且包含ProblemDetails()

3A 模式的嚴格遵循:每個測試都要有清楚的 Arrange、Act、Assert 區段

// Arrange - 準備測試資料
var invalidRequest = new ProductCreateRequest { Name = "", Price = 100.00m };

// Act - 執行被測試的動作
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);

// Assert - 驗證結果
response.Should().Be400BadRequest();

異常處理的效能考量

避免過度詳細的錯誤訊息:在 GlobalExceptionHandler 中要平衡資訊完整性和安全性

日誌記錄策略:確保異常處理器會記錄適當的日誌等級

_logger.LogError(exception, "發生未處理的異常: {Message}", exception.Message);
_logger.LogWarning(validationException, "驗證失敗: {Message}", validationException.Message);

回應時間監控:整合測試中要確保錯誤處理不會顯著影響回應時間

這種完整的異常處理測試策略確保了 API 在各種錯誤情境下都能提供一致、標準化的回應,提升了整個系統的穩定性和使用者體驗。

實際整合測試案例分析

讓我們檢視專案中真實存在的整合測試:

ProductsController 實際測試案例

以下是專案中的真實測試程式碼案例:

1. 產品建立測試

[Fact]
public async Task CreateProduct_使用有效資料_應成功建立產品()
{
    // Arrange
    var request = TestHelpers.CreateProductRequest("新產品", 299.99m);

    // Act
    var response = await HttpClient.PostAsJsonAsync("/products", request);

    // Assert
    response.Should().Be201Created()
            .And.Satisfy<ProductResponse>(product =>
            {
                product.Id.Should().NotBeEmpty();
                product.Name.Should().Be("新產品");
                product.Price.Should().Be(299.99m);
                product.CreatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
                product.UpdatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
            });
}

2. 驗證錯誤測試

[Fact]
public async Task CreateProduct_當產品名稱為空_應回傳400BadRequest()
{
    // Arrange
    var invalidRequest = new ProductCreateRequest
    {
        Name = "",
        Price = 100.00m
    };

    // Act
    var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);

    // Assert
    response.Should().Be400BadRequest()
            .And.Satisfy<ValidationProblemDetails>(problem =>
            {
                problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
                problem.Title.Should().Be("One or more validation errors occurred.");
                problem.Status.Should().Be(400);
                problem.Errors.Should().ContainKey("Name");
                problem.Errors["Name"].Should().Contain("產品名稱不能為空");
            });
}

3. 分頁查詢測試

[Fact]
public async Task GetProducts_使用分頁參數_應回傳正確的分頁結果()
{
    // Arrange
    await TestHelpers.SeedProductsAsync(DatabaseManager, 15);

    // Act - 使用 Flurl 建構 QueryString
    var url = "/products"
              .SetQueryParam("pageSize", 5)
              .SetQueryParam("page", 2);

    var response = await HttpClient.GetAsync(url);

    // Assert
    response.Should().Be200Ok()
            .And.Satisfy<PagedResult<ProductResponse>>(result =>
            {
                result.Total.Should().Be(15);
                result.PageSize.Should().Be(5);
                result.Page.Should().Be(2);
                result.PageCount.Should().Be(3);
                result.Items.Should().HaveCount(5);
                result.Items.Should().AllSatisfy(product =>
                {
                    product.Id.Should().NotBeEmpty();
                    product.Name.Should().NotBeNullOrEmpty();
                    product.Price.Should().BeGreaterThan(0);
                });
            });
}

4. 錯誤處理測試

[Fact]
public async Task GetById_當產品不存在_應回傳404且包含ProblemDetails()
{
    // Arrange
    var nonExistentId = Guid.NewGuid();

    // Act
    var response = await HttpClient.GetAsync($"/Products/{nonExistentId}");

    // Assert
    response.Should().Be404NotFound();

    var content = await response.Content.ReadAsStringAsync();
    var problemDetails = JsonSerializer.Deserialize<JsonElement>(content);

    // 檢查 ProblemDetails 結構
    problemDetails.GetProperty("type").GetString().Should().Be("https://httpstatuses.com/404");
    problemDetails.GetProperty("title").GetString().Should().Be("產品不存在");
    problemDetails.GetProperty("status").GetInt32().Should().Be(404);
    problemDetails.GetProperty("detail").GetString().Should().Contain($"找不到 ID 為 {nonExistentId} 的產品");
}

HealthController 測試案例

[Fact]
public async Task GetHealth_應回傳200OK()
{
    // Act
    var response = await HttpClient.GetAsync("/health");

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

實務經驗與踩雷心得

容器啟動順序很重要

在設定 Testcontainers 時,要確保容器按正確順序啟動:

// 確保容器完全啟動
await _postgresContainer.StartAsync();
await _redisContainer.StartAsync();

測試隔離的重要性

每個測試都應該有乾淨的起始狀態:

public async Task DisposeAsync()
{
    // 清理資料庫資料
    await DatabaseManager.CleanDatabaseAsync();
    FlurlClient.Dispose();
}

整合測試實務經驗總結

容器啟動順序最佳實務

專案的 TestWebApplicationFactory 已經處理了容器的正確啟動順序:

public async Task InitializeAsync()
{
    // 建立並啟動容器
    await _postgresContainer.StartAsync();
    await _redisContainer.StartAsync();
}

測試資料隔離是關鍵

專案使用 Respawner 來確保測試資料隔離:

/// <summary>
/// 清理資料庫資料
/// </summary>
public async Task CleanDatabaseAsync()
{
    if (_respawner == null)
    {
        throw new InvalidOperationException("Respawner 尚未初始化,請先呼叫 InitializeDatabaseAsync");
    }

    await using var connection = new NpgsqlConnection(_connectionString);
    await connection.OpenAsync();
    await _respawner.ResetAsync(connection);
}

### 記憶體洩漏防範

專案中的 IntegrationTestBase 已經正確處理資源釋放:

```csharp
public virtual async Task DisposeAsync()
{
    // 清理資料庫資料
    await DatabaseManager.CleanDatabaseAsync();

    FlurlClient.Dispose();
}

測試輔助工具設計

使用實際專案中的 TestHelpers 類別:

/// <summary>
/// 驗證產品回應
/// </summary>
public static void AssertProductResponse(
    ProductResponse response,
    string expectedName,
    decimal expectedPrice,
    Guid? expectedId = null)
{
    response.Should().NotBeNull();
    response.Name.Should().Be(expectedName);
    response.Price.Should().Be(expectedPrice);
    response.CreatedAt.Should().BeAfter(DateTimeOffset.MinValue);
    response.UpdatedAt.Should().BeAfter(DateTimeOffset.MinValue);

    if (expectedId.HasValue)
    {
        response.Id.Should().Be(expectedId.Value);
    }
    else
    {
        response.Id.Should().NotBe(Guid.Empty);
    }
}

整合測試最佳實務建議

測試環境配置管理

專案使用 TestWebApplicationFactory 統一管理測試環境配置,確保測試環境的一致性和可重複性。

測試失敗診斷策略

在測試失敗時,有效的診斷策略能幫助快速定位問題:

  1. 日誌分析:檢查應用程式日誌和容器日誌
  2. 資料庫狀態:檢查測試執行前後的資料庫狀態
  3. 網路狀態:確認容器間的網路連線正常
  4. 環境變數:驗證測試環境的配置變數
  5. 時間因素:檢查時間相關的邏輯是否正確

測試效能監控

建立測試效能的監控機制:

在整合測試中,監控測試執行效能對於確保測試品質很重要:

  1. 執行時間監控:使用 Stopwatch 測量關鍵操作的執行時間
  2. 記憶體使用監控:透過 GC.GetTotalMemory() 檢查記憶體使用狀況
  3. 容器健康檢查:定期檢查 Testcontainers 的執行狀態
  4. 資料庫效能:監控 SQL 查詢的執行時間
  5. API 回應時間:確保 HTTP 請求在合理時間內完成

除錯技巧與故障排除

專案中實際的健康檢查測試範例:

[Fact]
public async Task Get_Health_應回傳200狀態碼()
{
    // Arrange
    // (無需特別準備)

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

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

AwesomeAssertions 讓斷言更清晰

使用 AwesomeAssertions 的 Satisfy 方法可以讓複雜物件的驗證更加清晰:

response.Should().Be200Ok()
        .And.Satisfy<PagedResult<ProductResponse>>(result =>
        {
            result.Total.Should().Be(15);
            result.Items.Should().AllSatisfy(product =>
            {
                product.Id.Should().NotBeEmpty();
                product.Name.Should().NotBeNullOrEmpty();
                product.Price.Should().BeGreaterThan(0);
            });
        });

今日總結

今天我們完成了 Clean Architecture 的整合測試實戰,從 ASP.NET Core WebApi 到複雜的錯誤處理機制,建立了完整的整合測試技能。

ExceptionHandler 與 ValidationProblemDetails 整合

統一錯誤處理:我們實作了 ASP.NET Core 9 的 IExceptionHandler 整合測試,確保全域例外處理能正確回傳結構化的錯誤回應。透過 ProblemDetails 標準,讓 API 錯誤處理更加一致和專業。

模型驗證整合ValidationProblemDetails 的整合測試涵蓋了各種驗證失敗情境,從空值檢查到複雜的業務規則驗證,確保 API 能提供清楚的錯誤訊息。

整合測試基礎設施最佳實務

Testcontainers 與 Clean Architecture:我們建立了支援 PostgreSQL 和 Redis 的完整測試環境,使用 Collection Fixture 模式確保測試效率。測試涵蓋了從 Controller 到 Repository 的完整資料流,驗證了各層級間的正確互動。

AwesomeAssertions 的實戰應用:透過豐富的斷言方法,我們能精確驗證 HTTP 回應的結構和內容。從狀態碼檢查到複雜物件驗證,提供了完整的測試覆蓋。

測試資料管理策略

資料隔離與清理:每個測試都有乾淨的起始狀態,透過 DatabaseManager 確保測試間的獨立性。這種方法比共享資料更可靠,避免了測試間的相互干擾。

TestHelpers 設計模式:統一的測試資料建立方法,讓測試程式碼更加簡潔和可維護。這是整合測試可讀性的關鍵。

時間控制與可預測性

TimeProvider 整合:透過可控的時間提供者,確保測試結果的可預測性。這在驗證時間戳記、快取 TTL 等場景中特別重要。

實務開發心得

  1. 從簡單到複雜:先建立基本的 CRUD 測試,再加入錯誤處理、分頁查詢等進階功能
  2. 重視錯誤情境:好的 API 不只要處理正常情況,更要優雅地處理各種異常
  3. 測試要有實際意義:測試案例要反映真實的使用場景,不只是為了覆蓋率
  4. 基礎設施是關鍵:投資時間建立好的測試基礎設施,後續的測試開發會更有效率

整合測試雖然比單元測試複雜,但它提供了最接近真實環境的驗證。這種信心是單元測試無法給予的,也是高品質軟體不可或缺的一環。

參考資料

ExceptionHandler 相關資料

Flurl

Respawner

範例程式碼:


這是「重啟挑戰:老派軟體工程師的測試修練」的第二十三天。明天會介紹 Day 24 – .NET Aspire Test Project 概論與配置。


上一篇
Day 22 - Testcontainers 整合測試:MongoDB 及 Redis 基礎到進階
系列文
重啟挑戰:老派軟體工程師的測試修練23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言