---
name: dotnet-testing-advanced-testcontainers-database
description: |
使用 Testcontainers 進行容器化資料庫測試的專門技能。
當需要測試真實資料庫行為、使用 SQL Server/PostgreSQL/MySQL 容器、測試 EF Core/Dapper 時使用。
涵蓋容器啟動、資料庫遷移、測試隔離、容器共享等。
triggers:
# 核心關鍵字
- testcontainers
- 容器測試
- container testing
- database testing
- 資料庫測試
- docker testing
# 資料庫類型
- SQL Server container
- PostgreSQL container
- MySQL container
- MsSqlContainer
- PostgreSqlContainer
- MySqlContainer
# 使用情境
- 真實資料庫測試
- EF Core testing
- Dapper testing
- 資料庫遷移測試
- database migration testing
- 交易測試
- transaction testing
# 技術術語
- Testcontainers.MsSql
- Testcontainers.PostgreSql
- StartAsync
- GetConnectionString
- IAsyncLifetime
- CollectionFixture
license: MIT
metadata:
author: Kevin Tseng
version: "1.0.0"
tags: ".NET, testing, Testcontainers, database, SQL Server, PostgreSQL"
---
# Testcontainers 資料庫整合測試指南
## 適用情境
當被要求執行以下任務時,請使用此技能:
- 需要測試真實資料庫行為(交易、並發、預存程序等)
- EF Core InMemory 資料庫無法滿足測試需求
- 建立 PostgreSQL 或 MSSQL 的容器化測試環境
- 使用 Collection Fixture 模式共享容器實例
- 同時測試 EF Core 和 Dapper 的資料存取層
- 需要 SQL 腳本外部化策略
## EF Core InMemory 的限制
在選擇測試策略前,必須了解 EF Core InMemory 資料庫的重大限制:
### 1. 交易行為與資料庫鎖定
- **不支援資料庫交易 (Transactions)**:`SaveChanges()` 後資料立即儲存,無法進行 Rollback
- **無資料庫鎖定機制**:無法模擬並發 (Concurrency) 情境下的行為
### 2. LINQ 查詢差異
- **查詢翻譯差異**:某些 LINQ 查詢(複雜 GroupBy、JOIN、自訂函數)在 InMemory 中可執行,但轉換成 SQL 時可能失敗
- **Case Sensitivity**:InMemory 預設不區分大小寫,但真實資料庫依賴校對規則 (Collation)
- **效能模擬不足**:無法模擬真實資料庫的效能瓶頸或索引問題
### 3. 資料庫特定功能
InMemory 模式無法測試:
- 預存程序 (Stored Procedures) 與 Triggers
- Views
- 外鍵約束 (Foreign Key Constraints)、檢查約束 (Check Constraints)
- 資料類型精確度(decimal、datetime 等)
- Concurrency Tokens(RowVersion、Timestamp)
**結論**:當需要驗證複雜交易邏輯、並發處理、資料庫特定行為時,應使用 Testcontainers 進行整合測試。
## Testcontainers 核心概念
### 什麼是 Testcontainers?
Testcontainers 是一個測試函式庫,提供輕量好用的 API 來啟動 Docker 容器,專門用於整合測試。
### 核心優勢
1. **真實環境測試**:使用真實資料庫,測試實際 SQL 語法與資料庫限制
2. **環境一致性**:確保測試環境與正式環境使用相同服務版本
3. **清潔測試環境**:每個測試有獨立乾淨的環境,容器自動清理
4. **簡化開發環境**:開發者只需 Docker,不需安裝各種服務
## 必要套件
```xml
```
> **重要**:使用 `Microsoft.Data.SqlClient` 而非舊版 `System.Data.SqlClient`,提供更好的效能與安全性。
## 環境需求
### Docker Desktop 設定
- Windows 10 版本 2004 或更新版本
- 啟用 WSL 2 功能
- 8GB RAM(建議 16GB 以上)
- 64GB 可用磁碟空間
### 建議的 Docker Desktop Resources 設定
- Memory: 6GB(系統記憶體的 50-75%)
- CPUs: 4 cores
- Swap: 2GB
- Disk image size: 64GB
## 基本容器操作模式
### PostgreSQL 容器
```csharp
public class PostgreSqlTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres;
private UserDbContext _dbContext = null!;
public PostgreSqlTests()
{
_postgres = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("testdb")
.WithUsername("testuser")
.WithPassword("testpass")
.WithPortBinding(5432, true) // 自動分配主機埠號
.Build();
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var options = new DbContextOptionsBuilder()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
_dbContext = new UserDbContext(options);
await _dbContext.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _postgres.DisposeAsync();
}
}
```
### SQL Server 容器
```csharp
public class SqlServerTests : IAsyncLifetime
{
private readonly MsSqlContainer _container;
private UserDbContext _dbContext = null!;
public SqlServerTests()
{
_container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("YourStrong@Passw0rd")
.WithCleanUp(true)
.Build();
}
public async Task InitializeAsync()
{
await _container.StartAsync();
var options = new DbContextOptionsBuilder()
.UseSqlServer(_container.GetConnectionString())
.Options;
_dbContext = new UserDbContext(options);
await _dbContext.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _container.DisposeAsync();
}
}
```
## Collection Fixture 模式:容器共享
### 為什麼需要容器共享?
在大型專案中,每個測試類別都建立新容器會遇到嚴重的效能瓶頸:
- **傳統方式**:每個測試類別啟動一個容器。若有 3 個測試類別,總耗時約 `3 × 10 秒 = 30 秒`
- **Collection Fixture**:所有測試類別共享同一個容器。總耗時僅約 `1 × 10 秒 = 10 秒`
**測試執行時間減少約 67%**
### Collection Fixture 實作
```csharp
///
/// MSSQL 容器的 Collection Fixture
///
public class SqlServerContainerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container;
public SqlServerContainerFixture()
{
_container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("Test123456!")
.WithCleanUp(true)
.Build();
}
public static string ConnectionString { get; private set; } = string.Empty;
public async Task InitializeAsync()
{
await _container.StartAsync();
ConnectionString = _container.GetConnectionString();
// 等待容器完全啟動
await Task.Delay(2000);
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
///
/// 定義測試集合
///
[CollectionDefinition(nameof(SqlServerCollectionFixture))]
public class SqlServerCollectionFixture : ICollectionFixture
{
// 此類別只是用來定義 Collection,不需要實作內容
}
```
### 測試類別整合
```csharp
[Collection(nameof(SqlServerCollectionFixture))]
public class EfCoreTests : IDisposable
{
private readonly ECommerceDbContext _dbContext;
public EfCoreTests(ITestOutputHelper testOutputHelper)
{
var connectionString = SqlServerContainerFixture.ConnectionString;
var options = new DbContextOptionsBuilder()
.UseSqlServer(connectionString)
.EnableSensitiveDataLogging()
.LogTo(testOutputHelper.WriteLine, LogLevel.Information)
.Options;
_dbContext = new ECommerceDbContext(options);
_dbContext.Database.EnsureCreated();
}
public void Dispose()
{
// 按照外鍵約束順序清理資料
_dbContext.Database.ExecuteSqlRaw("DELETE FROM OrderItems");
_dbContext.Database.ExecuteSqlRaw("DELETE FROM Orders");
_dbContext.Database.ExecuteSqlRaw("DELETE FROM Products");
_dbContext.Database.ExecuteSqlRaw("DELETE FROM Categories");
_dbContext.Dispose();
}
}
```
## SQL 腳本外部化策略
### 為什麼需要外部化 SQL 腳本?
- **關注點分離**:C# 程式碼專注於測試邏輯,SQL 腳本專注於資料庫結構
- **可維護性**:修改資料庫結構時,只需編輯 `.sql` 檔案
- **可讀性**:C# 程式碼變得更簡潔
- **工具支援**:SQL 檔案可獲得編輯器的語法高亮和格式化支援
- **版本控制友善**:SQL 變更可在版本控制系統中清楚追蹤
### 資料夾結構
```text
tests/DatabaseTesting.Tests/
├── SqlScripts/
│ ├── Tables/
│ │ ├── CreateCategoriesTable.sql
│ │ ├── CreateProductsTable.sql
│ │ ├── CreateOrdersTable.sql
│ │ └── CreateOrderItemsTable.sql
│ └── StoredProcedures/
│ └── GetProductSalesReport.sql
```
### .csproj 設定
```xml
Always
```
### 腳本載入實作
```csharp
private void EnsureTablesExist()
{
var scriptDirectory = Path.Combine(AppContext.BaseDirectory, "SqlScripts");
if (!Directory.Exists(scriptDirectory)) return;
// 按照依賴順序執行表格建立腳本
var orderedScripts = new[]
{
"Tables/CreateCategoriesTable.sql",
"Tables/CreateProductsTable.sql",
"Tables/CreateOrdersTable.sql",
"Tables/CreateOrderItemsTable.sql"
};
foreach (var scriptPath in orderedScripts)
{
var fullPath = Path.Combine(scriptDirectory, scriptPath);
if (File.Exists(fullPath))
{
var script = File.ReadAllText(fullPath);
_dbContext.Database.ExecuteSqlRaw(script);
}
}
}
```
## Wait Strategy 最佳實務
### 內建 Wait Strategy
```csharp
// 等待特定埠號可用
var postgres = new PostgreSqlBuilder()
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilPortIsAvailable(5432))
.Build();
// 等待日誌訊息出現
var sqlServer = new MsSqlBuilder()
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilPortIsAvailable(1433)
.UntilMessageIsLogged("SQL Server is now ready for client connections"))
.Build();
```
## EF Core 進階功能測試
### Include/ThenInclude 多層關聯查詢
```csharp
[Fact]
public async Task GetProductWithCategoryAndTagsAsync_載入完整關聯資料_應正確載入()
{
// Arrange
await CreateProductWithCategoryAndTagsAsync();
// Act
var product = await _repository.GetProductWithCategoryAndTagsAsync(1);
// Assert
product.Should().NotBeNull();
product!.Category.Should().NotBeNull();
product.ProductTags.Should().NotBeEmpty();
}
```
### AsSplitQuery 避免笛卡兒積
```csharp
[Fact]
public async Task GetProductsByCategoryWithSplitQueryAsync_使用分割查詢_應避免笛卡兒積()
{
// Arrange
await CreateMultipleProductsWithTagsAsync();
// Act
var products = await _repository.GetProductsByCategoryWithSplitQueryAsync(1);
// Assert
products.Should().NotBeEmpty();
products.All(p => p.ProductTags.Any()).Should().BeTrue();
}
```
> **笛卡兒積問題**:當一個查詢 JOIN 多個一對多關聯時,會為每個可能的組合產生一列資料。`AsSplitQuery()` 將查詢分解成多個獨立 SQL 查詢,在記憶體中組合結果,避免此問題。
### N+1 查詢問題驗證
```csharp
[Fact]
public async Task N1QueryProblemVerification_對比Repository方法_應展示效率差異()
{
// Arrange
await CreateCategoriesWithProductsAsync();
// Act 1: 測試有問題的方法
var categoriesWithProblem = await _repository.GetCategoriesWithN1ProblemAsync();
// Act 2: 測試最佳化方法
var categoriesOptimized = await _repository.GetCategoriesWithProductsOptimizedAsync();
// Assert
categoriesOptimized.All(c => c.Products.Any()).Should().BeTrue();
}
```
### AsNoTracking 唯讀查詢最佳化
```csharp
[Fact]
public async Task GetProductsWithNoTrackingAsync_唯讀查詢_不應追蹤實體()
{
// Arrange
await CreateMultipleProductsAsync();
// Act
var products = await _repository.GetProductsWithNoTrackingAsync(500m);
// Assert
products.Should().NotBeEmpty();
var trackedEntities = _dbContext.ChangeTracker.Entries().Count();
trackedEntities.Should().Be(0, "AsNoTracking 查詢不應追蹤實體");
}
```
## Dapper 進階功能測試
### 基本 CRUD 測試
```csharp
[Collection(nameof(SqlServerCollectionFixture))]
public class DapperCrudTests : IDisposable
{
private readonly IDbConnection _connection;
private readonly IProductRepository _productRepository;
public DapperCrudTests()
{
var connectionString = SqlServerContainerFixture.ConnectionString;
_connection = new SqlConnection(connectionString);
_connection.Open();
_productRepository = new DapperProductRepository(connectionString);
EnsureTablesExist();
}
public void Dispose()
{
_connection.Execute("DELETE FROM Products");
_connection.Execute("DELETE FROM Categories");
_connection.Close();
_connection.Dispose();
}
}
```
### QueryMultiple 一對多關聯處理
```csharp
public async Task GetProductWithTagsAsync(int productId)
{
const string sql = @"
SELECT * FROM Products WHERE Id = @ProductId;
SELECT t.* FROM Tags t
INNER JOIN ProductTags pt ON t.Id = pt.TagId
WHERE pt.ProductId = @ProductId;";
using var multi = await _connection.QueryMultipleAsync(sql, new { ProductId = productId });
var product = await multi.ReadSingleOrDefaultAsync();
if (product != null)
{
product.Tags = (await multi.ReadAsync()).ToList();
}
return product;
}
```
### DynamicParameters 動態查詢
```csharp
public async Task> SearchProductsAsync(
int? categoryId = null,
decimal? minPrice = null,
bool? isActive = null)
{
var sql = new StringBuilder("SELECT * FROM Products WHERE 1=1");
var parameters = new DynamicParameters();
if (categoryId.HasValue)
{
sql.Append(" AND CategoryId = @CategoryId");
parameters.Add("CategoryId", categoryId.Value);
}
if (minPrice.HasValue)
{
sql.Append(" AND Price >= @MinPrice");
parameters.Add("MinPrice", minPrice.Value);
}
if (isActive.HasValue)
{
sql.Append(" AND IsActive = @IsActive");
parameters.Add("IsActive", isActive.Value);
}
return await _connection.QueryAsync(sql.ToString(), parameters);
}
```
## Repository Pattern 設計原則
### 介面分離原則 (ISP) 的應用
```csharp
///
/// 基礎 CRUD 操作介面
///
public interface IProductRepository
{
Task> GetAllAsync();
Task GetByIdAsync(int id);
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
///
/// EF Core 特有的進階功能介面
///
public interface IProductByEFCoreRepository
{
Task GetProductWithCategoryAndTagsAsync(int productId);
Task> GetProductsByCategoryWithSplitQueryAsync(int categoryId);
Task BatchUpdateProductPricesAsync(int categoryId, decimal priceMultiplier);
Task> GetProductsWithNoTrackingAsync(decimal minPrice);
}
///
/// Dapper 特有的進階功能介面
///
public interface IProductByDapperRepository
{
Task GetProductWithTagsAsync(int productId);
Task> SearchProductsAsync(int? categoryId, decimal? minPrice, bool? isActive);
Task> GetProductSalesReportAsync(decimal minPrice);
}
```
### 設計優勢
1. **單一職責原則 (SRP)**:每個介面專注於特定職責
2. **介面隔離原則 (ISP)**:使用者只需依賴所需的介面
3. **依賴反轉原則 (DIP)**:高層模組依賴抽象而非具體實作
4. **測試隔離性**:可針對特定功能進行精準測試
## 常見問題處理
### Docker 容器啟動失敗
```bash
# 檢查連接埠是否被佔用
netstat -an | findstr :5432
# 清理未使用的映像檔
docker system prune -a
```
### 記憶體不足問題
- 調整 Docker Desktop 記憶體配置
- 限制同時執行的容器數量
- 使用 Collection Fixture 共享容器
### 測試資料隔離
```csharp
public void Dispose()
{
// 按照外鍵約束順序清理資料
_dbContext.Database.ExecuteSqlRaw("DELETE FROM OrderItems");
_dbContext.Database.ExecuteSqlRaw("DELETE FROM Orders");
_dbContext.Database.ExecuteSqlRaw("DELETE FROM Products");
_dbContext.Database.ExecuteSqlRaw("DELETE FROM Categories");
_dbContext.Dispose();
}
```
## 參考資源
### 原始文章
本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:
- **Day 20 - Testcontainers 初探:使用 Docker 架設測試環境**
- 鐵人賽文章:https://ithelp.ithome.com.tw/articles/10376401
- 範例程式碼:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day20
- **Day 21 - Testcontainers 整合測試:MSSQL + EF Core 以及 Dapper 基礎應用**
- 鐵人賽文章:https://ithelp.ithome.com.tw/articles/10376524
- 範例程式碼:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day21
### 官方文件
- [Testcontainers 官方網站](https://testcontainers.com/)
- [Testcontainers for .NET](https://dotnet.testcontainers.org/)
- [Testcontainers for .NET / Modules](https://dotnet.testcontainers.org/modules/)