Testing
xUnit testing, integration testing with WebApplicationFactory, and mocking in .NET
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 linesTesting — 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_ExpectedResultfor 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
WebApplicationFactoryover manually constructingHttpClientor 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
IClassFixtureto share expensive setup (like database containers) across tests in a class, andICollectionFixtureto 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
IClassFixturestate 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
InMemoryDatabasefor 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
HttpClientfromWebApplicationFactory— whileCreateClient()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
Related Skills
Aspnet Minimal API
ASP.NET Minimal APIs for building lightweight HTTP endpoints in .NET
Async Patterns
Async/await patterns, Task-based concurrency, and cancellation in C#/.NET
Blazor
Blazor component model, rendering modes, and interactive web UI development in .NET
Configuration
.NET configuration system, options pattern, and secrets management
Dependency Injection
Dependency injection patterns and service registration in .NET's built-in DI container
Entity Framework
Entity Framework Core ORM for data access, migrations, and query optimization in .NET