Skip to main content
Technology & EngineeringCsharp Dotnet305 lines

Testing

xUnit testing, integration testing with WebApplicationFactory, and mocking in .NET

Quick Summary17 lines
You are an expert in testing .NET applications with xUnit, integration testing, and mocking strategies.

## Key Points

- **Name tests with the pattern** `MethodUnderTest_Scenario_ExpectedResult` for immediate clarity on what failed.
- **Use one assertion concept per test** — each test should verify one logical behavior, though multiple `Should()` calls on the same result are fine.
- **Prefer `WebApplicationFactory`** over manually constructing `HttpClient` or mocking HTTP pipelines; it tests the real middleware stack.
- **Use Testcontainers** for integration tests against real databases rather than `InMemoryDatabase`, which does not enforce constraints or support SQL features.
- **Use `IClassFixture`** to share expensive setup (like database containers) across tests in a class, and `ICollectionFixture` to share across multiple classes.
- **Isolate test data** — each test should create its own data and not depend on shared state from other tests.
- **Shared mutable state between tests** — xUnit creates a new class instance per test, but `IClassFixture` state is shared. Use unique identifiers or separate database schemas.
- **Testing implementation instead of behavior** — verifying that a specific method was called in a certain order makes tests brittle. Verify outcomes instead.
- **Using `InMemoryDatabase` for EF Core and expecting SQL behavior** — it does not enforce foreign keys, unique constraints, or run SQL. Use SQLite or Testcontainers for realistic tests.
- **Not disposing `HttpClient` from `WebApplicationFactory`** — while `CreateClient()` clients are disposed with the factory, custom handlers need explicit disposal.
- **Ignoring test parallelism** — xUnit runs test classes in parallel by default. Tests sharing a database or file system resource will conflict unless isolated or serialized with `[Collection]`.
skilldb get csharp-dotnet-skills/TestingFull skill: 305 lines
Paste into your CLAUDE.md or agent config

Testing — C#/.NET

You are an expert in testing .NET applications with xUnit, integration testing, and mocking strategies.

Core Philosophy

Overview

The .NET testing ecosystem centers on xUnit as the predominant test framework, supplemented by NSubstitute or Moq for mocking, FluentAssertions for readable assertions, and WebApplicationFactory for integration testing ASP.NET Core applications against a real HTTP pipeline without network overhead.

Core Concepts

xUnit Test Structure

public class OrderServiceTests
{
    private readonly IOrderRepository _repo = Substitute.For<IOrderRepository>();
    private readonly IEmailSender _email = Substitute.For<IEmailSender>();
    private readonly ILogger<OrderService> _logger = Substitute.For<ILogger<OrderService>>();
    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        _sut = new OrderService(_repo, _email, _logger);
    }

    [Fact]
    public async Task PlaceOrder_WithValidRequest_CreatesOrderAndSendsEmail()
    {
        // Arrange
        var request = new CreateOrderRequest("customer-1", [new("prod-1", 2)]);
        var expectedOrder = new Order { Id = 42, CustomerId = "customer-1" };

        _repo.CreateAsync(Arg.Any<CreateOrderRequest>())
            .Returns(expectedOrder);

        // Act
        var result = await _sut.PlaceOrderAsync(request);

        // Assert
        result.Should().NotBeNull();
        result.Id.Should().Be(42);

        await _email.Received(1).SendOrderConfirmationAsync(
            Arg.Is<Order>(o => o.Id == 42));
    }

    [Fact]
    public async Task PlaceOrder_WhenRepoFails_ThrowsAndDoesNotSendEmail()
    {
        _repo.CreateAsync(Arg.Any<CreateOrderRequest>())
            .ThrowsAsync(new DatabaseException("Connection failed"));

        var act = () => _sut.PlaceOrderAsync(
            new CreateOrderRequest("c-1", [new("p-1", 1)]));

        await act.Should().ThrowAsync<DatabaseException>();
        await _email.DidNotReceiveWithAnyArgs().SendOrderConfirmationAsync(default!);
    }
}

Theory and InlineData

[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(100, true)]
[InlineData(-1, false)]
public void IsValidQuantity_ReturnsExpected(int quantity, bool expected)
{
    var result = OrderValidator.IsValidQuantity(quantity);
    result.Should().Be(expected);
}

[Theory]
[MemberData(nameof(GetDiscountTestCases))]
public void CalculateDiscount_ReturnsCorrectAmount(
    decimal subtotal, string promoCode, decimal expectedDiscount)
{
    var result = PricingService.CalculateDiscount(subtotal, promoCode);
    result.Should().Be(expectedDiscount);
}

public static IEnumerable<object[]> GetDiscountTestCases()
{
    yield return [100m, "SAVE10", 10m];
    yield return [100m, "SAVE20", 20m];
    yield return [50m, "SAVE10", 5m];
    yield return [100m, "INVALID", 0m];
}

Implementation Patterns

Integration Testing with WebApplicationFactory

public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    private readonly WebApplicationFactory<Program> _factory;

    public OrderApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real database with in-memory
                services.RemoveAll<DbContextOptions<AppDbContext>>();
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase("TestDb-" + Guid.NewGuid()));

                // Replace external services with fakes
                services.RemoveAll<IEmailSender>();
                services.AddSingleton<IEmailSender, FakeEmailSender>();
            });
        });

        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task CreateOrder_ReturnsCreatedWithLocation()
    {
        var request = new { CustomerId = "c-1", Items = new[] { new { ProductId = "p-1", Quantity = 2 } } };

        var response = await _client.PostAsJsonAsync("/api/v1/orders", request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();

        var order = await response.Content.ReadFromJsonAsync<OrderDto>();
        order!.CustomerId.Should().Be("c-1");
    }

    [Fact]
    public async Task GetOrder_NotFound_Returns404WithProblemDetails()
    {
        var response = await _client.GetAsync("/api/v1/orders/99999");

        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }
}

Custom WebApplicationFactory

public class CustomApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<DbContextOptions<AppDbContext>>();
            services.AddDbContext<AppDbContext>(options =>
                options.UseNpgsql(_postgres.GetConnectionString()));
        });
    }

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        // Run migrations
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.Database.MigrateAsync();
    }

    public new async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
        await base.DisposeAsync();
    }
}

// Usage with Testcontainers
public class OrderApiTests(CustomApiFactory factory) : IClassFixture<CustomApiFactory>
{
    private readonly HttpClient _client = factory.CreateClient();

    [Fact]
    public async Task FullOrderWorkflow()
    {
        // Create
        var createResponse = await _client.PostAsJsonAsync("/api/v1/orders",
            new CreateOrderRequest("c-1", [new("p-1", 3)]));
        createResponse.StatusCode.Should().Be(HttpStatusCode.Created);

        var created = await createResponse.Content.ReadFromJsonAsync<OrderDto>();

        // Read
        var getResponse = await _client.GetAsync($"/api/v1/orders/{created!.Id}");
        var fetched = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
        fetched!.Id.Should().Be(created.Id);

        // Cancel
        var cancelResponse = await _client.DeleteAsync($"/api/v1/orders/{created.Id}");
        cancelResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
    }
}

Testing with Time Abstractions

public class SubscriptionServiceTests
{
    private readonly FakeTimeProvider _time = new();
    private readonly SubscriptionService _sut;

    public SubscriptionServiceTests()
    {
        _sut = new SubscriptionService(_time);
    }

    [Fact]
    public void IsExpired_BeforeExpiry_ReturnsFalse()
    {
        _time.SetUtcNow(new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero));

        var subscription = new Subscription
        {
            ExpiresAt = new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc)
        };

        _sut.IsExpired(subscription).Should().BeFalse();
    }

    [Fact]
    public void IsExpired_AfterExpiry_ReturnsTrue()
    {
        _time.SetUtcNow(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero));

        var subscription = new Subscription
        {
            ExpiresAt = new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc)
        };

        _sut.IsExpired(subscription).Should().BeTrue();
    }
}

Snapshot Testing with Verify

[Fact]
public async Task GetOrderDto_MatchesSnapshot()
{
    var order = new OrderDto(
        Id: 1,
        CustomerId: "c-1",
        Status: "Pending",
        Items: [new OrderItemDto("Widget", 2, 9.99m)],
        Total: 19.98m);

    await Verify(order);
}
// First run creates OrderApiTests.GetOrderDto_MatchesSnapshot.verified.txt
// Subsequent runs compare against it

Best Practices

  • Name tests with the pattern MethodUnderTest_Scenario_ExpectedResult for immediate clarity on what failed.
  • Use one assertion concept per test — each test should verify one logical behavior, though multiple Should() calls on the same result are fine.
  • Prefer WebApplicationFactory over manually constructing HttpClient or mocking HTTP pipelines; it tests the real middleware stack.
  • Use Testcontainers for integration tests against real databases rather than InMemoryDatabase, which does not enforce constraints or support SQL features.
  • Use IClassFixture to share expensive setup (like database containers) across tests in a class, and ICollectionFixture to share across multiple classes.
  • Isolate test data — each test should create its own data and not depend on shared state from other tests.

Common Pitfalls

  • Shared mutable state between tests — xUnit creates a new class instance per test, but IClassFixture state is shared. Use unique identifiers or separate database schemas.
  • Testing implementation instead of behavior — verifying that a specific method was called in a certain order makes tests brittle. Verify outcomes instead.
  • Using InMemoryDatabase for EF Core and expecting SQL behavior — it does not enforce foreign keys, unique constraints, or run SQL. Use SQLite or Testcontainers for realistic tests.
  • Not disposing HttpClient from WebApplicationFactory — while CreateClient() clients are disposed with the factory, custom handlers need explicit disposal.
  • Ignoring test parallelism — xUnit runs test classes in parallel by default. Tests sharing a database or file system resource will conflict unless isolated or serialized with [Collection].

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add csharp-dotnet-skills

Get CLI access →