dotnet-testing-advanced-webapi-integration-testing
npx skills add https://github.com/kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-advanced-webapi-integration-testing
Agent 安装分布
Skill 文档
WebApi æ´å測試
é©ç¨æ å¢
æè½çç´: é²é
æéåç½®ç¥è: xUnit åºç¤ãASP.NET Core åºç¤ãTestcontainers åºç¤ãClean Architecture
é è¨å¸ç¿æé: 60-90 åé
å¸ç¿ç®æ¨
å®ææ¬æè½å¸ç¿å¾ï¼æ¨å°è½å¤ ï¼
- 建ç«å®æ´ç WebApi æ´åæ¸¬è©¦æ¶æ§
- 使ç¨
IExceptionHandler實ä½ç¾ä»£åç°å¸¸èç - é©è
ProblemDetailsåValidationProblemDetailsæ¨æºæ ¼å¼ - ä½¿ç¨ Flurl ç°¡å HTTP 測試ç URL 建æ§
- ä½¿ç¨ AwesomeAssertions é²è¡ç²¾ç¢ºç HTTP åæé©è
- 建ç«å¤å®¹å¨ (PostgreSQL + Redis) 測試ç°å¢
æ ¸å¿æ¦å¿µ
IExceptionHandler – ç¾ä»£åç°å¸¸èç
ASP.NET Core 8+ å¼å
¥ç IExceptionHandler ä»é¢æä¾äºæ¯å³çµ± middleware æ´åªé
çé¯èª¤èçæ¹å¼ï¼
/// <summary>
/// å
¨åç°å¸¸èçå¨
/// </summary>
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "ç¼çæªèççç°å¸¸: {Message}", exception.Message);
var problemDetails = CreateProblemDetails(exception);
httpContext.Response.StatusCode = problemDetails.Status ?? 500;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
private static ProblemDetails CreateProblemDetails(Exception exception)
{
return exception switch
{
KeyNotFoundException => new ProblemDetails
{
Type = "https://httpstatuses.com/404",
Title = "è³æºä¸åå¨",
Status = 404,
Detail = exception.Message
},
ArgumentException => new ProblemDetails
{
Type = "https://httpstatuses.com/400",
Title = "忏é¯èª¤",
Status = 400,
Detail = exception.Message
},
_ => new ProblemDetails
{
Type = "https://httpstatuses.com/500",
Title = "å
§é¨ä¼ºæå¨é¯èª¤",
Status = 500,
Detail = "ç¼çæªé æçé¯èª¤"
}
};
}
}
ProblemDetails æ¨æºæ ¼å¼
RFC 7807 å®ç¾©ççµ±ä¸é¯èª¤åææ ¼å¼ï¼
| æ¬ä½ | 說æ |
|---|---|
type |
åé¡é¡åç URI |
title |
ç°¡ççé¯èª¤æè¿° |
status |
HTTP çæ ç¢¼ |
detail |
詳細çé¯èª¤èªªæ |
instance |
ç¼çåé¡çå¯¦ä¾ URI |
ValidationProblemDetails – é©èé¯èª¤å°ç¨
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "輸å
¥çè³æå
å«é©èé¯èª¤",
"errors": {
"Name": ["ç¢åå稱ä¸è½çºç©º"],
"Price": ["ç¢å广 ¼å¿
é å¤§æ¼ 0"]
}
}
FluentValidation ç°å¸¸èçå¨
FluentValidation ç°å¸¸èçå¨å¯¦ä½ IExceptionHandler ä»é¢ï¼å°éèç ValidationExceptionï¼å°é©èé¯èª¤è½æçºæ¨æºç ValidationProblemDetails æ ¼å¼åæãèçå¨ä¹éæç
§è¨»åé åºå·è¡ï¼ç¹å®èçå¨ï¼å¦ FluentValidationï¼å¿
é å¨å
¨åèçå¨ä¹å註åã
ð 宿´å¯¦ä½ç¨å¼ç¢¼è«åé± references/exception-handler-details.md
æ´å測試åºç¤è¨æ½
TestWebApplicationFactory
public class TestWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private PostgreSqlContainer? _postgresContainer;
private RedisContainer? _redisContainer;
private FakeTimeProvider? _timeProvider;
public PostgreSqlContainer PostgresContainer => _postgresContainer
?? throw new InvalidOperationException("PostgreSQL container å°æªåå§å");
public RedisContainer RedisContainer => _redisContainer
?? throw new InvalidOperationException("Redis container å°æªåå§å");
public FakeTimeProvider TimeProvider => _timeProvider
?? throw new InvalidOperationException("TimeProvider å°æªåå§å");
public async Task InitializeAsync()
{
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("test_db")
.WithUsername("testuser")
.WithPassword("testpass")
.WithCleanUp(true)
.Build();
_redisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.Build();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
await _postgresContainer.StartAsync();
await _redisContainer.StartAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration(config =>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = PostgresContainer.GetConnectionString(),
["ConnectionStrings:Redis"] = RedisContainer.GetConnectionString(),
["Logging:LogLevel:Default"] = "Warning"
});
});
builder.ConfigureServices(services =>
{
// æ¿æ TimeProvider
services.Remove(services.Single(d => d.ServiceType == typeof(TimeProvider)));
services.AddSingleton<TimeProvider>(TimeProvider);
});
builder.UseEnvironment("Testing");
}
public new async Task DisposeAsync()
{
if (_postgresContainer != null) await _postgresContainer.DisposeAsync();
if (_redisContainer != null) await _redisContainer.DisposeAsync();
await base.DisposeAsync();
}
}
Collection Fixture 模å¼
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
public const string Name = "Integration Tests";
}
測試åºåºé¡å¥
[Collection("Integration Tests")]
public abstract class IntegrationTestBase : IAsyncLifetime
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient HttpClient;
protected readonly DatabaseManager DatabaseManager;
protected readonly IFlurlClient FlurlClient;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
HttpClient = factory.CreateClient();
DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());
FlurlClient = new FlurlClient(HttpClient);
}
public virtual async Task InitializeAsync()
{
await DatabaseManager.InitializeDatabaseAsync();
}
public virtual async Task DisposeAsync()
{
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
protected void ResetTime()
{
Factory.TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
}
protected void AdvanceTime(TimeSpan timeSpan)
{
Factory.TimeProvider.Advance(timeSpan);
}
}
Flurl ç°¡å URL 建æ§
Flurl æä¾æµæ¢ç API ä¾å»ºæ§è¤éç URLï¼
// å³çµ±æ¹å¼
var url = $"/products?pageSize={pageSize}&page={page}&keyword={keyword}";
// ä½¿ç¨ Flurl
var url = "/products"
.SetQueryParam("pageSize", 5)
.SetQueryParam("page", 2)
.SetQueryParam("keyword", "ç¹æ®");
測試ç¯ä¾
æå建ç«ç¢å測試
[Fact]
public async Task CreateProduct_ä½¿ç¨ææè³æ_ææå建ç«ç¢å()
{
// Arrange
var request = new ProductCreateRequest { Name = "æ°ç¢å", Price = 299.99m };
// Act
var response = await HttpClient.PostAsJsonAsync("/products", request);
// Assert
response.Should().Be201Created()
.And.Satisfy<ProductResponse>(product =>
{
product.Id.Should().NotBeEmpty();
product.Name.Should().Be("æ°ç¢å");
product.Price.Should().Be(299.99m);
});
}
é©èé¯èª¤æ¸¬è©¦
[Fact]
public async Task CreateProduct_ç¶ç¢åå稱çºç©º_æåå³400BadRequest()
{
// Arrange
var invalidRequest = new ProductCreateRequest { Name = "", Price = 100.00m };
// Act
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
problem.Title.Should().Be("One or more validation errors occurred.");
problem.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("ç¢åå稱ä¸è½çºç©º");
});
}
è³æºä¸å卿¸¬è©¦
[Fact]
public async Task GetById_ç¶ç¢åä¸åå¨_æåå³404ä¸å
å«ProblemDetails()
{
// Arrange
var nonExistentId = Guid.NewGuid();
// Act
var response = await HttpClient.GetAsync($"/Products/{nonExistentId}");
// Assert
response.Should().Be404NotFound()
.And.Satisfy<ProblemDetails>(problem =>
{
problem.Type.Should().Be("https://httpstatuses.com/404");
problem.Title.Should().Be("ç¢åä¸åå¨");
problem.Status.Should().Be(404);
});
}
åé æ¥è©¢æ¸¬è©¦
[Fact]
public async Task GetProducts_使ç¨åé 忏_æå峿£ç¢ºçåé çµæ()
{
// Arrange
await TestHelpers.SeedProductsAsync(DatabaseManager, 15);
// Act - ä½¿ç¨ Flurl å»ºæ§ QueryString
var url = "/products"
.SetQueryParam("pageSize", 5)
.SetQueryParam("page", 2);
var response = await HttpClient.GetAsync(url);
// Assert
response.Should().Be200Ok()
.And.Satisfy<PagedResult<ProductResponse>>(result =>
{
result.Total.Should().Be(15);
result.PageSize.Should().Be(5);
result.Page.Should().Be(2);
result.Items.Should().HaveCount(5);
});
}
è³æç®¡ççç¥
TestHelpers è¨è¨
public static class TestHelpers
{
public static ProductCreateRequest CreateProductRequest(
string name = "測試ç¢å",
decimal price = 100.00m)
{
return new ProductCreateRequest { Name = name, Price = price };
}
public static async Task SeedProductsAsync(DatabaseManager dbManager, int count)
{
var tasks = Enumerable.Range(1, count)
.Select(i => SeedSpecificProductAsync(dbManager, $"ç¢å {i:D2}", i * 10.0m));
await Task.WhenAll(tasks);
}
}
SQL æä»¤ç¢¼å¤é¨å
tests/Integration/
âââ SqlScripts/
âââ Tables/
âââ CreateProductsTable.sql
æä½³å¯¦å
1. æ¸¬è©¦çµæ§è¨è¨
- å®ä¸è·è²¬ï¼æ¯åæ¸¬è©¦å°æ³¨æ¼ä¸åç¹å®å ´æ¯
- 3A 模å¼ï¼æ¸ æ¥åå ArrangeãActãAssert
- æ¸ æ°å½åï¼æ¹æ³åç¨±è¡¨éæ¸¬è©¦æå
2. é¯èª¤èçé©è
- ValidationProblemDetailsï¼é©èé¯èª¤åææ ¼å¼
- ProblemDetailsï¼é©èæ¥åç°å¸¸åæ
- HTTP çæ ç¢¼ï¼ç¢ºèªæ£ç¢ºççæ ç¢¼
3. æè½èé
- 容å¨å ±äº«ï¼ä½¿ç¨ Collection Fixture
- è³ææ¸ çï¼æ¸¬è©¦å¾æ¸ çè³æï¼ä¸é建容å¨
- 並è¡å·è¡ï¼ç¢ºä¿æ¸¬è©¦ç¨ç«æ§
ç¸ä¾å¥ä»¶
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="AwesomeAssertions" Version="9.1.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.0.0" />
<PackageReference Include="Testcontainers.Redis" Version="4.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Respawn" Version="6.2.1" />
å°æ¡çµæ§
src/
âââ Api/ # WebApi 層
âââ Application/ # æç¨æå層
âââ Domain/ # é 忍¡å
âââ Infrastructure/ # åºç¤è¨æ½å±¤
tests/
âââ Integration/
âââ Fixtures/
â âââ TestWebApplicationFactory.cs
â âââ IntegrationTestCollection.cs
â âââ IntegrationTestBase.cs
âââ Handlers/
â âââ GlobalExceptionHandler.cs
â âââ FluentValidationExceptionHandler.cs
âââ Helpers/
â âââ DatabaseManager.cs
â âââ TestHelpers.cs
âââ SqlScripts/
â âââ Tables/
âââ Controllers/
âââ ProductsControllerTests.cs
åèè³æº
åå§æç«
æ¬æè½å §å®¹æç èªãèæ´¾è»é«å·¥ç¨å¸«ç測試修練 – 30 å¤©ææ°ãç³»åæç« ï¼
- Day 23 – æ´å測試實æ°ï¼WebApi æåçæ´åæ¸¬è©¦
- éµäººè³½æç« ï¼https://ithelp.ithome.com.tw/articles/10376873
- ç¯ä¾ç¨å¼ç¢¼ï¼https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day23