dotnet-testing-advanced-testcontainers-database
npx skills add https://github.com/kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-advanced-testcontainers-database
Agent 安装分布
Skill 文档
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 容å¨ï¼å°éç¨æ¼æ´å測試ã
æ ¸å¿åªå¢
- ç實ç°å¢æ¸¬è©¦ï¼ä½¿ç¨çå¯¦è³æåº«ï¼æ¸¬è©¦å¯¦é SQL èªæ³èè³æåº«éå¶
- ç°å¢ä¸è´æ§ï¼ç¢ºä¿æ¸¬è©¦ç°å¢èæ£å¼ç°å¢ä½¿ç¨ç¸åæåçæ¬
- æ¸ æ½æ¸¬è©¦ç°å¢ï¼æ¯å測試æç¨ç«ä¹¾æ·¨çç°å¢ï¼å®¹å¨èªåæ¸ ç
- ç°¡åéç¼ç°å¢ï¼éç¼è åªé Dockerï¼ä¸éå®è£å種æå
å¿ è¦å¥ä»¶
<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 Core -->
<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" />
<!-- Dapper (å¯é¸) -->
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
</ItemGroup>
éè¦ï¼ä½¿ç¨
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 容å¨
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<UserDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
_dbContext = new UserDbContext(options);
await _dbContext.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _postgres.DisposeAsync();
}
}
SQL Server 容å¨
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<UserDbContext>()
.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 實ä½
/// <summary>
/// MSSQL 容å¨ç Collection Fixture
/// </summary>
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();
}
}
/// <summary>
/// å®ç¾©æ¸¬è©¦éå
/// </summary>
[CollectionDefinition(nameof(SqlServerCollectionFixture))]
public class SqlServerCollectionFixture : ICollectionFixture<SqlServerContainerFixture>
{
// æ¤é¡å¥åªæ¯ç¨ä¾å®ç¾© Collectionï¼ä¸éè¦å¯¦ä½å
§å®¹
}
測試é¡å¥æ´å
[Collection(nameof(SqlServerCollectionFixture))]
public class EfCoreTests : IDisposable
{
private readonly ECommerceDbContext _dbContext;
public EfCoreTests(ITestOutputHelper testOutputHelper)
{
var connectionString = SqlServerContainerFixture.ConnectionString;
var options = new DbContextOptionsBuilder<ECommerceDbContext>()
.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 è®æ´å¯å¨çæ¬æ§å¶ç³»çµ±ä¸æ¸ æ¥è¿½è¹¤
è³æå¤¾çµæ§
tests/DatabaseTesting.Tests/
âââ SqlScripts/
â âââ Tables/
â â âââ CreateCategoriesTable.sql
â â âââ CreateProductsTable.sql
â â âââ CreateOrdersTable.sql
â â âââ CreateOrderItemsTable.sql
â âââ StoredProcedures/
â âââ GetProductSalesReport.sql
.csproj è¨å®
<ItemGroup>
<Content Include="SqlScripts\**\*.sql">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
è ³æ¬è¼å ¥å¯¦ä½
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
// çå¾
ç¹å®å èå¯ç¨
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 å¤å±¤éè¯æ¥è©¢ãAsSplitQuery é¿å ç¬å¡å ç©ãN+1 æ¥è©¢åé¡é©èãAsNoTracking å¯è®æ¥è©¢æä½³åç宿´æ¸¬è©¦ç¯ä¾ã
ð 宿´ç¨å¼ç¢¼ç¯ä¾è«åè references/orm-advanced-testing.md
Dapper é²éåè½æ¸¬è©¦
æ¶µèåºæ¬ CRUD 測試é¡å¥è¨ç½®ãQueryMultiple ä¸å°å¤éè¯èçãDynamicParameters åæ æ¥è©¢å»ºæ§ç宿´æ¸¬è©¦ç¯ä¾ã
ð 宿´ç¨å¼ç¢¼ç¯ä¾è«åè references/orm-advanced-testing.md
Repository Pattern è¨è¨åå
ä»é¢åé¢åå (ISP) çæç¨
/// <summary>
/// åºç¤ CRUD æä½ä»é¢
/// </summary>
public interface IProductRepository
{
Task<IEnumerable<Product>> GetAllAsync();
Task<Product?> GetByIdAsync(int id);
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
/// <summary>
/// EF Core ç¹æçé²éåè½ä»é¢
/// </summary>
public interface IProductByEFCoreRepository
{
Task<Product?> GetProductWithCategoryAndTagsAsync(int productId);
Task<IEnumerable<Product>> GetProductsByCategoryWithSplitQueryAsync(int categoryId);
Task<int> BatchUpdateProductPricesAsync(int categoryId, decimal priceMultiplier);
Task<IEnumerable<Product>> GetProductsWithNoTrackingAsync(decimal minPrice);
}
/// <summary>
/// Dapper ç¹æçé²éåè½ä»é¢
/// </summary>
public interface IProductByDapperRepository
{
Task<Product?> GetProductWithTagsAsync(int productId);
Task<IEnumerable<Product>> SearchProductsAsync(int? categoryId, decimal? minPrice, bool? isActive);
Task<IEnumerable<ProductSalesReport>> GetProductSalesReportAsync(decimal minPrice);
}
è¨è¨åªå¢
- å®ä¸è·è²¬åå (SRP)ï¼æ¯åä»é¢å°æ³¨æ¼ç¹å®è·è²¬
- ä»é¢éé¢åå (ISP)ï¼ä½¿ç¨è åªéä¾è³´æéçä»é¢
- ä¾è³´åè½åå (DIP)ï¼é«å±¤æ¨¡çµä¾è³´æ½è±¡èéå ·é«å¯¦ä½
- 測試é颿§ï¼å¯éå°ç¹å®åè½é²è¡ç²¾æºæ¸¬è©¦
常è¦åé¡èç
Docker 容å¨åå失æ
# 檢æ¥é£æ¥å æ¯å¦è¢«ä½ç¨
netstat -an | findstr :5432
# æ¸
çæªä½¿ç¨çæ åæª
docker system prune -a
è¨æ¶é«ä¸è¶³åé¡
- èª¿æ´ Docker Desktop è¨æ¶é«é ç½®
- éå¶åæå·è¡ç容卿¸é
- ä½¿ç¨ Collection Fixture å ±äº«å®¹å¨
æ¸¬è©¦è³æéé¢
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