epository, _inventory);
}
[Fact]
public async Task PlaceOrder_ShouldReserveInventory_WhenStockAvailable()
{
// Arrange
var order = new Order { ProductId = "SKU-100", Quantity = 2 };
_inventory.CheckAvailabilityAsync("SKU-100", 2).Returns(true);
_repository.AddAsync(Arg.Any<Order>()).Returns(Task.CompletedTask);
// Act
await _sut.PlaceOrderAsync(order);
// Assert
await _inventory.Received(1).ReserveAsync("SKU-100", 2);
await _repository.Received(1).AddAsync(Arg.Is<Order>(o => o.Status == OrderStatus.Reserved));
}
}
Architecture decision: Avoid static test data. Inject dependencies via constructors. Use `[Fact]` for deterministic tests and `[Theory]` with `[InlineData]` for parameterized scenarios. Parallel execution is enabled by default in xUnit; configure via `xunit.runner.json` if needed.
### Step 2: Integration Testing with Testcontainers
Integration tests must validate data access, message queues, and external API contracts against real infrastructure. In-memory databases hide transaction, indexing, and concurrency bugs. Use `Testcontainers.NET` to spin up ephemeral PostgreSQL, Redis, or RabbitMQ instances per test session.
```csharp
public class PostgresIntegrationTest : IClassFixture<PostgresContainerFixture>
{
private readonly PostgresContainerFixture _fixture;
public PostgresIntegrationTest(PostgresContainerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task UserRepository_ShouldPersistAndRetrieveUser()
{
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
var user = new User { Email = "dev@codcompass.io", CreatedAt = DateTimeOffset.UtcNow };
await using var cmd = new NpgsqlCommand(
"INSERT INTO users (email, created_at) VALUES (@email, @created)", connection);
cmd.Parameters.AddWithValue("@email", user.Email);
cmd.Parameters.AddWithValue("@created", user.CreatedAt);
await cmd.ExecuteNonQueryAsync();
await using var readCmd = new NpgsqlCommand("SELECT email FROM users WHERE email = @email", connection);
readCmd.Parameters.AddWithValue("@email", user.Email);
var result = await readCmd.ExecuteScalarAsync();
result.Should().Be(user.Email);
}
}
public class PostgresContainerFixture : IAsyncLifetime
{
public PostgreSqlContainer Container { get; } = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("integration_test")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => Container.GetConnectionString();
public async Task InitializeAsync() => await Container.StartAsync();
public async Task DisposeAsync() => await Container.StopAsync();
}
Architecture decision: Containers are reused across tests in the same fixture but destroyed after the session. This guarantees schema isolation and prevents state leakage. EF Core integration tests should target the same container to validate migrations, indexes, and query translation.
Step 3: Contract Testing with WireMock.Net
API consumers and providers drift when contracts are not enforced. Use WireMock.Net to simulate external services with deterministic request/response matching.
public class PaymentGatewayClientTests
{
private readonly WireMockServer _server;
private readonly PaymentGatewayClient _client;
public PaymentGatewayClientTests()
{
_server = WireMockServer.Start();
_client = new PaymentGatewayClient(_server.Urls[0]);
_server.Given(Request.Create().WithPath("/v1/charge").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithBody("{\"transaction_id\": \"txn_123\", \"status\": \"succeeded\"}"));
}
[Fact]
public async Task ChargeAsync_ShouldReturnTransactionId()
{
var result = await _client.ChargeAsync("card_tok_visa", 1500m);
result.TransactionId.Should().Be("txn_123");
result.Status.Should().Be("succeeded");
}
public void Dispose() => _server.Stop();
}
Architecture decision: WireMock runs in-process. No network overhead. Validate serialization, retry logic, and error mapping without hitting third-party rate limits or sandbox environments.
Step 4: E2E Testing with Playwright for .NET
UI and full-stack flows require browser automation. Playwright for .NET provides auto-waiting, network interception, and parallel context isolation.
public class CheckoutFlowTests
{
private readonly IPlaywright _playwright;
private readonly IBrowser _browser;
public CheckoutFlowTests()
{
_playwright = Playwright.CreateAsync().Result;
_browser = _playwright.Chromium.LaunchAsync(new() { Headless = true }).Result;
}
[Fact]
public async Task CompletePurchase_ShouldShowConfirmation()
{
var context = await _browser.NewContextAsync();
var page = await context.NewPageAsync();
await page.GotoAsync("http://localhost:5000/checkout");
await page.FillAsync("#card-number", "4111111111111111");
await page.ClickAsync("#submit-payment");
await page.WaitForURLAsync("**/confirmation");
var heading = await page.TextContentAsync("h1");
heading.Should().Contain("Payment Successful");
await context.CloseAsync();
}
}
Architecture decision: Each test gets a fresh browser context. Use --parallel in CI. Target staging or ephemeral environments, never production. Intercept network calls to mock third-party payment gateways when testing UI flow.
Pitfall Guide
-
Over-mocking domain boundaries
Mocking repositories, message buses, and external clients in unit tests creates false confidence. When dependencies change shape, mocks pass while production fails. Fix: Mock only external systems. Use real EF Core with Testcontainers for data access tests. Validate contracts, not implementation.
-
Testing implementation details
Asserting on private method calls, internal state mutations, or exact SQL queries ties tests to code structure rather than behavior. Refactoring breaks tests without changing functionality. Fix: Assert on observable outputs: return values, state transitions, emitted events, and HTTP responses.
-
Shared mutable test state
Static fields, singleton fixtures, or uncleaned databases cause test order dependency. Tests pass locally but fail in CI due to parallel execution. Fix: Use [IClassFixture] for shared infrastructure, [ICollectionFixture] for cross-class state, and always clean or recreate data per test. Prefer ephemeral containers over shared databases.
-
Ignoring deterministic time
DateTime.Now or TimeSpan calls in tests produce flaky results across time zones, daylight saving, and CI runners. Fix: Inject IClock or IDateTimeProvider. Use System.TimeProvider in .NET 8+. Freeze time in tests using libraries like Bogus or custom test doubles.
-
Running tests sequentially in CI
Legacy runners execute tests in a single process. Modern .NET test SDKs support parallel execution by default. Fix: Configure dotnet test with --parallel and --maxCpuCount. Split slow integration tests into separate pipeline stages. Use test impact analysis to run only affected suites on PR.
-
Using in-memory databases for integration tests
UseInMemoryDatabase() in EF Core bypasses SQL translation, indexing, transactions, and concurrency controls. Tests pass locally but fail in production against PostgreSQL or SQL Server. Fix: Target the same database engine as production via Testcontainers. Validate migrations, query plans, and isolation levels.
-
Skipping contract testing until late in development
API drift causes downstream failures in microservices. Fix: Implement consumer-driven contracts early. Use WireMock or Pact to validate request/response shapes before backend implementation. Run contract tests in CI on every PR.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Monolithic .NET app with EF Core | xUnit + Testcontainers (PostgreSQL) | Validates real SQL, migrations, and transactions | Low infrastructure cost, high reliability |
| Microservices with external APIs | WireMock.Net + Pact | Prevents contract drift without sandbox dependencies | Reduces third-party costs, accelerates PR reviews |
| High-traffic web application | Playwright for .NET + parallel contexts | Catches UI/regression bugs before staging | Moderate CI compute, prevents production rollbacks |
| Legacy .NET Framework migration | NUnit + Moq + Dockerized SQL Server | Maintains familiarity while modernizing infrastructure | High initial setup, reduces migration risk |
| Serverless/Azure Functions | xUnit + Azure Storage Emulator + Testcontainers | Isolates triggers and bindings without cloud costs | Low cloud spend, improves local dev velocity |
Configuration Template
// xunit.runner.json
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": 0,
"diagnosticMessages": true,
"methodDisplay": "method"
}
<!-- Directory.Build.props (Test Projects) -->
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsTestProject>true</IsTestProject>
<EnableNUnitDefaultItems>false</EnableNUnitDefaultItems>
<RunAnalyzersDuringBuild>false</RunAnalyzersDuringBuild>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Testcontainers" Version="3.8.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.8.0" />
<PackageReference Include="WireMock.Net" Version="1.5.43" />
<PackageReference Include="Microsoft.Playwright" Version="1.43.0" />
</ItemGroup>
</Project>
Quick Start Guide
- Create a new test project:
dotnet new xunit -n MyApp.Tests
- Install dependencies:
dotnet add package Testcontainers.PostgreSql WireMock.Net Microsoft.Playwright FluentAssertions NSubstitute
- Add
xunit.runner.json to the project root and set Copy to Output Directory to Copy if newer
- Create a
PostgresContainerFixture implementing IAsyncLifetime and reference it via [IClassFixture]
- Run
dotnet test --parallel --logger "console;verbosity=detailed" to validate parallel execution and container lifecycle
This strategy delivers deterministic feedback, scales with team size, and aligns test architecture with modern .NET runtime characteristics. Implement layer by layer. Measure execution time, flake rate, and bug escape rate. Iterate until CI feedback consistently stays under 3 minutes.