Install any skill in seconds. Free to start, no credit card required.
Get Started Free →xUnit、FluentAssertions、モッキング、統合テスト、テスト組織のベストプラクティスを使用したC#と.NETのテストパターン。
.claude/skills/affaan-m-csharp-testing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 84% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 130% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 98% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 86% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 121% | 0% |
使用 xUnit、FluentAssertions 和现代测试实践为 .NET 应用程序提供的全面测试模式。
| 工具 | 用途 | |---|---| | xUnit | 测试框架(.NET 首选) | | FluentAssertions | 可读的断言语法 | | NSubstitute 或 Moq | 模拟依赖项 | | Testcontainers | 集成测试中的真实基础设施 | | WebApplicationFactory | ASP.NET Core 集成测试 | | Bogus | 生成逼真的测试数据 |
csharppublic sealed class OrderServiceTests { private readonly IOrderRepository _repository = Substitute.For<IOrderRepository>(); private readonly ILogger<OrderService> _logger = Substitute.For<ILogger<OrderService>>(); private readonly OrderService _sut; public OrderServiceTests() { _sut = new OrderService(_repository, _logger); } [Fact] public async Task PlaceOrderAsync_ReturnsSuccess_WhenRequestIsValid() { // Arrange var request = new CreateOrderRequest { CustomerId = "cust-123", Items = [new OrderItem("SKU-001", 2, 29.99m)] }; // Act var result = await _sut.PlaceOrderAsync(request, CancellationToken.None); // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); result.Value!.CustomerId.Should().Be("cust-123"); } [Fact] public async Task PlaceOrderAsync_ReturnsFailure_WhenNoItems() { // Arrange var request = new CreateOrderRequest { CustomerId = "cust-123", Items = [] }; // Act var result = await _sut.PlaceOrderAsync(request, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("at least one item"); } }
csharp[Theory] [InlineData("", false)] [InlineData("a", false)] [InlineData("ab@c.d", false)] [InlineData("user@example.com", true)] [InlineData("user+tag@example.co.uk", true)] public void IsValidEmail_ReturnsExpected(string email, bool expected) { EmailValidator.IsValid(email).Should().Be(expected); } [Theory] [MemberData(nameof(InvalidOrderCases))] public async Task PlaceOrderAsync_RejectsInvalidOrders(CreateOrderRequest request, string expectedError) { var result = await _sut.PlaceOrderAsync(request, CancellationToken.None); result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain(expectedError); } public static TheoryData<CreateOrderRequest, string> InvalidOrderCases => new() { { new() { CustomerId = "", Items = [ValidItem()] }, "CustomerId" }, { new() { CustomerId = "c1", Items = [] }, "at least one item" }, { new() { CustomerId = "c1", Items = [new("", 1, 10m)] }, "SKU" }, };
csharp[Fact] public async Task GetOrderAsync_ReturnsNull_WhenNotFound() { // Arrange var orderId = Guid.NewGuid(); _repository.FindByIdAsync(orderId, Arg.Any<CancellationToken>()) .Returns((Order?)null); // Act var result = await _sut.GetOrderAsync(orderId, CancellationToken.None); // Assert result.Should().BeNull(); } [Fact] public async Task PlaceOrderAsync_PersistsOrder() { // Arrange var request = ValidOrderRequest(); // Act await _sut.PlaceOrderAsync(request, CancellationToken.None); // Assert — verify the repository was called await _repository.Received(1).AddAsync( Arg.Is<Order>(o => o.CustomerId == request.CustomerId), Arg.Any<CancellationToken>()); }
csharppublic sealed class OrderApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public OrderApiTests(WebApplicationFactory<Program> factory) { _client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real DB with in-memory for tests services.RemoveAll<DbContextOptions<AppDbContext>>(); services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("TestDb")); }); }).CreateClient(); } [Fact] public async Task GetOrder_Returns404_WhenNotFound() { var response = await _client.GetAsync($"/api/orders/{Guid.NewGuid()}"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task CreateOrder_Returns201_WithValidRequest() { var request = new CreateOrderRequest { CustomerId = "cust-1", Items = [new("SKU-001", 1, 19.99m)] }; var response = await _client.PostAsJsonAsync("/api/orders", request); response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.Should().NotBeNull(); } }
csharppublic sealed class PostgresOrderRepositoryTests : IAsyncLifetime { private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .Build(); private AppDbContext _db = null!; public async Task InitializeAsync() { await _postgres.StartAsync(); var options = new DbContextOptionsBuilder<AppDbContext>() .UseNpgsql(_postgres.GetConnectionString()) .Options; _db = new AppDbContext(options); await _db.Database.MigrateAsync(); } public async Task DisposeAsync() { await _db.DisposeAsync(); await _postgres.DisposeAsync(); } [Fact] public async Task AddAsync_PersistsOrder() { var repo = new SqlOrderRepository(_db); var order = Order.Create("cust-1", [new OrderItem("SKU-001", 2, 10m)]); await repo.AddAsync(order, CancellationToken.None); var found = await repo.FindByIdAsync(order.Id, CancellationToken.None); found.Should().NotBeNull(); found!.Items.Should().HaveCount(1); } }
tests/
MyApp.UnitTests/
Services/
OrderServiceTests.cs
PaymentServiceTests.cs
Validators/
EmailValidatorTests.cs
MyApp.IntegrationTests/
Api/
OrderApiTests.cs
Repositories/
OrderRepositoryTests.cs
MyApp.TestHelpers/
Builders/
OrderBuilder.cs
Fixtures/
DatabaseFixture.cscsharppublic sealed class OrderBuilder { private string _customerId = "cust-default"; private readonly List<OrderItem> _items = [new("SKU-001", 1, 10m)]; public OrderBuilder WithCustomer(string customerId) { _customerId = customerId; return this; } public OrderBuilder WithItem(string sku, int quantity, decimal price) { _items.Add(new OrderItem(sku, quantity, price)); return this; } public Order Build() => Order.Create(_customerId, _items); } // Usage in tests var order = new OrderBuilder() .WithCustomer("cust-vip") .WithItem("SKU-PREMIUM", 3, 99.99m) .Build();
| 反模式 | 修复方法 | |---|---| | 测试实现细节 | 测试行为和结果 | | 共享的可变测试状态 | 每个测试使用新实例(xUnit 通过构造函数实现) | | 在异步测试中使用 Thread.Sleep | 使用带超时的 Task.Delay 或轮询辅助方法 | | 对 ToString() 输出进行断言 | 对类型化属性进行断言 | | 每个测试一个巨型断言 | 每个测试一个逻辑断言 | | 测试名称描述实现 | 按行为命名:Method_ExpectedResult_WhenCondition | | 忽略 CancellationToken | 始终传递并验证取消 |
bash# Run all tests dotnet test # Run with coverage dotnet test --collect:"XPlat Code Coverage" # Run specific project dotnet test tests/MyApp.UnitTests/ # Filter by test name dotnet test --filter "FullyQualifiedName~OrderService" # Watch mode during development dotnet watch test --project tests/MyApp.UnitTests/
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 14,567 | 14,597 | +0% | 1 | 1 | 0% | 2,885 | 5,317 | +84% | 0 | 0 | — |
case-06 | pass→pass | 13,291 | 9,738 | -27% | 1 | 1 | 0% | 2,573 | 4,064 | +58% | 0 | 0 | — |
case-02 | fail→pass | 8,146 | 5,891 | -28% | 1 | 1 | 0% | 1,500 | 3,457 | +130% | 0 | 0 | — |
case-03 | pass→pass | 11,469 | 8,799 | -23% | 1 | 1 | 0% | 2,055 | 4,047 | +97% | 0 | 0 | — |
case-04 | fail→pass | 10,301 | 9,219 | -11% | 1 | 1 | 0% | 2,081 | 4,120 | +98% | 0 | 0 | — |
case-05 | pass→pass | 14,198 | 14,295 | +1% | 1 | 1 | 0% | 2,691 | 5,014 | +86% | 0 | 0 | — |
case-07 | pass→pass | 14,034 | 13,481 | -4% | 1 | 1 | 0% | 2,545 | 4,988 | +96% | 0 | 0 | — |
case-08 | pass→pass | 16,572 | 16,901 | +2% | 1 | 1 | 0% | 2,936 | 5,206 | +77% | 0 | 0 | — |
case-09 | fail→pass | 64,851 | 19,080 | -71% | 1 | 1 | 0% | 3,093 | 5,748 | +86% | 0 | 0 | — |
case-10 | fail→pass | 11,953 | 14,242 | +19% | 1 | 1 | 0% | 2,376 | 5,260 | +121% | 0 | 0 | — |
case-11 | pass→pass | 15,692 | 11,226 | -28% | 1 | 1 | 0% | 2,669 | 4,299 | +61% | 0 | 0 | — |
case-12 | pass→pass | 12,518 | 11,658 | -7% | 1 | 1 | 0% | 2,201 | 4,335 | +97% | 0 | 0 | — |
case-13 | pass→fail | 13,660 | 16,021 | +17% | 1 | 1 | 0% | 2,488 | 5,090 | +105% | 0 | 0 | — |
case-14 | pass→pass | 7,277 | 4,591 | -37% | 1 | 1 | 0% | 1,241 | 3,040 | +145% | 0 | 0 | — |
case-15 | pass→pass | 6,835 | 4,794 | -30% | 1 | 1 | 0% | 1,255 | 3,282 | +162% | 0 | 0 | — |
case-16 | pass→pass | 4,556 | 2,920 | -36% | 1 | 1 | 0% | 797 | 2,743 | +244% | 0 | 0 | — |
case-17 | pass→pass | 9,412 | 6,805 | -28% | 1 | 1 | 0% | 1,803 | 3,548 | +97% | 0 | 0 | — |
case-18 | pass→pass | 16,836 | 8,204 | -51% | 1 | 1 | 0% | 1,748 | 3,654 | +109% | 0 | 0 | — |
case-19 | pass→pass | 5,772 | 5,919 | +3% | 1 | 1 | 0% | 1,045 | 3,298 | +216% | 0 | 0 | — |
case-20 | pass→pass | 13,303 | 12,841 | -3% | 1 | 1 | 0% | 2,415 | 4,718 | +95% | 0 | 0 | — |
case-21 | pass→pass | 8,403 | 10,287 | +22% | 1 | 1 | 0% | 1,563 | 4,196 | +168% | 0 | 0 | — |
case-22 | pass→pass | 14,289 | 15,040 | +5% | 1 | 1 | 0% | 3,061 | 5,671 | +85% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +18 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.