From ccfg-csharp
This skill should be used when writing .NET tests, creating xUnit test fixtures, using NSubstitute, testing ASP.NET Core applications, or improving test coverage with FluentAssertions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ccfg-csharp:testing-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill defines comprehensive testing patterns for .NET, covering xUnit conventions, NSubstitute
This skill defines comprehensive testing patterns for .NET, covering xUnit conventions, NSubstitute mocking, FluentAssertions, WebApplicationFactory, Testcontainers, and coverage standards.
Every test must clearly separate setup, execution, and verification.
// CORRECT: Clear AAA separation
[Fact]
public async Task GetByIdAsync_WhenProductExists_ReturnsProduct()
{
// Arrange
var productId = Guid.NewGuid();
var product = CreateTestProduct(productId);
_repository.FindByIdAsync(productId, Arg.Any<CancellationToken>())
.Returns(product);
// Act
var result = await _sut.GetByIdAsync(productId, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("Test Product");
}
// WRONG: Mixed setup and assertions
[Fact]
public async Task GetByIdAsync_ReturnsProduct()
{
var result = await _sut.GetByIdAsync(
SetupRepositoryAndReturnId(), CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("Test Product", result.Name);
_repository.Received(1); // Verification mixed with assertions
}
// CORRECT: Descriptive, consistent naming
[Fact]
public async Task CreateAsync_WithValidRequest_ReturnsCreatedProduct() { }
[Fact]
public async Task CreateAsync_WithDuplicateSku_ThrowsDuplicateException() { }
[Fact]
public async Task DeleteAsync_WhenProductNotFound_ThrowsNotFoundException() { }
[Fact]
public async Task GetAllAsync_WithSearchTerm_ReturnsFilteredResults() { }
// WRONG: Vague or inconsistent naming
[Fact]
public async Task TestCreate() { }
[Fact]
public async Task ShouldThrowWhenDuplicate() { }
[Fact]
public async Task Test_Delete_NotFound() { }
[Fact]
public async Task GetAll_Works() { }
// CORRECT: [Fact] for single-case tests
[Fact]
public void Constructor_WithNegativePrice_ThrowsArgumentOutOfRange()
{
var act = () => new Money(-1, "USD");
act.Should().Throw<ArgumentOutOfRangeException>();
}
// CORRECT: [Theory] with [InlineData] for multiple cases
[Theory]
[InlineData("", false)]
[InlineData("a", false)]
[InlineData("ab", false)]
[InlineData("abc", true)]
[InlineData("valid-slug", true)]
[InlineData("INVALID", false)]
[InlineData("has spaces", false)]
public void IsValidSlug_ReturnsExpected(string input, bool expected)
{
var result = SlugValidator.IsValid(input);
result.Should().Be(expected);
}
// WRONG: Separate tests for each case
[Fact]
public void IsValidSlug_EmptyString_ReturnsFalse()
{
SlugValidator.IsValid("").Should().BeFalse();
}
[Fact]
public void IsValidSlug_SingleChar_ReturnsFalse()
{
SlugValidator.IsValid("a").Should().BeFalse();
}
// ...many more identical tests
// CORRECT: [MemberData] for complex test data
[Theory]
[MemberData(nameof(ShippingTestCases))]
public void CalculateCost_ReturnsExpected(
Order order, decimal expectedCost)
{
var result = _sut.CalculateCost(order);
result.Should().Be(expectedCost);
}
public static IEnumerable<object[]> ShippingTestCases()
{
yield return [CreateDomesticOrder(weight: 0.5m), 5.99m];
yield return [CreateDomesticOrder(weight: 3.0m), 9.99m];
yield return [CreateInternationalOrder(weight: 1.5m), 24.99m];
}
// CORRECT: Constructor injection for setup
public class ProductServiceTests : IDisposable
{
private readonly IProductRepository _repository;
private readonly ILogger<ProductService> _logger;
private readonly ProductService _sut;
public ProductServiceTests()
{
_repository = Substitute.For<IProductRepository>();
_logger = Substitute.For<ILogger<ProductService>>();
_sut = new ProductService(_repository, _logger);
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}
// WRONG: Using [SetUp] (that is NUnit, not xUnit)
// xUnit creates a new instance per test, so constructor IS the setup
// CORRECT: IAsyncLifetime for async setup/teardown
public class IntegrationTests : IAsyncLifetime
{
private HttpClient _client = null!;
private WebApplicationFactory<Program> _factory = null!;
public async Task InitializeAsync()
{
_factory = new WebApplicationFactory<Program>();
_client = _factory.CreateClient();
await SeedDatabaseAsync();
}
public async Task DisposeAsync()
{
_client.Dispose();
await _factory.DisposeAsync();
}
}
// CORRECT: Substitutes created in constructor, SUT assembled from them
public class OrderServiceTests
{
private readonly IOrderRepository _repository;
private readonly IPaymentGateway _paymentGateway;
private readonly OrderService _sut;
public OrderServiceTests()
{
_repository = Substitute.For<IOrderRepository>();
_paymentGateway = Substitute.For<IPaymentGateway>();
_sut = new OrderService(_repository, _paymentGateway);
}
}
// WRONG: Creating mocks inline in each test
[Fact]
public async Task PlaceOrder_Succeeds()
{
var repo = Substitute.For<IOrderRepository>();
var payment = Substitute.For<IPaymentGateway>();
var sut = new OrderService(repo, payment);
// Every test recreates everything
}
// CORRECT: Returns for setting up return values
_repository.FindByIdAsync(productId, Arg.Any<CancellationToken>())
.Returns(product);
// Async returns
_repository.FindByIdAsync(productId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Product?>(product));
// Conditional returns
_repository.FindByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var id = callInfo.ArgAt<Guid>(0);
return id == knownId ? product : null;
});
// CORRECT: Verify interactions after Act
await _repository.Received(1)
.AddAsync(Arg.Is<Product>(p => p.Name == "Widget"),
Arg.Any<CancellationToken>());
await _repository.DidNotReceive()
.DeleteAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
// WRONG: Verify before Act (test passes vacuously)
await _repository.Received(1).AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>());
var result = await _sut.CreateAsync(request, CancellationToken.None);
// CORRECT: Throwing from async substitutes
_paymentGateway.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new PaymentDeclinedException("Insufficient funds"));
// WRONG: Returns(Task.FromException(...))
_paymentGateway.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PaymentResult>(new PaymentDeclinedException("Insufficient funds")));
// CORRECT: FluentAssertions
result.Should().NotBeNull();
result!.Name.Should().Be("Widget");
result.Price.Should().BeGreaterThan(0);
result.Tags.Should().Contain("electronics");
// WRONG: xUnit Assert class
Assert.NotNull(result);
Assert.Equal("Widget", result.Name);
Assert.True(result.Price > 0);
Assert.Contains("electronics", result.Tags);
// CORRECT: Chained assertions on collections
products.Should()
.NotBeEmpty()
.And.HaveCount(3)
.And.OnlyContain(p => p.Price > 0)
.And.BeInAscendingOrder(p => p.Name);
// WRONG: Separate assertion statements for related checks
products.Should().NotBeEmpty();
products.Should().HaveCount(3);
products.All(p => p.Price > 0).Should().BeTrue();
// CORRECT: Structural comparison with exclusions
actual.Should().BeEquivalentTo(expected, options => options
.Excluding(x => x.Id)
.Excluding(x => x.CreatedAt)
.WithStrictOrdering());
// WRONG: Comparing each property individually
actual.Name.Should().Be(expected.Name);
actual.Price.Should().Be(expected.Price);
actual.Category.Should().Be(expected.Category);
// Easy to miss a property
// CORRECT: FluentAssertions exception testing
await _sut.Invoking(s => s.DeleteAsync(id, CancellationToken.None))
.Should().ThrowAsync<NotFoundException>()
.WithMessage("*not found*");
// WRONG: Try-catch in tests
try
{
await _sut.DeleteAsync(id, CancellationToken.None);
Assert.Fail("Should have thrown");
}
catch (NotFoundException ex)
{
Assert.Contains("not found", ex.Message);
}
// CORRECT: Tolerant time assertion
result.CreatedAt.Should().BeCloseTo(
DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// WRONG: Exact time comparison (flaky)
result.CreatedAt.Should().Be(DateTimeOffset.UtcNow);
// CORRECT: Override services for integration tests
public class ApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove real DbContext
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor is not null) services.Remove(descriptor);
// Add test DbContext
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// Replace external services with substitutes
services.AddSingleton(Substitute.For<IExternalApi>());
});
builder.UseEnvironment("Testing");
}
}
// WRONG: Testing against real external services
public class ApiFactory : WebApplicationFactory<Program>
{
// No overrides - tests hit real database and external APIs
}
// CORRECT: Shared factory across tests in a class
public class ProductEndpointTests(ApiFactory factory)
: IClassFixture<ApiFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task GetProducts_ReturnsOk()
{
var response = await _client.GetAsync("/api/products");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
// WRONG: Creating a new factory per test (slow)
public class ProductEndpointTests
{
[Fact]
public async Task GetProducts_ReturnsOk()
{
await using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
// Factory startup cost for every test
}
}
// CORRECT: Container managed via IAsyncLifetime
public class DatabaseFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}
// CORRECT: Collection fixture shares one container across many test classes
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>;
[Collection("Database")]
public class ProductRepositoryTests(DatabaseFixture fixture)
{
[Fact]
public async Task AddProduct_PersistsToDatabase()
{
await using var dbContext = fixture.CreateDbContext();
// Test against real database
}
}
[Collection("Database")]
public class CategoryRepositoryTests(DatabaseFixture fixture)
{
// Shares the same container as ProductRepositoryTests
}
// WRONG: Each test class starts its own container (very slow)
public class ProductRepositoryTests : IAsyncLifetime
{
private MsSqlContainer _container = null!;
public async Task InitializeAsync()
{
_container = new MsSqlBuilder().Build();
await _container.StartAsync(); // 10+ seconds per test class
}
}
// CORRECT: Centralized test data factory
public static class TestDataFactory
{
public static Product CreateProduct(
string? name = null,
decimal? price = null,
ProductStatus? status = null) => new()
{
Id = new ProductId(Guid.NewGuid()),
Name = name ?? $"Product-{Guid.NewGuid():N}"[..20],
Price = price ?? 29.99m,
Status = status ?? ProductStatus.Active,
Sku = $"SKU-{Guid.NewGuid():N}"[..12],
CategoryId = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow
};
}
// WRONG: Duplicated test data in every test
[Fact]
public async Task Test1()
{
var product = new Product
{
Id = new ProductId(Guid.NewGuid()),
Name = "Test",
Price = 29.99m,
Status = ProductStatus.Active,
// 10 more properties...
};
}
[Fact]
public async Task Test2()
{
var product = new Product
{
// Same 10+ properties copied again...
};
}
// CORRECT: Each test sets up its own state
[Fact]
public async Task AddProduct_PersistsToDatabase()
{
await using var dbContext = fixture.CreateDbContext();
var repository = new ProductRepository(dbContext);
var product = TestDataFactory.CreateProduct(name: "Isolated Product");
await repository.AddAsync(product);
await dbContext.SaveChangesAsync();
// Verify in a fresh context
await using var verifyContext = fixture.CreateDbContext();
var saved = await verifyContext.Products.FindAsync(product.Id);
saved.Should().NotBeNull();
}
// WRONG: Tests share state and depend on execution order
private static Product? _sharedProduct;
[Fact]
public async Task Test1_CreateProduct()
{
_sharedProduct = await _sut.CreateAsync(request, CancellationToken.None);
_sharedProduct.Should().NotBeNull();
}
[Fact]
public async Task Test2_UpdateProduct()
{
// Depends on Test1 running first
await _sut.UpdateAsync(_sharedProduct!.Id, updateRequest, CancellationToken.None);
}
services.AddScoped)npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-csharpProvides C# and .NET testing patterns using xUnit, FluentAssertions, NSubstitute, Testcontainers, and WebApplicationFactory for unit and integration tests.
Testing strategy for .NET 10 applications using xUnit v3, WebApplicationFactory for integration tests, Testcontainers for real database testing, Verify for snapshot testing, and the AAA pattern.
Patterns for C# .NET testing using xUnit, FluentAssertions, NSubstitute/Moq, Testcontainers, and WebApplicationFactory for unit and integration tests.