nd Contract tests for API stability.
1. Unit Testing Foundation: xUnit + FluentAssertions
xUnit remains the standard for its extensibility and parallel execution model. FluentAssertions provides readable, chainable assertions that reduce assertion clutter.
Implementation:
// MyApp.Domain.Tests/OrderServiceTests.cs
using FluentAssertions;
using Xunit;
public class OrderServiceTests
{
[Theory]
[InlineData(100, 0.1, 90)]
[InlineData(50, 0, 50)]
public void ApplyDiscount_ValidInput_CalculatesCorrectTotal(decimal price, decimal discountRate, decimal expectedTotal)
{
// Arrange
var service = new OrderService();
// Act
var result = service.ApplyDiscount(price, discountRate);
// Assert
result.Should().Be(expectedTotal);
}
}
2. Integration Testing: Testcontainers for .NET
Avoid DockerCompose for tests. It introduces orchestration complexity and state leakage. Testcontainers for .NET provides a programmatic API to spin up ephemeral containers (PostgreSQL, Redis, RabbitMQ) per test class or method.
Architecture Decision:
Use IAsyncLifetime to manage container lifecycle efficiently. Start containers once per class to balance speed and isolation.
Implementation:
// MyApp.IntegrationTests/Repositories/OrderRepositoryTests.cs
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Xunit;
public class OrderRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer;
private IDbConnection _connection;
public OrderRepositoryTests()
{
_dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("testdb")
.WithUsername("user")
.WithPassword("password")
.Build();
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
_connection = new NpgsqlConnection(_dbContainer.GetConnectionString());
await _connection.OpenAsync();
// Run migrations or schema setup here
}
public async Task DisposeAsync()
{
await _dbContainer.StopAsync();
}
[Fact]
public async Task SaveOrder_PersistsData_Correctly()
{
// Act
var repo = new OrderRepository(_connection);
var order = new Order { Id = Guid.NewGuid(), Amount = 100 };
await repo.SaveAsync(order);
// Assert
var retrieved = await repo.GetByIdAsync(order.Id);
retrieved.Should().NotBeNull();
retrieved.Amount.Should().Be(100);
}
}
3. Web API Integration: WebApplicationFactory
For ASP.NET Core applications, WebApplicationFactory<T> allows in-process integration testing of HTTP endpoints without starting a real Kestrel server. This enables testing middleware, routing, and dependency injection configurations.
Implementation:
// MyApp.IntegrationTests/Api/OrderApiTests.cs
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
// Override dependencies for testing
builder.ConfigureServices(services =>
{
services.AddSingleton<IOrderRepository, InMemoryOrderRepository>();
});
}).CreateClient();
}
[Fact]
public async Task GetOrder_ReturnsOkResult()
{
// Act
var response = await _client.GetAsync("/api/orders/1");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("OrderId");
}
}
4. Snapshot Testing with Verify
For complex DTOs or API responses, manual assertion of every property is brittle. Verify captures a snapshot of the output and compares it against a stored file. This is ideal for serialization testing and API contract validation.
Implementation:
[Fact]
public Task SerializeOrder_ReturnsExpectedJson()
{
var order = new Order { Id = Guid.NewGuid(), Amount = 100 };
var json = JsonSerializer.Serialize(order);
// Verify creates/compares .received.txt vs .verified.txt
return Verify(json);
}
Pitfall Guide
- Over-Mocking Internal Dependencies: Mocking methods within the same assembly or internal helpers creates tests that verify implementation details rather than behavior. Best Practice: Mock only external boundaries (DB, HTTP, File System). Use real implementations for internal logic.
- Shared Static State: Tests modifying static variables, singletons, or static caches cause race conditions in parallel test runners. Best Practice: Ensure tests are idempotent. Use dependency injection to inject state. Reset state in
[Fact] setup or use unique test data per run.
- Ignoring
CancellationToken: Production code respects cancellation; test code often ignores it, masking deadlocks or timeout issues. Best Practice: Always pass CancellationToken.None or a real token in tests. Test timeout scenarios explicitly.
async void in Test Helpers: Using async void for test utility methods swallows exceptions and breaks test runners. Best Practice: Use async Task for all asynchronous methods. Return Task.CompletedTask for synchronous completions.
- Slow Teardown: Failing to dispose containers or connections promptly leads to resource exhaustion in CI. Best Practice: Implement
IAsyncDisposable or IDisposable rigorously. Use await using for resources. Testcontainers handle disposal automatically if IAsyncLifetime is implemented correctly.
- Testing Framework Features: Writing tests that verify how Moq or xUnit works (e.g., "Verify method was called 3 times") is anti-pattern unless the interaction is the business requirement. Best Practice: Focus on state changes and return values. Verify interactions only for side effects like "Email sent" or "Audit log created."
- Ignoring Test Project Structure: Mixing unit and integration tests in one project slows down local development. Best Practice: Separate
*.Tests (Unit) and *.IntegrationTests. Configure CI to run unit tests on every commit and integration tests on PR merge or nightly.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Pure Business Logic | xUnit + FluentAssertions | Fast, deterministic, zero infrastructure overhead. | Low |
| Database/EF Core | Testcontainers + Real DB | Validates migrations, SQL generation, and concurrency. | Medium |
| HTTP API Endpoints | WebApplicationFactory | Tests full middleware pipeline and DI configuration. | Low |
| External API Client | WireMock.NET | Stable, fast, no network dependency, contract validation. | Low |
| Blazor Components | bUnit | Renders components in memory, verifies UI logic. | Medium |
| Legacy Refactoring | Characterization Tests (Verify) | Captures current behavior safely before changes. | High Initial / Low Long-term |
| Performance Critical | BenchmarkDotNet | Measures execution time and memory allocation. | Low |
Configuration Template
xunit.runner.json
Place this in the test project root to optimize parallel execution and diagnostics.
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1,
"diagnosticMessages": true,
"methodDisplay": "Method"
}
GlobalUsings.cs
Modernize test code with global usings to reduce boilerplate.
global using FluentAssertions;
global using Xunit;
global using DotNet.Testcontainers.Builders;
global using DotNet.Testcontainers.Containers;
Dockerfile for CI Test Runner
Ensure CI environment matches local development.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
# Tests run in the build stage to fail fast
RUN dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage"
Quick Start Guide
- Initialize Test Project:
dotnet new xunit -n MyApp.IntegrationTests
cd MyApp.IntegrationTests
- Add Core Packages:
dotnet add package FluentAssertions
dotnet add package Testcontainers
dotnet add package Testcontainers.PostgreSql
dotnet add package Verify.Xunit
- Create Integration Test:
Create
DatabaseTests.cs implementing IAsyncLifetime with a PostgreSql container.
- Run Tests:
dotnet test --logger "console;verbosity=detailed"
- Integrate CI:
Add
dotnet test --collect:"XPlat Code Coverage" to your GitHub Actions or Azure DevOps pipeline. Ensure Docker is available in the runner for Testcontainers.
This article provides a technical baseline for modernizing .NET testing strategies. Implementation details should be adapted to specific architectural constraints and team maturity levels.