Skip to main content
Kodelyth ECC
Skill

csharp-testing

C# and .NET testing patterns with xUnit, FluentAssertions, mocking, integration tests, and test organization best practices.

Invoke via:use csharp-testing
Origin:ECC

C# Testing Patterns

Comprehensive testing patterns for .NET applications using xUnit, FluentAssertions, and modern testing practices.

When to Activate

  • Writing new tests for C# code
  • Reviewing test quality and coverage
  • Setting up test infrastructure for .NET projects
  • Debugging flaky or slow tests

Test Framework Stack

| Tool | Purpose | |---|---| | xUnit | Test framework (preferred for .NET) | | FluentAssertions | Readable assertion syntax | | NSubstitute or Moq | Mocking dependencies | | Testcontainers | Real infrastructure in integration tests | | WebApplicationFactory | ASP.NET Core integration tests | | Bogus | Realistic test data generation |

Unit Test Structure

Arrange-Act-Assert

public 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"); } }

Parameterized Tests with Theory

[Theory]
[InlineData("", false)]
[InlineData("a", false)]
[InlineData("[email protected]", false)]
[InlineData("[email protected]", true)]
[InlineData("[email protected]", 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" }, };

Mocking with NSubstitute

[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>()); }

ASP.NET Core Integration Tests

WebApplicationFactory Setup

public 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(); } }

Testing with Testcontainers

public 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); } }

Test Organization

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.cs

Test Data Builders

public 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();

Common Anti-Patterns

| Anti-Pattern | Fix | |---|---| | Testing implementation details | Test behavior and outcomes | | Shared mutable test state | Fresh instance per test (xUnit does this via constructors) | | Thread.Sleep in async tests | Use Task.Delay with timeout, or polling helpers | | Asserting on ToString() output | Assert on typed properties | | One giant assertion per test | One logical assertion per test | | Test names describing implementation | Name by behavior: Method_ExpectedResult_WhenCondition | | Ignoring CancellationToken | Always pass and verify cancellation |

Running Tests

# 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/