iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Software Development

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

Day 20 – Testcontainers 初探:使用 Docker 架設測試環境

  • 分享至 

  • xImage
  •  

前言

在 ASP.NET Core 中,使用 Entity Framework Core (EF Core) 的 InMemory 資料庫來進行單元測試是一種常見且有效的方法,因為它速度快且不需要實際的資料庫連線。

然而,InMemory 模式並非萬能,它無法模擬真實資料庫的所有行為。了解這些限制對於確保測試的全面性和準確性至關重要。

以下是使用 EF Core InMemory 資料庫與真實資料庫(例如 SQL Server、PostgreSQL 等)進行測試的主要差異與限制:

  1. 交易行為與資料庫鎖定

    • InMemory 不支援資料庫交易(Transactions),這意味著 SaveChanges() 成功後資料會立即儲存,不會像在真實資料庫中那樣,可以將多個操作包裝在一個原子性的交易中,並在發生錯誤時進行 Rollback。因此,涉及複雜交易邏輯的測試無法在 InMemory 模式下進行。
    • 此外,InMemory 也沒有資料庫鎖定(Locking)的機制,無法模擬並發(Concurrency)情境下的行為,例如多人同時修改同一筆資料時可能發生的問題。
  2. LINQ 查詢的差異

    • InMemory 資料庫會直接在記憶體中執行 LINQ 查詢,這與真實資料庫透過 SQL 語法進行查詢有本質上的不同。
    • 查詢翻譯差異:某些 LINQ 查詢,例如複雜的 GroupBy、JOIN、OrderBy 或自訂函數,在 InMemory 中可能可以正常執行,但在轉換成 SQL 時可能會失敗或產生不同的結果。
    • Case Sensitivity:真實資料庫的字串比較行為取決於其校對規則(Collation),有些是區分大小寫的(Case-sensitive),有些則否。而 InMemory 的行為默認為不區分大小寫,這可能導致測試結果與實際運行時的行為不一致。
    • 效能模擬不足:InMemory 測試無法模擬真實資料庫在執行複雜查詢時可能遇到的效能瓶頸或索引(Index)問題。
  3. 資料庫特定行為與功能

    • InMemory 模式無法測試以下依賴於真實資料庫的功能:
      • 預存程序 (Stored Procedures) 與 Triggers:這些是資料庫伺服器上的程式碼,InMemory 無法模擬。
      • Views:類似於預存程序,Views 是資料庫物件,無法在 InMemory 中建立或查詢。
      • 資料庫約束 (Constraints):如外來鍵約束 (Foreign Key Constraints)、檢查約束 (Check Constraints) 或唯一約束 (Unique Constraints),在 InMemory 中雖然可以進行一些基本的檢查,但其行為與真實資料庫的嚴格性仍有差異。例如,刪除父資料時,外來鍵的 Cascade Delete 行為無法完全模擬。
      • 資料類型與精確度:不同的資料庫對於資料類型(如 decimal 的精確度、datetime 的範圍等)有不同的實現,InMemory 傾向於使用 .NET 的標準類型,這可能無法捕捉到真實資料庫中潛在的資料精確度或溢位問題。
      • Concurrency Tokens:如 RowVersion 或 Timestamp,這些是資料庫提供的用於解決並發衝突的機制,InMemory 無法準確模擬其自動更新的行為。

InMemory 資料庫 - 小結
InMemory 資料庫是一個非常適合進行單元測試的工具,特別是在測試 Repository 模式或 Service 層的 CRUD (建立、讀取、更新、刪除) 邏輯時。它可以快速驗證商業邏輯是否正確,而無需依賴外部服務。

然而,如果你的測試需要驗證:

  • 涉及複雜交易的商業邏輯
  • 並發情境下的資料處理
  • 特定於資料庫的效能、查詢翻譯或行為
  • 與預存程序、Triggers 等資料庫物件的互動

那麼,你應該考慮使用整合測試,並連接到一個輕量級的真實資料庫(不要用 SQLite,因為也是無法測試到實際複雜的行為)。這能更準確地模擬應用程式在實際生產環境中的行為,確保測試的全面性與準確性。

什麼是原子性操作?

原子性操作(Atomic Operation)是指一個或一系列的程式碼操作,在執行時要麼全部成功完成,要麼全部不執行,不存在部分完成的狀態。這就好比一個原子是不可再分的,原子性操作也是不可再分的。


基於以上這些 InMemory 資料庫的重大限制,我們需要一個更強大的測試解決方案。今天要介紹的 Testcontainers 正是為了解決這些問題而生。它讓我們在測試中使用真實的 Docker 容器,能夠準確模擬真實資料庫的所有行為,包括交易處理、並發控制、資料庫特定功能等,讓整合測試更接近真實的正式環境。

本篇內容

今天的內容有:

  • 認識 Testcontainers 的概念與優勢:了解容器化測試如何解決傳統測試的限制
  • 掌握基本容器操作與生命週期管理:容器的建立、配置與管理策略
  • 實作基礎資料庫容器測試:PostgreSQL 和 SQL Server 的整合測試操作
  • 學習外部服務的基礎模擬:使用 WireMock 容器模擬 HTTP API 服務與 Redis 快取服務測試

Testcontainers 框架介紹

什麼是 Testcontainers?

Testcontainers 是一個測試函式庫,提供輕量好用的 API 來啟動 Docker 容器,專門用於整合測試。簡單說就是「在測試程式碼中管理 Docker 容器」。

這個概念解決了一個長期困擾開發者的問題:如何在測試中使用真實的外部服務(如資料庫、訊息佇列),而不需要在每台開發機器上手動安裝和設定這些服務。

核心概念很簡單:

// 建立 PostgreSQL 容器
var postgres = new PostgreSqlBuilder()
    .WithImage("postgres:15-alpine")
    .WithDatabase("testdb")
    .WithUsername("test")
    .WithPassword("test")
    .Build();

// 啟動容器
await postgres.StartAsync();

// 使用容器的連線字串進行測試
var connectionString = postgres.GetConnectionString();

// 測試完成後自動清理容器
await postgres.DisposeAsync();

容器化測試的優勢

與傳統的 Mock 物件相比,Testcontainers 提供了以下優勢:

1. 真實環境測試

使用真實的資料庫、訊息佇列等服務,而不是模擬物件。這樣可以測試到實際的 SQL 語法、資料庫限制條件、以及資料存取層的真實行為。

2. 環境一致性

確保測試環境與正式環境使用相同的服務版本。避免因為版本差異導致的測試結果不準確,讓測試更具可信度。

3. 清潔的測試環境

每個測試都有獨立、乾淨的環境,避免測試間的干擾。容器會在測試結束後自動清理,確保下次測試不會受到前一次測試資料的影響。

4. 簡化開發環境設定

開發者不需要在本機安裝各種服務,只需要有 Docker。這大幅降低了新人加入專案的門檻,也避免了因為本機環境差異而導致的測試結果不一致問題。

與傳統 Mock 的差異

讓我們比較使用 Mock 與 Testcontainers 的差異:

使用 NSubstitute Mock

[Fact]
public async Task GetUserAsync_輸入使用者ID1_應回傳對應使用者()
{
    // Arrange
    var mockRepository = Substitute.For<IUserRepository>();
    mockRepository.GetByIdAsync(1).Returns(new User { Id = 1, Name = "Test" });
    
    var userService = new UserService(mockRepository);
    
    // Act
    var user = await userService.GetUserAsync(1);
    
    // Assert
    user.Should().NotBeNull();
    user.Name.Should().Be("Test");
}

使用 Testcontainers

[Fact]
public async Task GetUserAsync_使用真實資料庫_應回傳正確使用者資料()
{
    // Arrange
    await using var postgres = new PostgreSqlBuilder().Build();
    await postgres.StartAsync();
    
    var connectionString = postgres.GetConnectionString();
    var repository = new UserRepository(connectionString);
    var userService = new UserService(repository);
    
    // 先建立測試資料
    await repository.CreateUserAsync(new User { Id = 1, Name = "Test" });
    
    // Act
    var user = await userService.GetUserAsync(1);
    
    // Assert
    user.Should().NotBeNull();
    user.Name.Should().Be("Test");
}

Mock 測試速度快但只測試邏輯,Testcontainers 測試較慢但能測試完整的資料存取流程。

Mock 測試的特點

  • 執行速度快,通常幾毫秒就完成
  • 專注於業務邏輯的測試
  • 不會發現資料存取層的問題
  • 適合單元測試

Testcontainers 測試的特點

  • 執行時間較長,需要啟動容器
  • 能測試完整的資料流程
  • 可以發現 SQL 語法錯誤、資料庫限制等問題
  • 適合整合測試

兩種方法各有優勢,在實際專案中通常會混合使用。

.NET Testcontainers 生態系

.NET 的 Testcontainers 提供完整的生態系:

核心套件

<!-- 基礎 Testcontainers 功能 -->
<PackageReference Include="Testcontainers" Version="3.10.0" />

專用模組套件

<!-- 資料庫 -->
<PackageReference Include="Testcontainers.PostgreSql" Version="3.10.0" />
<PackageReference Include="Testcontainers.MsSql" Version="3.10.0" />
<PackageReference Include="Testcontainers.MongoDb" Version="3.10.0" />

<!-- 快取與訊息佇列 -->
<PackageReference Include="Testcontainers.Redis" Version="3.10.0" />
<PackageReference Include="Testcontainers.RabbitMq" Version="3.10.0" />

支援的容器類型概覽

Testcontainers for .NET 支援廣泛的容器類型,幾乎涵蓋了現代應用程式需要的所有外部服務:

  • 關聯式資料庫:PostgreSQL、SQL Server、MySQL、Oracle
    用於測試資料存取層、Entity Framework Core 整合等

  • NoSQL 資料庫:MongoDB、Cassandra、CouchDB
    適合測試文件儲存、大數據應用的資料存取

  • 快取服務:Redis、Memcached
    測試快取策略、分散式快取的實作

  • 訊息佇列:RabbitMQ、Apache Kafka
    驗證非同步訊息處理、事件驅動架構

  • 搜尋引擎:Elasticsearch、Apache Solr
    測試全文搜尋功能、資料索引邏輯

這樣廣泛的支援讓我們可以針對各種架構進行完整的整合測試。

相關連結:

環境準備

在開始使用 Testcontainers 之前,需要確保開發環境具備完整的容器化測試能力。

系統需求與安裝

Docker Desktop 環境

最低系統需求

  • Windows 10 版本 2004 或更新版本
  • 啟用 WSL 2 功能
  • 8GB RAM(建議 16GB 以上)
  • 64GB 可用磁碟空間

安裝步驟

  1. 啟用 WSL 2

    # 以系統管理員身分執行 PowerShell
    dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
    dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
    
    # 重新啟動電腦後執行
    wsl --set-default-version 2
    
  2. 下載並安裝 Docker Desktop

    • 前往 Docker Desktop 官網
    • 下載並執行安裝程式
    • 安裝時選擇「Use WSL 2 instead of Hyper-V」
  3. Docker Desktop 設定最佳化

    // Settings → Docker Engine
    {
      "builder": {
        "gc": {
          "defaultKeepStorage": "20GB",
          "enabled": true
        }
      }
    }
    

    Resources 設定

    • Memory: 6GB(系統記憶體的 50-75%)
    • CPUs: 4 cores
    • Swap: 2GB
    • Disk image size: 64GB

.NET 開發環境

安裝 .NET 9.0 SDK

# 檢查目前版本
dotnet --version

# 安裝全域工具
dotnet tool install --global dotnet-ef
dotnet tool install --global dotnet-reportgenerator-globaltool

環境驗證與測試

執行以下完整驗證流程確認環境正常:

基礎環境檢查

# 檢查 Docker 是否正常運作
docker --version
docker run --rm hello-world

# 檢查 .NET SDK 版本
dotnet --version
dotnet --info

# 檢查可用的 Docker 映像檔
docker images

資料庫容器測試

# 測試 PostgreSQL 容器
docker run --name test-postgres -e POSTGRES_PASSWORD=password -d -p 5432:5432 postgres:15-alpine
docker logs test-postgres
docker exec -it test-postgres psql -U postgres -c "SELECT version();"
docker stop test-postgres && docker rm test-postgres

# 測試 SQL Server 容器
docker run --name test-sqlserver -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=TestPass123!" -d -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest
docker logs test-sqlserver
docker stop test-sqlserver && docker rm test-sqlserver

外部服務容器測試

# 測試 Redis 容器
docker run --name test-redis -d -p 6379:6379 redis:7-alpine
docker exec -it test-redis redis-cli ping
docker stop test-redis && docker rm test-redis

# 測試 WireMock 容器
docker run --name test-wiremock -d -p 8080:8080 wiremock/wiremock:3.2.0
curl http://localhost:8080/__admin/health
docker stop test-wiremock && docker rm test-wiremock

專案套件準備

建立新的測試專案並安裝必要的 NuGet 套件:

# 建立解決方案和專案
dotnet new sln -n Day20.Testcontainers
dotnet new classlib -n Day20.Testcontainers.Core
dotnet new xunit -n Day20.Testcontainers.Tests

# 加入專案到解決方案
dotnet sln add Day20.Testcontainers.Core
dotnet sln add Day20.Testcontainers.Tests

在測試專案中安裝套件:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <!-- 測試框架 -->
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.9.3" />
    <PackageReference Include="AwesomeAssertions" Version="9.1.0" />
    
    <!-- Testcontainers 核心套件 -->
    <PackageReference Include="Testcontainers" Version="3.10.0" />
    
    <!-- 資料庫容器 -->
    <PackageReference Include="Testcontainers.PostgreSql" Version="3.10.0" />
    <PackageReference Include="Testcontainers.MsSql" Version="3.10.0" />
    
    <!-- Entity Framework -->
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
  </ItemGroup>
</Project>

常見問題處理

Docker 容器啟動失敗

  • 檢查連接埠是否被佔用:netstat -an | findstr :5432
  • 確認 Docker Desktop 正在執行
  • 重新啟動 Docker Desktop 服務

記憶體不足問題

  • 調整 Docker Desktop 記憶體配置
  • 清理未使用的映像檔:docker system prune -a
  • 限制同時執行的容器數量

網路連線問題

  • 檢查企業防火牆設定
  • 確認 Docker Desktop 網路模式設定
  • 測試容器內外網路連通性

進階環境設定

.NET 工具與套件管理

驗證安裝

# 檢查 .NET 版本
dotnet --version

# 列出已安裝的 SDK
dotnet --list-sdks

# 列出已安裝的執行階段
dotnet --list-runtimes

# 檢查 .NET 資訊
dotnet --info

設定全域工具

# 安裝 Entity Framework Core 工具
dotnet tool install --global dotnet-ef

# 安裝測試報告工具
dotnet tool install --global dotnet-reportgenerator-globaltool

# 安裝程式碼覆蓋率工具
dotnet tool install --global dotnet-coverage

# 驗證工具安裝
dotnet tool list --global

NuGet 套件設定

建立 NuGet.Config 檔案來設定套件來源:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="dotnet-tools" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json" />
  </packageSources>
  <packageSourceCredentials />
</configuration>

Docker 環境驗證

建立簡單的驗證腳本 verify-environment.ps1

# 檢查 Docker 狀態
Write-Host "Checking Docker version..." -ForegroundColor Green
docker --version

# 檢查 Docker Compose 版本
Write-Host "Checking Docker Compose version..." -ForegroundColor Green  
docker-compose --version

# 檢查 Docker 服務狀態
Write-Host "Checking Docker service status..." -ForegroundColor Green
docker system info --format "table {{.Name}}\t{{.Status}}"

# 測試容器啟動
Write-Host "Testing container startup..." -ForegroundColor Green
docker run --rm hello-world

# 檢查可用映像檔
Write-Host "Checking available images..." -ForegroundColor Green
docker images

# 檢查執行中的容器
Write-Host "Checking running containers..." -ForegroundColor Green
docker ps

# 檢查 Docker 資源使用情況
Write-Host "Checking Docker resource usage..." -ForegroundColor Green
docker system df

# 環境驗證完成
Write-Host "Environment verification completed!" -ForegroundColor Yellow

注意:上述腳本使用英文註解是為了避免 PowerShell 執行時的字元編碼問題。如果您複製貼上後執行時遇到編碼錯誤,建議將所有中文註解改為英文,或使用範例專案中提供的 verify-environment.ps1 檔案。

執行驗證步驟

  1. 將上述腳本內容複製並儲存為 verify-environment.ps1 檔案
  2. 開啟 PowerShell 並切換到腳本所在目錄
  3. 執行以下指令:
# 方法一:直接執行腳本
.\verify-environment.ps1

# 方法二:如果遇到執行權限問題
powershell -ExecutionPolicy Bypass -File verify-environment.ps1

基本容器操作與 Wait Strategy

容器生命週期管理

Testcontainers 提供直觀的 API 來管理容器的完整生命週期,從建立、啟動到清理。

為什麼使用 IAsyncLifetime?

在 Testcontainers 測試中,我們統一使用 xUnit 的 IAsyncLifetime 介面而不是 IAsyncDisposable,原因如下:

  • 完整的生命週期控制IAsyncLifetime 提供 InitializeAsync()DisposeAsync() 兩個方法,讓我們能清楚分離初始化和清理邏輯
  • xUnit 官方建議:這是 xUnit 測試框架推薦的非同步資源管理模式
  • 測試隔離保證:確保每個測試類別的容器都在測試開始前完全啟動,測試結束後完全清理
  • 避免建構函式阻塞:容器啟動等非同步操作移到 InitializeAsync() 中,避免在建構函式中進行同步等待

基本容器建立模式

public class BasicContainerOperationsTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres;

    public BasicContainerOperationsTests()
    {
        _postgres = new PostgreSqlBuilder()
            .WithImage("postgres:15-alpine")
            .WithDatabase("testdb")
            .WithUsername("testuser")
            .WithPassword("testpass")
            .WithPortBinding(5432, true)  // 自動分配主機埠號
            .Build();
    }

    public async Task InitializeAsync()
    {
        // 啟動容器並等待就緒
        await _postgres.StartAsync();
    }

    [Fact]
    public async Task GetConnectionString_容器啟動後_應提供有效連線字串()
    {
        // Arrange & Act
        var connectionString = _postgres.GetConnectionString();
        var mappedPort = _postgres.GetMappedPublicPort(5432);

        // Assert
        connectionString.Should().NotBeNullOrEmpty();
        connectionString.Should().Contain($"Port={mappedPort}");
        connectionString.Should().Contain("Database=testdb");
        connectionString.Should().Contain("Username=testuser");
    }

    public async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
    }
}

Wait Strategy 最佳實務

Wait Strategy 確保容器完全啟動後才執行測試,這是穩定測試的關鍵。

內建 Wait Strategy

// 等待特定埠號可用
var postgres = new PostgreSqlBuilder()
    .WithImage("postgres:15-alpine")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilPortIsAvailable(5432))
    .Build();

// 等待 HTTP 端點回應
var webApi = new ContainerBuilder()
    .WithImage("nginx:alpine")
    .WithPortBinding(80, true)
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilHttpRequestIsSucceeded(r => r.ForPort(80).ForPath("/")))
    .Build();

// 等待日誌訊息出現
var redis = new ContainerBuilder()
    .WithImage("redis:7-alpine")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilMessageIsLogged("Ready to accept connections"))
    .Build();

複合 Wait Strategy

var sqlServer = new MsSqlBuilder()
    .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
    .WithPassword("YourStrong@Passw0rd")
    .WithEnvironment("ACCEPT_EULA", "Y")
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilPortIsAvailable(1433)
        .UntilMessageIsLogged("SQL Server is now ready for client connections"))
    .Build();

自訂 Wait Strategy

public class DatabaseReadyWaitStrategy : IWaitUntil
{
    private readonly string _connectionString;

    public DatabaseReadyWaitStrategy(string connectionString)
    {
        _connectionString = connectionString;
    }

    public async Task<bool> UntilAsync(IContainer container)
    {
        try
        {
            await using var connection = new NpgsqlConnection(_connectionString);
            await connection.OpenAsync();
            await using var command = connection.CreateCommand();
            command.CommandText = "SELECT 1";
            await command.ExecuteScalarAsync();
            return true;
        }
        catch
        {
            return false;
        }
    }
}

// 使用自訂 Wait Strategy
var postgres = new PostgreSqlBuilder()
    .WithImage("postgres:15-alpine")
    .WithWaitStrategy(new DatabaseReadyWaitStrategy(_postgres.GetConnectionString()))
    .Build();

容器網路與連接埠管理

動態埠號分配

[Fact]
public async Task GetMappedPublicPort_使用隨機埠號_應回傳不同於預設埠號()
{
    // Arrange
    var redis = new ContainerBuilder()
        .WithImage("redis:7-alpine")
        .WithPortBinding(6379, true)  // 使用隨機埠號
        .Build();

    // Act
    await redis.StartAsync();
    var mappedPort = redis.GetMappedPublicPort(6379);

    // Assert
    mappedPort.Should().BeGreaterThan(1024);
    mappedPort.Should().NotBe(6379);  // 應該不是預設埠號
}

固定埠號分配(測試環境)

[Fact]
public async Task GetMappedPublicPort_使用固定埠號映射_應回傳指定埠號()
{
    // Arrange
    var postgres = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .WithPortBinding(15432, 5432)  // 固定映射到 15432
        .Build();

    // Act
    await postgres.StartAsync();
    var mappedPort = postgres.GetMappedPublicPort(5432);

    // Assert
    mappedPort.Should().Be(15432);
}

容器資源限制與效能調整

var postgres = new PostgreSqlBuilder()
    .WithImage("postgres:15-alpine")
    .WithResourceLimitations(ResourceLimitations.Builder()
        .WithMemory(512 * 1024 * 1024)  // 限制記憶體為 512MB
        .WithCpuCount(1)                 // 限制 CPU 核心數
        .Build())
    .WithTmpfsMount("/var/lib/postgresql/data")  // 使用記憶體檔案系統
    .Build();

容器日誌管理

[Fact]
public async Task GetLogsAsync_容器啟動後_應包含資料庫準備完成訊息()
{
    // Arrange
    var postgres = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .Build();

    // Act
    await postgres.StartAsync();
    
    // 取得容器日誌
    var logs = await postgres.GetLogsAsync();

    // Assert
    logs.Stdout.Should().Contain("database system is ready to accept connections");
    logs.Stderr.Should().BeEmpty();
}

這些基本操作為使用 Testcontainers 建立了堅實的基礎,確保容器能夠可靠地啟動和運作。

資料庫整合測試

在真實的應用程式中,資料庫是核心依賴,Testcontainers 讓我們能夠使用真實的資料庫進行測試。

PostgreSQL 容器測試

PostgreSQL 是目前最受歡迎的開源關聯式資料庫之一。

基本 PostgreSQL 測試設定

using Testcontainers.PostgreSql;
using Microsoft.EntityFrameworkCore;
using AwesomeAssertions;

public class UserServicePostgreSqlTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres;
    private UserDbContext _dbContext = null!;
    private SqlUserService _userService = null!;

    public UserServicePostgreSqlTests()
    {
        _postgres = new PostgreSqlBuilder()
            .WithImage("postgres:15-alpine")
            .WithDatabase("testdb")
            .WithUsername("testuser")
            .WithPassword("testpass")
            .WithPortBinding(54321, true)
            .Build();
    }

    public async Task InitializeAsync()
    {
        // 啟動容器
        await _postgres.StartAsync();

        // 設定 DbContext
        var options = new DbContextOptionsBuilder<UserDbContext>()
            .UseNpgsql(_postgres.GetConnectionString())
            .Options;

        _dbContext = new UserDbContext(options);
        await _dbContext.Database.EnsureCreatedAsync();

        _userService = new SqlUserService(_dbContext);
    }

    public async Task DisposeAsync()
    {
        await _dbContext.DisposeAsync();
        await _postgres.DisposeAsync();
    }

    [Fact]
    public async Task CreateUserAsync_輸入有效使用者資料_應成功建立使用者()
    {
        // Arrange
        var request = new UserCreateRequest
        {
            Username = "testuser_postgres",
            FullName = "Test User_postgres",
            Email = "test_postgres@example.com",
            Age = 25
        };

        // Act
        var result = await _userService.CreateUserAsync(request);

        // Assert
        result.Should().NotBeNull();
        result.Id.Should().NotBeNullOrEmpty();
        result.Username.Should().Be(request.Username);
        result.FullName.Should().Be(request.FullName);
        result.Email.Should().Be(request.Email);
        result.Age.Should().Be(request.Age);
    }

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

SQL Server 容器測試

SQL Server 是 Microsoft 的企業級關聯式資料庫,廣泛用於企業環境。

SQL Server 容器設定與測試

using Testcontainers.MsSql;
using Microsoft.EntityFrameworkCore;
using AwesomeAssertions;

public class UserServiceSqlServerTests : IAsyncLifetime
{
    private readonly MsSqlContainer _container;
    private UserDbContext _dbContext = null!;
    private SqlUserService _userService = null!;

    public UserServiceSqlServerTests()
    {
        _container = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .WithPassword("TestPass123!")
            .WithPortBinding(15433, true)
            .Build();
    }

    public async Task InitializeAsync()
    {
        // 啟動容器
        await _container.StartAsync();

        // 設定 DbContext
        var options = new DbContextOptionsBuilder<UserDbContext>()
            .UseSqlServer(_container.GetConnectionString())
            .Options;

        _dbContext = new UserDbContext(options);
        await _dbContext.Database.EnsureCreatedAsync();

        _userService = new SqlUserService(_dbContext);
    }

    public async Task DisposeAsync()
    {
        await _dbContext.DisposeAsync();
        await _container.DisposeAsync();
    }

    [Fact]
    public async Task CreateUserAsync_輸入有效使用者資料_應成功建立使用者()
    {
        // Arrange
        var request = new UserCreateRequest
        {
            Username = "testuser_sqlserver",
            FullName = "Test User_sqlserver",
            Email = "test_sqlserver@example.com",
            Age = 25
        };

        // Act
        var result = await _userService.CreateUserAsync(request);

        // Assert
        result.Should().NotBeNull();
        result.Id.Should().NotBeNullOrEmpty();
        result.Username.Should().Be(request.Username);
        result.FullName.Should().Be(request.FullName);
        result.Email.Should().Be(request.Email);
        result.Age.Should().Be(request.Age);
    }

    [Fact]
    public async Task GetUserByIdAsync_輸入已存在的使用者ID_應回傳對應使用者()
    {
        // Arrange
        var createRequest = new UserCreateRequest
        {
            Username = "testuser2_sqlserver",
            FullName = "Test User2_sqlserver",
            Email = "test2_sqlserver@example.com",
            Age = 25
        };
        var createdUser = await _userService.CreateUserAsync(createRequest);

        // Act
        var result = await _userService.GetUserByIdAsync(createdUser.Id);

        // Assert
        result.Should().NotBeNull();
        result!.Id.Should().Be(createdUser.Id);
        result.Username.Should().Be(createRequest.Username);
        result.FullName.Should().Be(createRequest.FullName);
        result.Email.Should().Be(createRequest.Email);
    }

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

Entity Framework Core 整合測試

在整合測試中,我們需要建立 DbContext 來處理資料庫操作。以下是我們的資料模型設計:

UserDbContext 類別

using Day20.Core.Models;

namespace Day20.Core.Data;

/// <summary>
/// SQL 資料庫 DbContext (支援 PostgreSQL 和 SQL Server)
/// </summary>
public class UserDbContext : DbContext
{
    public UserDbContext(DbContextOptions<UserDbContext> options) : base(options)
    {
    }

    /// <summary>
    /// 使用者資料集
    /// </summary>
    public DbSet<User> Users { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<User>(entity =>
        {
            // 設定主鍵
            entity.HasKey(e => e.Id);

            // 設定索引
            entity.HasIndex(e => e.Username).IsUnique();
            entity.HasIndex(e => e.Email).IsUnique();

            // 設定屬性
            entity.Property(e => e.Id)
                  .HasMaxLength(36)
                  .ValueGeneratedOnAdd();

            entity.Property(e => e.Username)
                  .HasMaxLength(50)
                  .IsRequired();

            entity.Property(e => e.Email)
                  .HasMaxLength(100)
                  .IsRequired();

            entity.Property(e => e.FullName)
                  .HasMaxLength(100)
                  .IsRequired();

            // 根據資料庫提供者設定預設值
            if (Database.IsNpgsql())
            {
                entity.Property(e => e.CreatedAt)
                      .HasDefaultValueSql("NOW()"); // PostgreSQL
            }
            else if (Database.IsSqlServer())
            {
                entity.Property(e => e.CreatedAt)
                      .HasDefaultValueSql("GETUTCDATE()"); // SQL Server
            }
            else
            {
                // 其他資料庫或沒有預設值
                entity.Property(e => e.CreatedAt)
                      .HasDefaultValue(DateTime.UtcNow);
            }

            // 種子資料
            entity.HasData(
                new User
                {
                    Id = "1",
                    Username = "admin",
                    Email = "admin@example.com",
                    FullName = "系統管理員",
                    Age = 30,
                    IsActive = true,
                    CreatedAt = DateTime.UtcNow
                },
                new User
                {
                    Id = "2",
                    Username = "testuser",
                    Email = "test@example.com",
                    FullName = "測試使用者",
                    Age = 25,
                    IsActive = true,
                    CreatedAt = DateTime.UtcNow
                }
            );
        });
    }
}

User 實體類別

namespace Day20.Core.Models;

/// <summary>
/// 使用者實體 - 支援多種資料庫
/// </summary>
public class User
{
    /// <summary>
    /// 使用者識別碼
    /// </summary>
    [Key]
    public string Id { get; set; } = string.Empty;

    /// <summary>
    /// 使用者名稱
    /// </summary>
    [Required]
    [StringLength(50)]
    public string Username { get; set; } = string.Empty;

    /// <summary>
    /// 電子郵件
    /// </summary>
    [Required]
    [EmailAddress]
    [StringLength(100)]
    public string Email { get; set; } = string.Empty;

    /// <summary>
    /// 全名
    /// </summary>
    [Required]
    [StringLength(100)]
    public string FullName { get; set; } = string.Empty;

    /// <summary>
    /// 年齡
    /// </summary>
    [Range(1, 150)]
    public int Age { get; set; }

    /// <summary>
    /// 是否啟用
    /// </summary>
    public bool IsActive { get; set; } = true;

    /// <summary>
    /// 建立時間
    /// </summary>
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    /// <summary>
    /// 更新時間
    /// </summary>
    public DateTime? UpdatedAt { get; set; }
}

請求模型類別

namespace Day20.Core.Models;

/// <summary>
/// 建立使用者請求
/// </summary>
public class UserCreateRequest
{
    /// <summary>
    /// 使用者名稱
    /// </summary>
    [Required]
    [StringLength(50)]
    public string Username { get; set; } = string.Empty;

    /// <summary>
    /// 電子郵件
    /// </summary>
    [Required]
    [EmailAddress]
    [StringLength(100)]
    public string Email { get; set; } = string.Empty;

    /// <summary>
    /// 全名
    /// </summary>
    [Required]
    [StringLength(100)]
    public string FullName { get; set; } = string.Empty;

    /// <summary>
    /// 年齡
    /// </summary>
    [Range(1, 150)]
    public int Age { get; set; }
}

複雜查詢測試

[Fact]
public async Task ExecuteComplexQuery_執行聚合查詢_應正確計算分類統計()
{
    // Arrange
    var electronics = new Category { Name = "Electronics" };
    var books = new Category { Name = "Books" };
    
    await _dbContext.Categories.AddRangeAsync(electronics, books);
    await _dbContext.SaveChangesAsync();

    var products = new[]
    {
        new Product { Name = "筆電", Price = 30000m, CategoryId = electronics.Id },
        new Product { Name = "手機", Price = 20000m, CategoryId = electronics.Id },
        new Product { Name = "C# 程式設計", Price = 500m, CategoryId = books.Id },
        new Product { Name = "測試驅動開發", Price = 600m, CategoryId = books.Id }
    };

    await _dbContext.Products.AddRangeAsync(products);
    await _dbContext.SaveChangesAsync();

    // Act
    var categoryStats = await _dbContext.Categories
        .Select(c => new
        {
            CategoryName = c.Name,
            ProductCount = _dbContext.Products.Count(p => p.CategoryId == c.Id),
            AveragePrice = _dbContext.Products
                .Where(p => p.CategoryId == c.Id)
                .Average(p => p.Price),
            TotalValue = _dbContext.Products
                .Where(p => p.CategoryId == c.Id)
                .Sum(p => p.Price)
        })
        .ToListAsync();

    // Assert
    categoryStats.Should().HaveCount(2);

    var electronicsStats = categoryStats.First(s => s.CategoryName == "Electronics");
    electronicsStats.ProductCount.Should().Be(2);
    electronicsStats.AveragePrice.Should().Be(25000m);
    electronicsStats.TotalValue.Should().Be(50000m);

    var booksStats = categoryStats.First(s => s.CategoryName == "Books");
    booksStats.ProductCount.Should().Be(2);
    booksStats.AveragePrice.Should().Be(550m);
    booksStats.TotalValue.Should().Be(1100m);
}

這些測試展示了如何使用 Testcontainers 進行真實的資料庫整合測試,涵蓋基本 CRUD 操作、約束驗證、交易處理和複雜查詢等場景。

SQL Server 進階配置範例

SQL Server 是 Microsoft 的企業級關聯式資料庫,在企業環境中廣泛使用。讓我們看看如何進行更進階的配置和測試:

public class AdvancedSqlServerTests : IAsyncDisposable
{
    private readonly MsSqlContainer _sqlServer;
    private readonly UserDbContext _dbContext;
    private readonly SqlUserService _userService;

    public AdvancedSqlServerTests()
    {
        _sqlServer = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .WithPassword("TestPass123!")           // SQL Server 需要強密碼
            .WithEnvironment("ACCEPT_EULA", "Y")    // 接受授權條款
            .WithPortBinding(15433, true)           // 自動分配埠號
            .WithWaitStrategy(Wait.ForUnixContainer()
                .UntilPortIsAvailable(1433))        // 等待埠號可用
            .Build();

        // 啟動容器
        _sqlServer.StartAsync().GetAwaiter().GetResult();

        var options = new DbContextOptionsBuilder<UserDbContext>()
            .UseSqlServer(_sqlServer.GetConnectionString())
            .Options;

        _dbContext = new UserDbContext(options);
        _dbContext.Database.EnsureCreated();

        _userService = new SqlUserService(_dbContext);
    }

    [Fact]
    public async Task CanConnectAsync_資料庫連線_應回傳健康狀態()
    {
        // Act & Assert
        (await _dbContext.Database.CanConnectAsync()).Should().BeTrue();
    }

    [Fact]
    public async Task RollbackTransaction_建立使用者後回滾_應不存在於資料庫()
    {
        // Arrange
        var request = new UserCreateRequest 
        { 
            Username = "transactiontest", 
            FullName = "Transaction Test",
            Email = "transaction@example.com",
            Age = 30
        };

        // Act
        using var transaction = await _dbContext.Database.BeginTransactionAsync();
        
        var createdUser = await _userService.CreateUserAsync(request);
        
        // 回滾交易
        await transaction.RollbackAsync();

        // Assert
        var userCount = await _dbContext.Users.CountAsync();
        userCount.Should().Be(0);
    }

    public async ValueTask DisposeAsync()
    {
        await _dbContext.DisposeAsync();
        await _sqlServer.DisposeAsync();
    }
}

這個 SQL Server 範例展示了進階配置,特別是交易回滾測試,這正是前言中提到的 InMemory 資料庫無法模擬的重要功能。透過真實的 SQL Server 容器,我們可以驗證交易行為是否符合預期。

外部服務模擬測試

在微服務架構中,應用程式通常需要與多種外部服務整合,包括 HTTP API、快取服務、訊息佇列等。Testcontainers 讓我們能夠在測試中模擬這些服務。

WireMock HTTP API 模擬

WireMock 是一個強大的 HTTP 服務模擬工具,可以模擬各種外部 API 的行為。

簡單的入門範例

首先,讓我們看一個簡單的 WireMock 使用範例:

using Day20.Core.Services;
using Day20.Core.Services.Implementations;
using AwesomeAssertions;
using WireMock.Net.Testcontainers;

public class ExternalApiClient
{
    private readonly HttpClient _httpClient;
    private readonly string _baseUrl;

    public ExternalApiClient(HttpClient httpClient, string baseUrl)
    {
        _httpClient = httpClient;
        _baseUrl = baseUrl;
    }

    public async Task<UserProfile?> GetUserProfileAsync(int userId)
    {
        var response = await _httpClient.GetAsync($"{_baseUrl}/api/users/{userId}");
        
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        var json = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<UserProfile>(json);
    }
}

public class ExternalApiTests : IAsyncLifetime
{
    private readonly WireMockContainer _wireMockContainer = new WireMockContainerBuilder()
        .Build();

    private IExternalApiService _externalApiService = null!;
    private HttpClient _httpClient = null!;

    public async Task InitializeAsync()
    {
        await _wireMockContainer.StartAsync();

        _httpClient = new HttpClient();
        // WireMock 預設使用 port 80
        var baseUrl = $"http://localhost:{_wireMockContainer.GetMappedPublicPort(80)}";
        _externalApiService = new ExternalApiService(_httpClient, baseUrl, baseUrl);
    }

    public async Task DisposeAsync()
    {
        _httpClient?.Dispose();
        await _wireMockContainer.DisposeAsync();
    }

    [Fact]
    public async Task ValidateEmailAsync_使用有效電子郵件_應回傳True()
    {
        // Arrange
        var email = "test@example.com";

        // 最簡單可行的 mapping - 回到工作的版本
        var mappingJson = """
                          {
                            "request": {
                              "method": "GET",
                              "urlPath": "/api/email/validate",
                              "queryParameters": {
                                "email": {
                                  "equalTo": "test@example.com"
                                }
                              }
                            },
                            "response": {
                              "status": 200,
                              "headers": {
                                "Content-Type": "application/json"
                              },
                              "body": "{\"IsValid\": true, \"Message\": \"Email is valid\"}"
                            }
                          }
                          """;

        // 使用 HttpClient 直接設定 WireMock mapping
        var adminUrl = $"http://localhost:{_wireMockContainer.GetMappedPublicPort(80)}/__admin/mappings";
        var content = new StringContent(mappingJson, System.Text.Encoding.UTF8, "application/json");
        var mappingResponse = await _httpClient.PostAsync(adminUrl, content);
        mappingResponse.EnsureSuccessStatusCode();

        // 等待一點時間讓 mapping 生效
        await Task.Delay(100);

        // Act
        var result = await _externalApiService.ValidateEmailAsync(email);

        // Assert
        result.Should().BeTrue();
    }
}

基本 WireMock 設定

using System.Text;
using Day20.Core.Services.Implementations;
using DotNet.Testcontainers.Containers;
using WireMock.Net.Testcontainers;
using Xunit.Abstractions;

public class WireMockIntegrationTests : IAsyncLifetime
{
    private readonly WireMockContainer _wireMockContainer = new WireMockContainerBuilder().Build();

    private readonly ITestOutputHelper _output;
    private IExternalApiService _externalApiService = null!;
    private HttpClient _httpClient = null!;

    /// <summary>
    /// 建構式,注入 ITestOutputHelper 用於測試輸出
    /// </summary>
    /// <param name="output"></param>
    public WireMockIntegrationTests(ITestOutputHelper output)
    {
        _output = output;
    }

    /// <summary>
    /// 測試初始化
    /// </summary>
    public async Task InitializeAsync()
    {
        await _wireMockContainer.StartAsync();

        _httpClient = new HttpClient();
        // WireMock 預設使用 port 80
        var baseUrl = $"http://localhost:{_wireMockContainer.GetMappedPublicPort(80)}";
        _externalApiService = new ExternalApiService(_httpClient, baseUrl, baseUrl);
    }

    /// <summary>
    /// 測試清理
    /// </summary>
    public async Task DisposeAsync()
    {
        _httpClient?.Dispose();
        await _wireMockContainer.DisposeAsync();
    }

    [Fact]
    public async Task ValidateEmailAsync_使用有效電子郵件_應回傳True()
    {
        // Arrange
        var email = "test@example.com";

        // 最簡單可行的 mapping - 回到工作的版本
        var mappingJson = """
                          {
                            "request": {
                              "method": "GET",
                              "urlPath": "/api/email/validate",
                              "queryParameters": {
                                "email": {
                                  "equalTo": "test@example.com"
                                }
                              }
                            },
                            "response": {
                              "status": 200,
                              "headers": {
                                "Content-Type": "application/json"
                              },
                              "body": "{\"IsValid\": true, \"Message\": \"Email is valid\"}"
                            }
                          }
                          """;

        // 使用 HttpClient 直接設定 WireMock mapping
        var adminUrl = $"http://localhost:{_wireMockContainer.GetMappedPublicPort(80)}/__admin/mappings";
        var content = new StringContent(mappingJson, Encoding.UTF8, "application/json");
        var mappingResponse = await _httpClient.PostAsync(adminUrl, content);
        mappingResponse.EnsureSuccessStatusCode();

        // 等待一點時間讓 mapping 生效
        await Task.Delay(100);

        // Act
        var result = await _externalApiService.ValidateEmailAsync(email);

        // Assert
        result.Should().BeTrue();
    }

    [Fact]
    public async Task ValidateEmailAsync_使用無效電子郵件_應回傳False()
    {
        // Arrange
        var email = "invalid-email";
        var mappingJson = """
                          {
                            "request": {
                              "method": "GET",
                              "urlPath": "/api/email/validate",
                              "queryParameters": {
                                "email": {
                                  "equalTo": "invalid-email"
                                }
                              }
                            },
                            "response": {
                              "status": 200,
                              "headers": {
                                "Content-Type": "application/json"
                              },
                              "body": "{\"IsValid\": false, \"Message\": \"Email is invalid\"}"
                            }
                          }
                          """;

        var adminUrl = $"http://localhost:{_wireMockContainer.GetMappedPublicPort(80)}/__admin/mappings";
        var content = new StringContent(mappingJson, Encoding.UTF8, "application/json");
        await _httpClient.PostAsync(adminUrl, content);

        // Act
        var result = await _externalApiService.ValidateEmailAsync(email);

        // Assert
        result.Should().BeFalse();
    }

    public async Task DisposeAsync()
    {
        _httpClient?.Dispose();
        if (_container != null)
        {
            await _container.DisposeAsync();
        }
    }
}

Redis 快取服務測試

Redis 是廣泛使用的記憶體資料結構儲存系統,常用作快取、訊息佇列等。

Redis 容器整合測試

using StackExchange.Redis;
using Testcontainers.Redis;
using AwesomeAssertions;

public class RedisIntegrationTests : IAsyncLifetime
{
    private readonly RedisContainer _redis;
    private IConnectionMultiplexer _connection = null!;
    private IDatabase _database = null!;

    public RedisIntegrationTests()
    {
        _redis = new RedisBuilder()
            .WithImage("redis:7-alpine")
            .WithCleanUp(true)
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _redis.StartAsync();
        _connection = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString());
        _database = _connection.GetDatabase();
    }

    [Fact]
    public async Task StringSetAsync_設定字串值_應正確儲存和讀取()
    {
        // Arrange
        const string key = "test:user:123";
        const string value = "測試使用者資料";

        // Act
        await _database.StringSetAsync(key, value);
        var retrievedValue = await _database.StringGetAsync(key);

        // Assert
        retrievedValue.Should().Be(value);
    }

    [Fact]
    public async Task StringSetAsync_設定過期時間_應能正確設定TTL()
    {
        // Arrange
        const string key = "test:session:456";
        const string value = "session_data";
        var expiry = TimeSpan.FromSeconds(10); // 使用較長的過期時間

        // Act
        await _database.StringSetAsync(key, value, expiry);
        var immediateValue = await _database.StringGetAsync(key);
        var ttl = await _database.KeyTimeToLiveAsync(key);

        // Assert
        immediateValue.Should().Be(value);
        ttl.Should().NotBeNull();
        ttl.Value.TotalSeconds.Should().BeGreaterThan(0);
        ttl.Value.TotalSeconds.Should().BeLessThanOrEqualTo(10);
    }

    [Fact]
    public async Task HashSetAsync_設定雜湊資料_應正確儲存和讀取所有欄位()
    {
        // Arrange
        const string key = "test:user:hash:789";
        var userData = new Dictionary<string, string>
        {
            ["name"] = "張三",
            ["email"] = "zhang@example.com",
            ["department"] = "IT"
        };

        // Act
        foreach (var kvp in userData)
        {
            await _database.HashSetAsync(key, kvp.Key, kvp.Value);
        }

        var allFields = await _database.HashGetAllAsync(key);
        var userName = await _database.HashGetAsync(key, "name");

        // Assert
        allFields.Should().HaveCount(3);
        userName.Should().Be("張三");
        
        var retrievedData = allFields.ToDictionary(
            item => item.Name.ToString(),
            item => item.Value.ToString());
        
        retrievedData["email"].Should().Be("zhang@example.com");
        retrievedData["department"].Should().Be("IT");
    }

    public async Task DisposeAsync()
    {
        _connection?.Dispose();
        await _redis.DisposeAsync();
    }
}

快取服務層測試

public interface ICacheService
{
    Task<T?> GetAsync<T>(string key);
    Task SetAsync<T>(string key, T value, TimeSpan? expiry = null);
    Task RemoveAsync(string key);
    Task<bool> ExistsAsync(string key);
}

public class RedisCacheService : ICacheService
{
    private readonly IDatabase _database;

    public RedisCacheService(IDatabase database)
    {
        _database = database;
    }

    public async Task<T?> GetAsync<T>(string key)
    {
        var value = await _database.StringGetAsync(key);
        if (!value.HasValue) return default;
        
        return JsonSerializer.Deserialize<T>(value!);
    }

    public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
    {
        var serializedValue = JsonSerializer.Serialize(value);
        await _database.StringSetAsync(key, serializedValue, expiry);
    }

    public async Task RemoveAsync(string key)
    {
        await _database.KeyDeleteAsync(key);
    }

    public async Task<bool> ExistsAsync(string key)
    {
        return await _database.KeyExistsAsync(key);
    }
}

public class CacheServiceIntegrationTests : IAsyncLifetime
{
    private readonly RedisContainer _redis;
    private IConnectionMultiplexer _connection = null!;
    private ICacheService _cacheService = null!;

    public CacheServiceIntegrationTests()
    {
        _redis = new RedisBuilder()
            .WithImage("redis:7-alpine")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _redis.StartAsync();
        _connection = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString());
        _cacheService = new RedisCacheService(_connection.GetDatabase());
    }

    [Fact]
    public async Task SetAsync_序列化使用者物件_應正確序列化和反序列化()
    {
        // Arrange
        var user = new User 
        { 
            Id = Guid.NewGuid(),
            Name = "測試使用者", 
            Email = "test@example.com",
            CreatedAt = DateTime.UtcNow
        };

        const string key = "user:cache:test";

        // Act
        await _cacheService.SetAsync(key, user);
        var cachedUser = await _cacheService.GetAsync<User>(key);

        // Assert
        cachedUser.Should().NotBeNull();
        cachedUser!.Id.Should().Be(user.Id);
        cachedUser.Name.Should().Be(user.Name);
        cachedUser.Email.Should().Be(user.Email);
    }

    [Fact]
    public async Task GetAsync_輸入不存在的Key_應回傳Null()
    {
        // Act
        var result = await _cacheService.GetAsync<User>("nonexistent:key");
        var exists = await _cacheService.ExistsAsync("nonexistent:key");

        // Assert
        result.Should().BeNull();
        exists.Should().BeFalse();
    }

    public async Task DisposeAsync()
    {
        _connection?.Dispose();
        await _redis.DisposeAsync();
    }
}

這些外部服務模擬測試讓我們能夠在隔離的環境中驗證應用程式與外部服務的整合邏輯,確保在各種情況下都能正確處理。

總結

今天我們學習了 Testcontainers 的基礎應用,建立了整合測試的基礎:

學習重點回顧

  1. Testcontainers 框架介紹

    • 了解容器化測試的優勢與價值
    • 掌握與傳統 Mock 測試的差異
    • 認識 .NET Testcontainers 生態系
  2. 基本容器操作

    • 容器的建立、啟動和銷毀流程
    • 連接埠映射與環境變數配置
    • 使用 IAsyncDisposable 管理容器生命週期
  3. 基礎資料庫整合測試

    • PostgreSQL 和 SQL Server 容器的設定與使用
    • 真實資料庫環境下的基本 Entity Framework Core 測試
    • 資料庫連線和基本 CRUD 操作驗證
  4. 外部服務的基礎模擬

    • 使用 WireMock 容器模擬簡單的外部 API
    • 建立可控制的測試環境
    • 實現基本的服務互動測試

關鍵優勢

  • 真實環境測試:使用實際的資料庫和服務,不再依賴模擬
  • 環境一致性:開發、測試、正式環境使用相同版本
  • 自動化管理:容器的建立、配置和清理完全自動化
  • 測試隔離性:每個測試都有獨立、乾淨的環境

最佳實務

  • 每個測試類別使用獨立的容器實例,確保測試隔離
  • 善用 Wait Strategy 確保容器完全啟動後才執行測試
  • 適當設定容器資源限制,避免測試環境資源不足
  • 正確實作 IAsyncDisposable 來管理容器生命週期

實作要點

  • 容器生命週期管理:建構函式啟動和 DisposeAsync() 清理
  • 連線字串動態取得:container.GetConnectionString()
  • 埠號自動分配:避免測試間的埠號衝突
  • 資源清理:確保測試後容器被正確清理

透過今天的學習,我們已經掌握了使用真實服務進行整合測試的基本技能,這將大大提升我們測試的可信度和實用性。

參考資料

官方文件與資源

Docker Desktop 與 WSL2 相關文件

工具與資源

技術文章

範例程式碼:


這是「重啟挑戰:老派軟體工程師的測試修練」的第二十天。明天會介紹 Day 21 – Testcontainers 整合測試:MSSQL + EF Core 以及 Dapper 基礎應用。


上一篇
Day 19 – 整合測試入門:基礎架構與應用場景
下一篇
Day 21 – Testcontainers 整合測試:MSSQL + EF Core 以及 Dapper 基礎應用
系列文
重啟挑戰:老派軟體工程師的測試修練23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言