iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Software Development

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

Day 24 - .NET Aspire Testing 入門基礎介紹

  • 分享至 

  • xImage
  •  

前言

在現代雲原生應用開發中,我們經常面臨一個棘手的問題:如何有效測試複雜的分散式應用?

傳統單元測試執行快速,但無法涵蓋服務間的互動。端對端測試覆蓋面廣,但配置複雜且執行緩慢。當應用程式需要連接 SQL Server 資料庫、Redis 快取、訊息佇列等多個外部服務時,這個挑戰變得更加嚴峻。

.NET Aspire 作為微軟推出的雲原生應用開發平台,不僅提供了強大的服務編排能力,還包含專門的測試支援。它讓我們能夠在測試環境中重現完整的應用架構,同時保持測試的隔離性和可重複性。

今天我們將深入探索 .NET Aspire 的測試框架,特別專注於 Repository/Service 架構的整合測試。相比於 Day 21 介紹的 Testcontainers for .NET,.NET Aspire Testing 在某些場景下提供了不同層級的抽象和整合方式。

環境準備

要使用 .NET Aspire Testing,需要先準備以下工具和環境。對於 Windows 作業系統,主要有兩個部分:安裝 .NET 8 SDK、安裝 Docker Desktop。

1. 安裝 .NET 8 SDK (以上版本)

.NET Aspire 框架是建立在 .NET 8 之上的,所以這是最基本也最重要的一步。

下載與安裝:前往 dotnet.microsoft.com/download/dotnet/8.0,下載並安裝 .NET 8.0 SDK。

驗證:安裝完成後,開啟命令提示字元(CMD)或 PowerShell,輸入 dotnet --info。如果看到 SDKs: 8.0.x 相關資訊,就表示安裝成功。

2. 安裝 Docker Desktop

.NET Aspire 使用 Docker 來協調和運行應用程式中的各種服務(如資料庫、Redis、RabbitMQ 等)。因此,Docker 是必不可少的。

下載與安裝:前往 docs.docker.com/desktop/install/windows-install/ 下載 Docker Desktop。

系統要求:安裝 Docker Desktop 需要 Windows 系統支援 WSL 2 (Windows Subsystem for Linux 2) 或 Hyper-V。建議使用 WSL 2,因為效能更好。

詳細的 Docker Desktop 安裝步驟與環境設定,可以參考 Day 20 – Testcontainers 初探:使用 Docker 架設測試環境 中的「環境準備」章節,其中包含完整的 WSL 2 啟用、Docker Desktop 安裝和效能最佳化設定。

本篇內容

今天的內容有:

  • 認識 .NET Aspire Testing 支援的核心概念:了解 Aspire 如何簡化分散式應用測試
  • 掌握 .NET Aspire Testing 專案建立與配置:從專案結構到套件引用的完整建置
  • 實作整合測試:使用真實 SQL Server 容器進行資料層測試
  • 學習測試生命週期管理與最佳實踐:確保測試穩定性和效能

.NET Aspire Testing 框架介紹

什麼是 .NET Aspire Testing?

.NET Aspire Testing 是微軟為分散式應用量身打造的測試解決方案。核心理念是:在測試中重現與正式環境相同的服務架構

傳統測試方法往往需要 Mock 外部依賴,或者手動建置複雜的測試環境。而 .NET Aspire Testing 提供了統一的抽象層,讓我們能夠用簡潔的程式碼啟動完整的應用架構:

// AppHost 的基本結構
var builder = DistributedApplication.CreateBuilder(args);

var database = builder.AddSqlServer("sql")
                     .AddDatabase("bookstore-db");

var api = builder.AddProject<Projects.BookStore_Api>("bookstore-api")
               .WithReference(database);

builder.Build().Run();

當我們要測試這樣的架構時,就面臨了一個根本性挑戰:如何在測試環境中重現這種複雜的服務編排?傳統的單一應用測試根本無法處理這種情況。

分散式應用測試的挑戰

分散式應用的測試挑戰主要體現在以下幾個方面:

服務間通訊的複雜性
不同服務之間的網路呼叫、序列化/反序列化、錯誤處理等,都增加了測試的複雜度。

資源依賴管理
特別是資料庫這類有狀態的服務,需要在測試前進行初始化,測試後進行清理,還要確保不同測試之間的隔離性。

環境一致性問題
開發環境、測試環境、正式環境之間的差異,常常導致「在我的電腦上可以運行」的經典問題。

測試隔離與並行執行
多個測試同時運行時,如何確保它們不會互相干擾,特別是在使用共享資源時。

測試類型與策略

在分散式應用的測試金字塔中,我們通常有這幾種測試類型:

  • 單元測試:測試單一組件或方法,速度快但覆蓋面有限
  • 整合測試:測試多個組件的協作,平衡了速度和覆蓋面
  • 端對端測試:測試完整的使用者流程,覆蓋面最廣但執行最慢

.NET Aspire Testing 定位是封閉式整合測試。它讓我們能夠在可控環境中測試真實的服務互動,而不需要依賴外部測試環境。

.NET Aspire Testing vs Testcontainers for .NET 比較

既然提到了容器化測試,你可能會想:「我們之前不是學過 Testcontainers 嗎?為什麼還需要 .NET Aspire Testing?」

相同點

兩者確實有很多相似之處:

  • 都支援容器化資源管理
  • 提供測試隔離環境
  • 簡化整合測試設置

差異點

但是它們的設計理念和適用場景有所不同:

.NET Aspire Testing
  • 專為雲原生應用設計,與 Aspire 應用模型深度整合
  • 內建服務編排能力,能夠重現正式環境的複雜架構
  • 適合已經使用 .NET Aspire 的專案
Testcontainers for .NET
  • 通用容器測試框架,不限於特定的應用架構
  • 更靈活,但需要更多手動配置
  • 適合傳統的 Web API + 資料庫架構

選擇建議

簡單來說:

  • 如果專案已經使用了 .NET Aspire,那麼優先使用 .NET Aspire Testing
  • 如果是傳統的 Web API + 資料庫架構,Testcontainers 可能更簡單
  • 如果需要特殊的容器配置或非 .NET 的容器,Testcontainers 更靈活

Testcontainers 和 .NET Aspire 都是針對 .NET 開發人員的優秀工具,但它們的用途不同。Testcontainers 主要用於測試,而 .NET Aspire 則用於開發和協調雲端原生應用程式

.NET Aspire Testing 專案配置

核心套件與工具介紹

Aspire.Hosting.Testing 套件

.NET Aspire Testing 的核心是 Aspire.Hosting.Testing 套件。它提供了一系列 API 來管理測試環境中的分散式應用:

// 建立測試應用程式
var appHost = await DistributedApplicationTestingBuilder
    .CreateAsync<Projects.BookStore_AppHost>();

// 建置測試環境配置
var app = await appHost.BuildAsync();
await app.StartAsync();

// 取得服務連接資訊
var connectionString = app.GetConnectionString("bookstore-db");
var httpClient = app.CreateHttpClient("bookstore-api");

主要提供的功能包括:

  • DistributedApplicationTestingBuilder:讀取 AppHost 配置並建立測試環境
  • DistributedApplicationFactory:管理應用程式的生命週期
  • 資源管理:自動處理容器的啟動、停止和清理

必要套件引用

在專案檔中需要引用以下套件:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.Testing" Version="9.1.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
    <PackageReference Include="AwesomeAssertions" Version="9.1.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="../BookStore.Core/BookStore.Core.csproj" />
    <ProjectReference Include="../BookStore.AppHost/BookStore.AppHost.csproj" />
  </ItemGroup>

</Project>

GlobalUsings.cs 建置

建立 GlobalUsings.cs 檔案來簡化 using 陳述式:

global using System;
global using System.Linq;
global using System.Collections.Generic;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.DependencyInjection;
global using Xunit;
global using AwesomeAssertions;
global using Microsoft.EntityFrameworkCore;
global using Aspire.Hosting;
global using Aspire.Hosting.Testing;
global using Aspire.Hosting.ApplicationModel;
global using BookStore.Core.Models;
global using BookStore.Core.Data;
global using BookStore.Core.Repositories;
global using BookStore.Core.Services;

專案架構設計

在進入測試實作之前,我們需要先建立合適的專案架構。根據最佳實踐,我們將採用 Repository 模式和 Service 層來分離關注點,確保我們測試的是業務邏輯而不是第三方框架的功能。

為什麼需要 Repository 和 Service 層?

直接在測試中使用 Entity Framework Core 的 DbContext 會遇到幾個問題:

  1. 測試對象錯誤:我們實際上是在測試 EF Core 的功能,而不是我們自己的業務邏輯
  2. 緊耦合問題:測試程式碼直接依賴於具體的 ORM 實作
  3. 難以擴展:當業務邏輯變複雜時,缺少合適的抽象層

透過引入 Repository 和 Service 層,我們可以:

  • 測試業務邏輯:專注於驗證我們的商業規則和流程
  • 抽象資料存取:隔離外部依賴,提高測試的可控性
  • 分離關注點:清楚區分資料存取和業務邏輯的職責

專案結構設計

我們的專案結構如下:

src/BookStore.Core/
├── Models/
│   └── Book.cs                      # 實體模型
├── Data/
│   └── BookStoreDbContext.cs        # EF Core 資料庫內容
├── Repositories/
│   ├── IBookRepository.cs           # 資料存取介面
│   └── EfCoreBookRepository.cs      # EF Core 實作
└── Services/
    ├── IBookService.cs              # 業務邏輯介面
    └── BookService.cs               # 業務邏輯實作

tests/BookStore.Tests/
├── Integration/
│   ├── EfCoreBookRepositoryTests.cs # Repository 整合測試
│   ├── BookServiceTests.cs          # Service 業務邏輯測試
│   └── BookStoreDbTests.cs          # 資料庫功能測試
└── Infrastructure/
    └── AspireAppFixture.cs          # 測試基礎設施

Repository 介面設計

IBookRepository 定義了書籍資料存取的抽象:

public interface IBookRepository
{
    Task<IEnumerable<Book>> GetAllAsync();
    Task<Book?> GetByIdAsync(int id);
    Task<Book> AddAsync(Book book);
    Task UpdateAsync(Book book);
    Task DeleteAsync(int id);
    Task<IEnumerable<Book>> GetBooksByAuthorAsync(string author);
    Task<IEnumerable<Book>> GetExpensiveBooksAsync(decimal minPrice);
}

Service 介面設計

IBookService 定義了書籍相關的業務邏輯:

public interface IBookService
{
    Task<Book> CreateBookAsync(string title, string author, decimal price);
    Task<Book?> GetBookAsync(int id);
    Task<IEnumerable<Book>> GetAllBooksAsync();
    Task<Book> UpdateBookPriceAsync(int id, decimal newPrice);
    Task DeleteBookAsync(int id);
    Task<IEnumerable<Book>> SearchBooksByAuthorAsync(string author);
    Task<IEnumerable<Book>> GetRecommendedBooksAsync(); // 業務邏輯:推薦書籍
}

業務邏輯範例

BookService 中,我們實作了一些商業規則:

public async Task<Book> CreateBookAsync(string title, string author, decimal price)
{
    // 業務規則:價格必須大於 0
    if (price <= 0)
        throw new ArgumentException("書籍價格必須大於 0", nameof(price));
    
    var book = new Book
    {
        Title = title,
        Author = author,
        Price = price,
        PublishedDate = DateTime.UtcNow,
        CreatedDate = DateTime.UtcNow
    };
    
    return await _repository.AddAsync(book);
}

public async Task<IEnumerable<Book>> GetRecommendedBooksAsync()
{
    // 業務邏輯:推薦價格在 100-800 之間的書籍
    var allBooks = await _repository.GetAllAsync();
    return allBooks.Where(b => b.Price >= 100m && b.Price <= 800m);
}

這樣的設計讓我們可以針對不同層級進行適當的測試:

  • Repository 測試:驗證資料存取邏輯的正確性
  • Service 測試:驗證業務邏輯和規則
  • 整合測試:驗證各層協作的正確性

建立 AspireAppFixture

AspireAppFixture 是測試基礎設施的核心,負責管理 .NET Aspire 應用程式的生命週期:

public class AspireAppFixture : IAsyncLifetime
{
    private DistributedApplication? _app;

    public async Task InitializeAsync()
    {
        // 建立 .NET Aspire Testing 主機
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.BookStore_AppHost>();
        
        _app = await appHost.BuildAsync();
        await _app.StartAsync();
    }

    /// <summary>
    /// 取得資料庫內容
    /// </summary>
    public async Task<BookStoreDbContext> GetDbContextAsync()
    {
        if (_app == null)
            throw new InvalidOperationException("應用程式尚未初始化");

        // 等待 SQL Server 容器啟動並取得連接字串
        var sqlResource = _app.Services.GetRequiredService<ResourceNotificationService>();
        await sqlResource.WaitForResourceAsync("bookstore-db", KnownResourceStates.Running, CancellationToken.None);
        
        // 額外等待 SQL Server 完全準備就緒
        await Task.Delay(TimeSpan.FromSeconds(5));

        var connectionString = await _app.GetConnectionStringAsync("bookstore-db");

        var options = new DbContextOptionsBuilder<BookStoreDbContext>()
            .UseSqlServer(connectionString, options => options.EnableRetryOnFailure())
            .Options;

        var context = new BookStoreDbContext(options);
        
        // 確保資料庫存在
        await context.Database.EnsureCreatedAsync();
        
        return context;
    }

    /// <summary>
    /// 取得資料庫內容(同步版本)
    /// </summary>
    public BookStoreDbContext GetDbContext()
    {
        return GetDbContextAsync().GetAwaiter().GetResult();
    }

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

實作過程中的常見問題與解決方案

在實際建立 .NET Aspire Testing 環境時,我們遇到了幾個關鍵問題,這些經驗對於初次使用者很有參考價值:

問題 1:最初測試失敗 - 未正確等待 SQL Server 容器啟動

遇到的問題
一開始直接使用 await _app.GetConnectionStringAsync("bookstore-db") 取得連線字串,但測試經常失敗,出現無法連接到資料庫的錯誤。

原因分析
雖然 .NET Aspire Testing 會啟動 SQL Server 容器,但容器啟動和 SQL Server 服務完全準備就緒是兩個不同的階段。直接取得連線字串並不保證 SQL Server 已經準備好接受連線。

解決方案

  1. 使用 ResourceNotificationService 等待資源就緒
  2. 加入額外的延遲確保 SQL Server 完全初始化
// 錯誤的做法 - 沒有等待資源就緒
public async Task<BookStoreDbContext> GetDbContextAsync()
{
    var connectionString = await _app.GetConnectionStringAsync("bookstore-db");
    // 這裡可能會失敗,因為 SQL Server 還沒完全啟動
}

// 正確的做法 - 等待資源完全就緒
public async Task<BookStoreDbContext> GetDbContextAsync()
{
    // 等待 SQL Server 容器啟動並取得連接字串
    var sqlResource = _app.Services.GetRequiredService<ResourceNotificationService>();
    await sqlResource.WaitForResourceAsync("bookstore-db", KnownResourceStates.Running, CancellationToken.None);
    
    // 額外等待 SQL Server 完全準備就緒
    await Task.Delay(TimeSpan.FromSeconds(5));
    
    var connectionString = await _app.GetConnectionStringAsync("bookstore-db");
    // 現在可以安全地使用連線字串
}

問題 2:缺少必要的命名空間引用

遇到的問題
編譯時出現找不到 ResourceNotificationServiceKnownResourceStates 的錯誤。

解決方案
GlobalUsings.cs 中補充完整的 Aspire 相關命名空間:

global using Aspire.Hosting;
global using Aspire.Hosting.Testing;
global using Aspire.Hosting.ApplicationModel;  // 提供 ResourceNotificationService 和 KnownResourceStates

問題 3:測試執行時間過長

遇到的問題
每次測試都需要 30-40 秒才能完成,主要時間花在容器啟動上。

優化策略

  1. 使用 ICollectionFixture 在測試間共享 AspireAppFixture
  2. 避免在每個測試方法中重新建立 DbContext
  3. 考慮使用測試資料庫的快照機制

重要!Collection Fixture 解決容器重複啟動問題

如果每個測試類別都使用 IClassFixture<AspireAppFixture>,會導致每個測試類別啟動一個獨立的 SQL Server 容器。當你有多個測試類別時,這會造成:

  • 系統資源大量消耗
  • 測試執行時間大幅延長
  • 容器啟動競爭導致測試不穩定

解決方案:使用 Collection Fixture

// 1. 建立測試集合定義
[CollectionDefinition("AspireApp")]
public class AspireAppCollectionDefinition : ICollectionFixture<AspireAppFixture>
{
    // 空的類別,僅用於定義測試集合
}

// 2. 測試類別使用 Collection 屬性
[Collection("AspireApp")]
public class BookStoreDbTests
{
    private readonly AspireAppFixture _fixture;

    public BookStoreDbTests(AspireAppFixture fixture)
    {
        _fixture = fixture;
    }
    
    // 測試方法...
}

這樣所有標記為 [Collection("AspireApp")] 的測試類別都會共享同一個 AspireAppFixture 實例,只啟動一個 SQL Server 容器。

實務建議

推薦做法

  • 總是使用 ResourceNotificationService 等待資源就緒
  • 為容器啟動預留足夠的時間緩衝
  • 使用適當的 Fixture 模式減少重複啟動成本
  • 在 CI/CD 環境中設定合理的測試逾時時間

避免的陷阱

  • 不要假設容器啟動就等於服務就緒
  • 不要在每個測試中重新建立完整的 Aspire 應用
  • 不要忽視測試環境的資源消耗

測試配置管理技巧

環境變數覆寫

在測試中,我們經常需要覆寫某些配置:

var appHost = await DistributedApplicationTestingBuilder
    .CreateAsync<Projects.BookStore_AppHost>(
        configureBuilder: (appOptions, hostSettings) =>
        {
            // 覆寫日誌等級
            appOptions.WithEnvironment("Logging__LogLevel__Default", "Warning");
            
            // 停用遙測
            appOptions.WithEnvironment("OTEL_SDK_DISABLED", "true");
            
            // 建置測試專用的連接逾時
            appOptions.WithEnvironment("ConnectionStrings__Timeout", "30");
        });

測試資料初始化策略

建立一個統一的測試資料管理類別:

public static class TestDataSeeder
{
    public static async Task SeedBasicDataAsync(BookStoreDbContext context)
    {
        if (await context.Books.AnyAsync())
            return; // 資料已存在,跳過初始化
            
        var books = new[]
        {
            new Book { Title = "C# 程式設計", Author = "張三", Price = 450.00m },
            new Book { Title = ".NET Core 實戰", Author = "李四", Price = 520.00m },
            new Book { Title = "ASP.NET Core 開發", Author = "王五", Price = 480.00m }
        };
        
        context.Books.AddRange(books);
        await context.SaveChangesAsync();
    }
    
    public static async Task CleanupDataAsync(BookStoreDbContext context)
    {
        // 清理測試資料,但保留必要的參考資料
        context.Books.RemoveRange(context.Books.Where(b => b.Author.StartsWith("測試")));
        await context.SaveChangesAsync();
    }
}

測試輔助類別設計

為了讓測試程式碼更清晰和可重複使用,我們建立幾個輔助類別。這些類別解決了實際測試中常見的問題:

  1. SQL 查詢結果對應問題:Entity Framework Core 的 SqlQueryRaw 方法需要明確的型別對應
  2. 測試資料管理:統一的資料建立和清理機制
  3. 敏感資訊處理:在錯誤訊息中遮蔽連線字串的敏感部分

QueryResult.cs - SQL 查詢結果對應

當我們需要執行原生 SQL 查詢時,Entity Framework Core 要求有明確的型別來接收結果。這些簡單的 DTO 類別專門用於此目的:

namespace BookStore.Tests.Helpers;

/// <summary>
/// 用於接收 SQL 查詢結果的簡單 DTO
/// </summary>
public class QueryResult
{
    public int Value { get; set; }
}

/// <summary>
/// 用於接收日期時間查詢結果的 DTO
/// </summary>
public class DateTimeQueryResult
{
    public DateTime Value { get; set; }
}

DatabaseTestHelper.cs - 資料庫測試輔助方法

這個靜態類別提供了測試中常用的資料庫操作方法,快速建立測試資料、清理測試痕跡,以及處理安全性相關資訊:

namespace BookStore.Tests.Helpers;

/// <summary>
/// 資料庫測試輔助類別
/// </summary>
public static class DatabaseTestHelper
{
    /// <summary>
    /// 建立測試書籍
    /// </summary>
    public static async Task<Book> CreateTestBookAsync(BookStoreDbContext context,
        string title = "測試書籍",
        string author = "測試作者",
        decimal price = 100m)
    {
        var book = new Book { Title = title, Author = author, Price = price };
        context.Books.Add(book);
        await context.SaveChangesAsync();
        return book;
    }

    /// <summary>
    /// 清理測試資料
    /// </summary>
    public static async Task CleanupTestDataAsync(BookStoreDbContext context)
    {
        // 只清理測試產生的資料
        var testBooks = context.Books.Where(b =>
            b.Title.StartsWith("測試") || b.Author.StartsWith("測試"));
        context.Books.RemoveRange(testBooks);
        await context.SaveChangesAsync();
    }

    /// <summary>
    /// 遮蔽敏感資訊
    /// </summary>
    public static string MaskSensitiveInfo(string connectionString)
    {
        if (string.IsNullOrEmpty(connectionString))
            return string.Empty;

        // 遮蔽敏感資訊,只保留診斷需要的部分
        return connectionString.Split(';')
            .Where(part => !part.Contains("Password", StringComparison.OrdinalIgnoreCase))
            .Where(part => !part.Contains("User Id", StringComparison.OrdinalIgnoreCase))
            .Aggregate((a, b) => $"{a};{b}");
    }
}

整合測試實作

依據我們建立的 Repository 和 Service 架構,我們將實作三種不同層級測試,每種測試都有其特定目標和價值。

Repository 層整合測試

Repository 層測試專注於驗證資料存取邏輯正確性,確保我們的抽象層能夠正確與 Entity Framework Core 和 SQL Server 協作:

/// <summary>
/// EfCoreBookRepository 整合測試
/// </summary>
[Collection("AspireApp")]
public class EfCoreBookRepositoryTests
{
    private readonly AspireAppFixture _fixture;

    public EfCoreBookRepositoryTests(AspireAppFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task AddAsync_有效書籍_應成功儲存並回傳含ID的書籍()
    {
        // Arrange
        using var dbContext = _fixture.GetDbContext();
        var repository = new EfCoreBookRepository(dbContext);
        var book = new Book 
        { 
            Title = "新書測試", 
            Author = "測試作者", 
            Price = 350m 
        };

        // Act
        var savedBook = await repository.AddAsync(book);

        // Assert
        savedBook.Should().NotBeNull();
        savedBook.Id.Should().BeGreaterThan(0, "SQL Server 應該自動產生 ID");
        savedBook.Title.Should().Be("新書測試");
        savedBook.CreatedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
    }

    [Fact]
    public async Task GetBooksByAuthorAsync_指定作者_應回傳該作者所有書籍()
    {
        // Arrange
        using var dbContext = _fixture.GetDbContext();
        var repository = new EfCoreBookRepository(dbContext);
        
        await repository.AddAsync(new Book { Title = "書籍1", Author = "張三", Price = 200m });
        await repository.AddAsync(new Book { Title = "書籍2", Author = "張三豐", Price = 200m });
        await repository.AddAsync(new Book { Title = "書籍3", Author = "李四", Price = 300m });

        // Act
        var authorBooks = await repository.GetBooksByAuthorAsync("張三");

        // Assert
        authorBooks.Should().HaveCount(2, "應該回傳張三和張三豐的書籍");
        authorBooks.Should().OnlyContain(b => b.Author.Contains("張三"));
        authorBooks.Should().Contain(b => b.Title == "書籍1");
        authorBooks.Should().Contain(b => b.Title == "書籍2");
        authorBooks.Should().NotContain(b => b.Author == "李四");
    }

    [Fact]
    public async Task GetExpensiveBooksAsync_指定最低價格_應回傳高價書籍並按價格降冪排序()
    {
        // Arrange
        await _fixture.CleanDatabaseAsync(); // 清理資料庫
        
        using var dbContext = _fixture.GetDbContext();
        var repository = new EfCoreBookRepository(dbContext);
        
        await repository.AddAsync(new Book { Title = "便宜書", Author = "作者", Price = 50m });
        await repository.AddAsync(new Book { Title = "中價書", Author = "作者", Price = 300m });
        await repository.AddAsync(new Book { Title = "高價書", Author = "作者", Price = 500m });

        // Act
        var expensiveBooks = await repository.GetExpensiveBooksAsync(200m);

        // Assert
        expensiveBooks.Should().HaveCount(2);
        expensiveBooks.Should().BeInDescendingOrder(b => b.Price);
        expensiveBooks.First().Title.Should().Be("高價書");
        expensiveBooks.Last().Title.Should().Be("中價書");
    }
}

Service 層業務邏輯測試

Service 層測試專注於驗證業務邏輯和商業規則,這是我們應用程式的核心價值:

/// <summary>
/// BookService 業務邏輯測試
/// </summary>
[Collection("AspireApp")]
public class BookServiceTests
{
    private readonly AspireAppFixture _fixture;

    public BookServiceTests(AspireAppFixture fixture)
    {
        _fixture = fixture;
    }

    private BookService CreateBookService()
    {
        var dbContext = _fixture.GetDbContext();
        var repository = new EfCoreBookRepository(dbContext);
        return new BookService(repository);
    }

    [Fact]
    public async Task CreateBookAsync_有效資料_應建立書籍並設定建立時間()
    {
        // Arrange
        var bookService = CreateBookService();
        var title = "新書測試";
        var author = "測試作者";
        var price = 350m;

        // Act
        var createdBook = await bookService.CreateBookAsync(title, author, price);

        // Assert
        createdBook.Should().NotBeNull();
        createdBook.Id.Should().BeGreaterThan(0);
        createdBook.Title.Should().Be(title);
        createdBook.Author.Should().Be(author);
        createdBook.Price.Should().Be(price);
        createdBook.CreatedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
        createdBook.PublishedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
    }

    [Fact]
    public async Task CreateBookAsync_負數價格_應拋出ArgumentException()
    {
        // Arrange
        var bookService = CreateBookService();

        // Act & Assert
        var exception = await Assert.ThrowsAsync<ArgumentException>(
            () => bookService.CreateBookAsync("測試書", "作者", -100m));
        
        exception.Message.Should().Contain("價格必須大於 0");
        exception.ParamName.Should().Be("price");
    }

    [Fact]
    public async Task GetRecommendedBooksAsync_依業務邏輯_應回傳合理價格範圍的書籍()
    {
        // Arrange
        await _fixture.CleanDatabaseAsync(); // 清理資料庫
        
        var bookService = CreateBookService();
        await bookService.CreateBookAsync("便宜書", "作者", 50m);    // 低於推薦範圍
        await bookService.CreateBookAsync("推薦書1", "作者", 200m);  // 在推薦範圍內
        await bookService.CreateBookAsync("推薦書2", "作者", 500m);  // 在推薦範圍內
        await bookService.CreateBookAsync("昂貴書", "作者", 1500m); // 超過推薦範圍

        // Act
        var recommendedBooks = await bookService.GetRecommendedBooksAsync();

        // Assert
        recommendedBooks.Should().HaveCount(2);
        recommendedBooks.Should().OnlyContain(b => b.Price >= 100m && b.Price <= 800m);
        recommendedBooks.Should().Contain(b => b.Title == "推薦書1");
        recommendedBooks.Should().Contain(b => b.Title == "推薦書2");
        recommendedBooks.Should().NotContain(b => b.Title == "便宜書");
        recommendedBooks.Should().NotContain(b => b.Title == "昂貴書");
    }

    [Fact]
    public async Task UpdateBookPriceAsync_有效價格_應更新價格並設定更新時間()
    {
        // Arrange
        var bookService = CreateBookService();
        var createdBook = await bookService.CreateBookAsync("測試書", "作者", 100m);
        var newPrice = 150m;

        // Act
        var updatedBook = await bookService.UpdateBookPriceAsync(createdBook.Id, newPrice);

        // Assert
        updatedBook.Should().NotBeNull();
        updatedBook.Id.Should().Be(createdBook.Id);
        updatedBook.Price.Should().Be(newPrice);
        updatedBook.UpdatedDate.Should().NotBeNull();
        updatedBook.UpdatedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
        
        // 其他欄位應保持不變
        updatedBook.Title.Should().Be("測試書");
        updatedBook.Author.Should().Be("作者");
    }
}

資料庫功能測試

這類測試驗證 .NET Aspire Testing 環境本身的功能,以及一些只有真實 SQL Server 才提供的特性:

/// <summary>
/// 資料庫基礎功能測試
/// </summary>
[Collection("AspireApp")]
public class BookStoreDbTests
{
    private readonly AspireAppFixture _fixture;
    
    public BookStoreDbTests(AspireAppFixture fixture)
    {
        _fixture = fixture;
    }
    
    [Fact]
    public async Task HealthCheck_驗證測試環境可用性()
    {
        // Arrange & Act
        using var dbContext = _fixture.GetDbContext();

        // Assert
        var canConnect = await dbContext.Database.CanConnectAsync();
        canConnect.Should().BeTrue("應該能夠連接到測試資料庫");

        // 驗證資料庫版本
        var serverVersion = dbContext.Database.GetDbConnection().ServerVersion;
        serverVersion.Should().NotBeNullOrEmpty("應該能夠取得 SQL Server 版本資訊");

        // 驗證基本查詢功能
        var result = await dbContext.Database
            .SqlQueryRaw<DateTimeQueryResult>("SELECT GETUTCDATE() as Value")
            .FirstAsync();
        
        var timeDifference = Math.Abs((result.Value - DateTime.UtcNow).TotalMinutes);
        timeDifference.Should().BeLessThan(600, "查詢結果應該在合理的時間範圍內");
    }

    [Fact]
    public async Task TransactionHandling_交易回滾_應保持資料一致性()
    {
        // Arrange
        using var dbContext = _fixture.GetDbContextWithoutRetry();
        using var transaction = await dbContext.Database.BeginTransactionAsync();
        
        var initialCount = await dbContext.Books.CountAsync();

        try
        {
            // Act
            var book1 = new Book { Title = "交易測試1", Author = "作者", Price = 100m };
            var book2 = new Book { Title = "交易測試2", Author = "作者", Price = 200m };
            
            dbContext.Books.Add(book1);
            await dbContext.SaveChangesAsync();
            
            dbContext.Books.Add(book2);
            await dbContext.SaveChangesAsync();
            
            // 故意回滾交易
            await transaction.RollbackAsync();
        }
        catch
        {
            await transaction.RollbackAsync();
            throw;
        }

        // Assert
        var finalCount = await dbContext.Books.CountAsync();
        finalCount.Should().Be(initialCount, "交易回滾後資料應回到原始狀態");
    }
}

測試對象的重要性說明

注意到這些測試的重點差異:

錯誤的測試方式(測試第三方框架)

[Fact]
public async Task BadExample_測試EFCore功能_沒有測試價值()
{
    // Arrange
    using var dbContext = _fixture.GetDbContext();
    var book = new Book { Title = "測試", Author = "作者", Price = 100m };
    
    // Act - 這是在測試 EF Core 的功能,不是我們的程式碼
    dbContext.Books.Add(book);
    var result = await dbContext.SaveChangesAsync();
    
    // Assert - 驗證 EF Core 是否正常工作(沒有意義)
    result.Should().Be(1);
    book.Id.Should().BeGreaterThan(0);
}

正確的測試方式(測試業務邏輯)

[Fact]
public async Task GoodExample_測試業務邏輯_有實際價值()
{
    // Arrange
    var bookService = CreateBookService();
    
    // Act - 測試我們的業務邏輯:價格驗證
    var exception = await Assert.ThrowsAsync<ArgumentException>(
        () => bookService.CreateBookAsync("測試書", "作者", -100m));
    
    // Assert - 驗證我們的商業規則是否正確執行
    exception.Message.Should().Contain("價格必須大於 0");
}

透過這種方式,我們確保測試重點在於驗證我們自己的程式碼,而不是第三方框架功能。

測試最佳實踐與注意事項

效能優化策略

1. 共享測試基礎設施

.NET Aspire Testing 環境的啟動需要時間,建議在測試類別間共享 Fixture:

// 使用 ICollectionFixture 在多個測試類別間共享
[CollectionDefinition("AspireApp")]
public class AspireAppCollectionDefinition : ICollectionFixture<AspireAppFixture>
{
    // 空的類別,僅用於定義測試集合
}

[Collection("AspireApp")]
public class BookServiceTests
{
    // 測試方法...
}

[Collection("AspireApp")]
public class EfCoreBookRepositoryTests
{
    // 測試方法...
}

2. 測試資料管理策略

根據新的架構設計,建立適當的測試資料管理:

public static class TestDataFactory
{
    /// <summary>
    /// 建立測試用的 BookService
    /// </summary>
    public static BookService CreateBookService(BookStoreDbContext context)
    {
        var repository = new EfCoreBookRepository(context);
        return new BookService(repository);
    }
    
    /// <summary>
    /// 建立測試用的書籍資料
    /// </summary>
    public static Book CreateTestBook(string title = "測試書籍", 
        string author = "測試作者", 
        decimal price = 100m)
    {
        return new Book 
        { 
            Title = title, 
            Author = author, 
            Price = price,
            PublishedDate = DateTime.UtcNow,
            CreatedDate = DateTime.UtcNow
        };
    }
}

測試分層策略

根據我們的架構設計,建議採用以下測試分層:

Repository 層測試重點

  • 資料存取邏輯:驗證 CRUD 操作的正確性
  • 查詢效能:確保複雜查詢能正確執行
  • 資料庫約束:測試外鍵、唯一約束等資料庫層面的規則
[Fact]
public async Task Repository_複雜查詢_應正確執行()
{
    // 專注於測試資料存取邏輯
    var repository = new EfCoreBookRepository(_fixture.GetDbContext());
    var result = await repository.GetExpensiveBooksAsync(500m);
    // 驗證查詢邏輯...
}

Service 層測試重點

  • 業務邏輯:驗證商業規則的實作
  • 錯誤處理:確保異常情況得到正確處理
  • 邊界條件:測試輸入驗證和邊界值
[Fact]
public async Task Service_業務規則_應正確驗證()
{
    // 專注於測試業務邏輯
    var service = TestDataFactory.CreateBookService(_fixture.GetDbContext());
    
    // 測試價格驗證業務規則
    await Assert.ThrowsAsync<ArgumentException>(
        () => service.CreateBookAsync("書名", "作者", -100m));
}

測試隔離與清理

資料庫清理策略

使用 AspireAppFixture 提供的清理方法:

[Fact]
public async Task CleanIsolatedTest_使用資料庫清理()
{
    // Arrange
    await _fixture.CleanDatabaseAsync(); // 確保乾淨的測試環境
    var service = TestDataFactory.CreateBookService(_fixture.GetDbContext());
    
    // Act & Assert
    var book = await service.CreateBookAsync("隔離測試", "作者", 100m);
    book.Should().NotBeNull();
    
    // 測試結束後,其他測試仍能使用乾淨的環境
}

交易隔離策略

對於需要完全隔離的測試場景:

[Fact]
public async Task TransactionIsolatedTest_使用交易隔離()
{
    // Arrange
    using var dbContext = await _fixture.GetDbContextWithoutRetryAsync();
    using var transaction = await dbContext.Database.BeginTransactionAsync();
    
    try
    {
        var service = TestDataFactory.CreateBookService(dbContext);
        
        // Act & Assert
        var book = await service.CreateBookAsync("交易測試", "作者", 100m);
        book.Should().NotBeNull();
        
        // 測試結束後自動回滾,不影響其他測試
    }
    finally
    {
        await transaction.RollbackAsync();
    }
}

錯誤處理與診斷

詳細的錯誤訊息

提供清楚的錯誤訊息協助問題診斷:

[Fact]
public async Task ErrorHandling_業務邏輯例外_應提供清楚訊息()
{
    // Arrange
    var service = TestDataFactory.CreateBookService(_fixture.GetDbContext());

    // Act & Assert
    var exception = await Assert.ThrowsAsync<ArgumentException>(
        () => service.CreateBookAsync("測試書", "作者", -100m));

    // 驗證例外訊息的完整性
    exception.Message.Should().Contain("價格必須大於 0");
    exception.ParamName.Should().Be("price");
    
    // 提供診斷資訊
    System.Diagnostics.Debug.WriteLine($"正確捕獲業務規則例外: {exception.Message}");
}

連接測試與健康檢查

定期驗證測試基礎設施的健康狀態:

[Fact]
public async Task HealthCheck_測試基礎設施_應正常運作()
{
    // Arrange & Act
    using var dbContext = _fixture.GetDbContext();

    // Assert - 基本連接測試
    var canConnect = await dbContext.Database.CanConnectAsync();
    canConnect.Should().BeTrue("應該能夠連接到測試資料庫");

    // 驗證 Repository 和 Service 層可正常建立
    var repository = new EfCoreBookRepository(dbContext);
    var service = new BookService(repository);
    
    repository.Should().NotBeNull("Repository 應該能正常建立");
    service.Should().NotBeNull("Service 應該能正常建立");
}

架構一致性檢查

確保測試架構與實際應用架構保持一致:

[Fact]
public async Task Architecture_依賴注入_應正確設定()
{
    // Arrange
    using var dbContext = _fixture.GetDbContext();
    
    // Act - 驗證依賴注入鏈的正確性
    var repository = new EfCoreBookRepository(dbContext);
    var service = new BookService(repository);
    
    // Assert - 確保各層能正確協作
    var testBook = await service.CreateBookAsync("架構測試", "測試作者", 200m);
    testBook.Should().NotBeNull();
    
    // 驗證資料確實通過完整的架構層級儲存
    var retrievedBook = await service.GetBookAsync(testBook.Id);
    retrievedBook.Should().NotBeNull();
    retrievedBook!.Title.Should().Be("架構測試");
}

與 Testcontainers 的選擇考量

根據不同的專案需求,選擇適合的測試框架:

選擇 .NET Aspire Testing 的情況

  • 已使用 .NET Aspire:專案本身就是用 Aspire 開發
  • 複雜的服務架構:需要測試多個相互依賴的服務
  • 簡化配置:希望重用 AppHost 的配置
  • 統一的開發體驗:開發和測試使用相同的工具鏈

選擇 Testcontainers 的情況

  • 傳統 .NET 專案:不使用 Aspire 的現有專案
  • 精細控制:需要對容器進行詳細配置
  • 特定容器需求:需要非標準的容器配置
  • 輕量級測試:只需要單一服務的容器化測試

實際選擇範例

// .NET Aspire Testing - 適合已有 AppHost 的專案
public class AspireBasedTest : IClassFixture<AspireAppFixture>
{
    [Fact]
    public async Task ComplexScenario_使用完整服務架構()
    {
        // 自動取得 AppHost 中定義的所有服務
        var dbContext = _fixture.GetDbContext();
        var httpClient = _fixture.CreateHttpClient("api");
        var redisConnection = _fixture.GetRedisConnection();
        
        // 測試完整的服務互動...
    }
}

// Testcontainers 測試 - 適合傳統專案
public class TestcontainersBasedTest : IAsyncLifetime
{
    private PostgreSqlContainer _postgres;
    
    public async Task InitializeAsync()
    {
        _postgres = new PostgreSqlBuilder()
            .WithImage("postgres:15-alpine")
            .WithDatabase("testdb")
            .Build();
            
        await _postgres.StartAsync();
    }
    
    [Fact]
    public async Task SimpleScenario_專注於資料庫測試()
    {
        // 只測試資料庫相關功能
        var connectionString = _postgres.GetConnectionString();
        // 進行資料庫測試...
    }
}

延伸思考

1. 技術選型實戰建議

選擇 .NET Aspire Testing 時,這些實際考量比較重要:

技術組合一致性
如果開發環境已經用 .NET Aspire,測試環境跟著用可以:

  • 減少學習成本
  • 共用配置檔案
  • 統一容器管理

維護成本評估
.NET Aspire 更新很快,要考慮:

  • 版本追蹤的人力成本
  • 團隊學習新技術的時間
  • 與 Testcontainers 等成熟方案的穩定性比較

2. 測試策略實務經驗

什麼時候該用整合測試

  • Mock 物件寫起來比真實服務還複雜
  • 資料庫邏輯太複雜,In-Memory 模擬不了
  • 跨服務流程必須端到端驗證

測試金字塔的現實考量

容器化確實降低了整合測試成本,但要平衡:

  • 執行時間:CI/CD 管道不能太慢
  • 資源消耗:容器啟動需要記憶體
  • 維護複雜度:測試失敗時的除錯難度

3. 實務踩雷分享

常見問題

  • 容器啟動時間不一致,測試會隨機失敗
  • 測試環境資源不足,多個測試並行會出問題
  • 過度依賴整合測試,忽略基本單元測試

解決方案

  • 用 Collection Fixture 減少容器重複啟動
  • 設定合理的測試逾時時間
  • 保持測試金字塔平衡,不要頭重腳輕

4. 採用建議

適合使用的情況

  • 專案已經用 .NET Aspire
  • 需要測試複雜的服務互動
  • 團隊有足夠時間學習新工具

不建議使用的情況

  • 簡單的 Web API + 資料庫架構
  • 團隊對容器化測試經驗不足
  • CI/CD 環境資源有限

參考資料

.NET Aspire

.NET Aspire Testing

範例程式碼:


這是「重啟挑戰:老派軟體工程師的測試修練」的第二十四天。明天會介紹 Day 25 – 實作 .NET Aspire Testing 整合測試案例。


上一篇
Day 23 – 整合測試實戰:WebApi 服務的整合測試
系列文
重啟挑戰:老派軟體工程師的測試修練24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言