在 ASP.NET Core 中,使用 Entity Framework Core (EF Core) 的 InMemory 資料庫來進行單元測試是一種常見且有效的方法,因為它速度快且不需要實際的資料庫連線。
然而,InMemory 模式並非萬能,它無法模擬真實資料庫的所有行為。了解這些限制對於確保測試的全面性和準確性至關重要。
以下是使用 EF Core InMemory 資料庫與真實資料庫(例如 SQL Server、PostgreSQL 等)進行測試的主要差異與限制:
交易行為與資料庫鎖定
LINQ 查詢的差異
資料庫特定行為與功能
InMemory 資料庫 - 小結
InMemory 資料庫是一個非常適合進行單元測試的工具,特別是在測試 Repository 模式或 Service 層的 CRUD (建立、讀取、更新、刪除) 邏輯時。它可以快速驗證商業邏輯是否正確,而無需依賴外部服務。
然而,如果你的測試需要驗證:
那麼,你應該考慮使用整合測試,並連接到一個輕量級的真實資料庫(不要用 SQLite,因為也是無法測試到實際複雜的行為)。這能更準確地模擬應用程式在實際生產環境中的行為,確保測試的全面性與準確性。
原子性操作(Atomic Operation)是指一個或一系列的程式碼操作,在執行時要麼全部成功完成,要麼全部不執行,不存在部分完成的狀態。這就好比一個原子是不可再分的,原子性操作也是不可再分的。
基於以上這些 InMemory 資料庫的重大限制,我們需要一個更強大的測試解決方案。今天要介紹的 Testcontainers 正是為了解決這些問題而生。它讓我們在測試中使用真實的 Docker 容器,能夠準確模擬真實資料庫的所有行為,包括交易處理、並發控制、資料庫特定功能等,讓整合測試更接近真實的正式環境。
今天的內容有:
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 提供了以下優勢:
使用真實的資料庫、訊息佇列等服務,而不是模擬物件。這樣可以測試到實際的 SQL 語法、資料庫限制條件、以及資料存取層的真實行為。
確保測試環境與正式環境使用相同的服務版本。避免因為版本差異導致的測試結果不準確,讓測試更具可信度。
每個測試都有獨立、乾淨的環境,避免測試間的干擾。容器會在測試結束後自動清理,確保下次測試不會受到前一次測試資料的影響。
開發者不需要在本機安裝各種服務,只需要有 Docker。這大幅降低了新人加入專案的門檻,也避免了因為本機環境差異而導致的測試結果不一致問題。
讓我們比較使用 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 測試的特點:
兩種方法各有優勢,在實際專案中通常會混合使用。
.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 之前,需要確保開發環境具備完整的容器化測試能力。
最低系統需求:
安裝步驟:
啟用 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
下載並安裝 Docker Desktop
Docker Desktop 設定最佳化
// Settings → Docker Engine
{
"builder": {
"gc": {
"defaultKeepStorage": "20GB",
"enabled": true
}
}
}
Resources 設定:
安裝 .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>
netstat -an | findstr :5432
docker system prune -a
驗證安裝:
# 檢查 .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.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>
建立簡單的驗證腳本 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
檔案。
執行驗證步驟:
verify-environment.ps1
檔案# 方法一:直接執行腳本
.\verify-environment.ps1
# 方法二:如果遇到執行權限問題
powershell -ExecutionPolicy Bypass -File verify-environment.ps1
Testcontainers 提供直觀的 API 來管理容器的完整生命週期,從建立、啟動到清理。
在 Testcontainers 測試中,我們統一使用 xUnit 的 IAsyncLifetime
介面而不是 IAsyncDisposable
,原因如下:
IAsyncLifetime
提供 InitializeAsync()
和 DisposeAsync()
兩個方法,讓我們能清楚分離初始化和清理邏輯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 確保容器完全啟動後才執行測試,這是穩定測試的關鍵。
// 等待特定埠號可用
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();
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();
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 是目前最受歡迎的開源關聯式資料庫之一。
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 是 Microsoft 的企業級關聯式資料庫,廣泛用於企業環境。
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();
}
}
在整合測試中,我們需要建立 DbContext 來處理資料庫操作。以下是我們的資料模型設計:
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
}
);
});
}
}
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 是 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 使用範例:
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();
}
}
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 是廣泛使用的記憶體資料結構儲存系統,常用作快取、訊息佇列等。
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 的基礎應用,建立了整合測試的基礎:
學習重點回顧:
Testcontainers 框架介紹
基本容器操作
IAsyncDisposable
管理容器生命週期基礎資料庫整合測試
外部服務的基礎模擬
關鍵優勢:
最佳實務:
IAsyncDisposable
來管理容器生命週期實作要點:
DisposeAsync()
清理container.GetConnectionString()
透過今天的學習,我們已經掌握了使用真實服務進行整合測試的基本技能,這將大大提升我們測試的可信度和實用性。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第二十天。明天會介紹 Day 21 – Testcontainers 整合測試:MSSQL + EF Core 以及 Dapper 基礎應用。