在現代雲原生應用開發中,我們經常面臨一個棘手的問題:如何有效測試複雜的分散式應用?
傳統單元測試執行快速,但無法涵蓋服務間的互動。端對端測試覆蓋面廣,但配置複雜且執行緩慢。當應用程式需要連接 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。
.NET Aspire 框架是建立在 .NET 8 之上的,所以這是最基本也最重要的一步。
下載與安裝:前往 dotnet.microsoft.com/download/dotnet/8.0,下載並安裝 .NET 8.0 SDK。
驗證:安裝完成後,開啟命令提示字元(CMD)或 PowerShell,輸入 dotnet --info
。如果看到 SDKs: 8.0.x 相關資訊,就表示安裝成功。
.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 是微軟為分散式應用量身打造的測試解決方案。核心理念是:在測試中重現與正式環境相同的服務架構。
傳統測試方法往往需要 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 定位是封閉式整合測試。它讓我們能夠在可控環境中測試真實的服務互動,而不需要依賴外部測試環境。
既然提到了容器化測試,你可能會想:「我們之前不是學過 Testcontainers 嗎?為什麼還需要 .NET Aspire Testing?」
兩者確實有很多相似之處:
但是它們的設計理念和適用場景有所不同:
簡單來說:
Testcontainers 和 .NET Aspire 都是針對 .NET 開發人員的優秀工具,但它們的用途不同。Testcontainers 主要用於測試,而 .NET Aspire 則用於開發和協調雲端原生應用程式。
.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");
主要提供的功能包括:
在專案檔中需要引用以下套件:
<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
檔案來簡化 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 層來分離關注點,確保我們測試的是業務邏輯而不是第三方框架的功能。
直接在測試中使用 Entity Framework Core 的 DbContext 會遇到幾個問題:
透過引入 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 # 測試基礎設施
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);
}
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);
}
這樣的設計讓我們可以針對不同層級進行適當的測試:
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 環境時,我們遇到了幾個關鍵問題,這些經驗對於初次使用者很有參考價值:
遇到的問題:
一開始直接使用 await _app.GetConnectionStringAsync("bookstore-db")
取得連線字串,但測試經常失敗,出現無法連接到資料庫的錯誤。
原因分析:
雖然 .NET Aspire Testing 會啟動 SQL Server 容器,但容器啟動和 SQL Server 服務完全準備就緒是兩個不同的階段。直接取得連線字串並不保證 SQL Server 已經準備好接受連線。
解決方案:
ResourceNotificationService
等待資源就緒// 錯誤的做法 - 沒有等待資源就緒
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");
// 現在可以安全地使用連線字串
}
遇到的問題:
編譯時出現找不到 ResourceNotificationService
和 KnownResourceStates
的錯誤。
解決方案:
在 GlobalUsings.cs
中補充完整的 Aspire 相關命名空間:
global using Aspire.Hosting;
global using Aspire.Hosting.Testing;
global using Aspire.Hosting.ApplicationModel; // 提供 ResourceNotificationService 和 KnownResourceStates
遇到的問題:
每次測試都需要 30-40 秒才能完成,主要時間花在容器啟動上。
優化策略:
ICollectionFixture
在測試間共享 AspireAppFixture重要!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
等待資源就緒避免的陷阱:
在測試中,我們經常需要覆寫某些配置:
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();
}
}
為了讓測試程式碼更清晰和可重複使用,我們建立幾個輔助類別。這些類別解決了實際測試中常見的問題:
SqlQueryRaw
方法需要明確的型別對應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 層測試專注於驗證資料存取邏輯正確性,確保我們的抽象層能夠正確與 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 層測試專注於驗證業務邏輯和商業規則,這是我們應用程式的核心價值:
/// <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");
}
透過這種方式,我們確保測試重點在於驗證我們自己的程式碼,而不是第三方框架功能。
.NET Aspire Testing 環境的啟動需要時間,建議在測試類別間共享 Fixture:
// 使用 ICollectionFixture 在多個測試類別間共享
[CollectionDefinition("AspireApp")]
public class AspireAppCollectionDefinition : ICollectionFixture<AspireAppFixture>
{
// 空的類別,僅用於定義測試集合
}
[Collection("AspireApp")]
public class BookServiceTests
{
// 測試方法...
}
[Collection("AspireApp")]
public class EfCoreBookRepositoryTests
{
// 測試方法...
}
根據新的架構設計,建立適當的測試資料管理:
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
};
}
}
根據我們的架構設計,建議採用以下測試分層:
[Fact]
public async Task Repository_複雜查詢_應正確執行()
{
// 專注於測試資料存取邏輯
var repository = new EfCoreBookRepository(_fixture.GetDbContext());
var result = await repository.GetExpensiveBooksAsync(500m);
// 驗證查詢邏輯...
}
[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("架構測試");
}
根據不同的專案需求,選擇適合的測試框架:
// .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();
// 進行資料庫測試...
}
}
選擇 .NET Aspire Testing 時,這些實際考量比較重要:
技術組合一致性
如果開發環境已經用 .NET Aspire,測試環境跟著用可以:
維護成本評估
.NET Aspire 更新很快,要考慮:
什麼時候該用整合測試
測試金字塔的現實考量
容器化確實降低了整合測試成本,但要平衡:
常見問題
解決方案
適合使用的情況
不建議使用的情況
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第二十四天。明天會介紹 Day 25 – 實作 .NET Aspire Testing 整合測試案例。