| name | dotnet-testing |
| description | Implements enterprise-grade testing strategies for .NET applications using xUnit, Moq, FluentAssertions, and WebApplicationFactory. Use when writing unit tests, integration tests, or API tests in .NET or C#, configuring test projects, deciding what to mock vs what to test with real dependencies, setting up shared test infrastructure with xUnit fixtures, implementing transaction rollback for database isolation, testing MediatR handlers, validating FluentValidation rules, structuring test projects in a Clean Architecture solution, or asked about test coverage, test isolation, AAA pattern, test doubles, or integration testing strategies in ASP.NET Core enterprise applications.
|
| license | MIT |
| metadata | {"author":"iamBrzDev","version":"1.0"} |
When to activate
- Writing a unit test or integration test in .NET / C#
- "What should I mock?" in a .NET testing context
- Setting up a test project for the first time in a solution
- Configuring WebApplicationFactory for ASP.NET Core API tests
- Implementing database isolation for integration tests
- Testing a MediatR Command, Query, or Handler
- Testing FluentValidation validators
- Setting up shared test infrastructure (Docker containers, DB migrations)
- Any question about xUnit fixtures, test collections, or parallel execution
- "How do I test this without hitting the real database?"
Rules — Non-negotiable
-
One Act per test. Strict AAA pattern: one action triggers one outcome.
A test that does two things hides which one failed.
-
Mock only external/unmanaged dependencies. Moq is for third-party APIs,
email services, payment gateways — not for repositories or domain services.
Test those with real implementations.
-
Integration tests use real databases. In-memory providers (UseInMemoryDatabase)
do not enforce foreign keys, SQL constraints, or stored procedures.
Use a real SQL Server instance (Docker) for integration tests.
-
Every test leaves zero side effects. Use transaction rollback or database
reset between tests. Never rely on test execution order.
-
Unit tests have zero infrastructure. No database, no file system,
no HTTP calls, no environment variables in unit tests. If you need them,
it's an integration test.
Test Project Structure
solution/
├── src/
│ ├── Domain/
│ ├── Application/
│ ├── Infrastructure/
│ └── API/
└── tests/
├── Domain.UnitTests/ # Pure domain logic — no mocks needed usually
├── Application.UnitTests/ # Handlers, validators — mock output ports
├── Infrastructure.IntegrationTests/ # Repositories against real DB
└── API.IntegrationTests/ # Full HTTP stack via WebApplicationFactory
Each test project mirrors the production project it covers.
Test class names: {ClassUnderTest}Tests.cs
Test method names: {MethodName}_{Scenario}_{ExpectedResult}
AAA Pattern — Strict Enforcement
[Fact]
public async Task Handle_WhenProductIsOutOfStock_ShouldThrowDomainException()
{
var productId = Guid.NewGuid();
var mockInventory = new Mock<IInventoryPort>();
mockInventory
.Setup(x => x.GetStockAsync(productId))
.ReturnsAsync(0);
var handler = new CreateOrderCommandHandler(mockInventory.Object);
var command = new CreateOrderCommand(productId, quantity: 5);
Func<Task> act = async () => await handler.Handle(command, CancellationToken.None);
await act.Should()
.ThrowAsync<DomainException>()
.WithMessage("*out of stock*");
}
One Arrange. One Act. One Assert area. Multiple assertions on the same
outcome are acceptable. Multiple Act calls are not.
What to Mock vs What Not to Mock
✅ Mock these (unmanaged/external dependencies):
- Third-party HTTP APIs (payment gateway, SMS provider, external ERP)
- Email/notification services
- Current time (IClock, IDateTimeProvider)
- Random/GUID generation when determinism matters
- Azure Blob Storage, Azure Service Bus
❌ Never mock these (test with real implementations):
- Your own repositories (test against real DB in integration tests)
- Domain services (test with real logic)
- MediatR pipeline (test handlers directly or via WebApplicationFactory)
- FluentValidation validators (test the real validator)
- EF Core DbContext (use real DB, not InMemory)
xUnit Fixtures — Shared Test Infrastructure
Class Fixture — shared within one test class
public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public OrderRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task GetById_WhenOrderExists_ReturnsOrder()
{
}
}
Collection Fixture — shared across multiple test classes
public class DatabaseFixture : IAsyncLifetime
{
public AppDbContext DbContext { get; private set; } = null!;
private IDbContextTransaction _transaction = null!;
public async Task InitializeAsync()
{
DbContext = CreateDbContext();
await DbContext.Database.MigrateAsync();
}
public async Task BeginTransactionAsync()
{
_transaction = await DbContext.Database.BeginTransactionAsync();
}
public async Task RollbackAsync()
{
await _transaction.RollbackAsync();
}
public async Task DisposeAsync()
{
await DbContext.DisposeAsync();
}
}
[CollectionDefinition(nameof(DatabaseCollection))]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
[Collection(nameof(DatabaseCollection))]
public class ProductRepositoryTests
{
private readonly DatabaseFixture _fixture;
public ProductRepositoryTests(DatabaseFixture fixture) => _fixture = fixture;
}
Database Isolation — Transaction Rollback Pattern
The safest isolation strategy: each test runs inside a transaction that rolls back.
public class OrderRepositoryIntegrationTests
: IClassFixture<DatabaseFixture>, IAsyncLifetime
{
private readonly DatabaseFixture _fixture;
public OrderRepositoryIntegrationTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync() => await _fixture.BeginTransactionAsync();
public async Task DisposeAsync() => await _fixture.RollbackAsync();
[Fact]
public async Task Save_ValidOrder_PersistsToDatabase()
{
var repository = new OrderRepository(_fixture.DbContext);
var order = Order.Create(Guid.NewGuid(), "customer-1");
await repository.SaveAsync(order);
await _fixture.DbContext.SaveChangesAsync();
var saved = await repository.GetByIdAsync(order.Id);
saved.Should().NotBeNull();
saved!.CustomerId.Should().Be("customer-1");
}
}
Integration Tests — WebApplicationFactory
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(TestConnectionString));
});
}
public async Task InitializeAsync() => await ResetDatabaseAsync();
public new async Task DisposeAsync() => await base.DisposeAsync();
}
[Collection(nameof(ApiCollection))]
public class OrdersEndpointTests
{
private readonly HttpClient _client;
public OrdersEndpointTests(ApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task POST_Orders_WithValidRequest_Returns201()
{
var request = new { ProductId = Guid.NewGuid(), Quantity = 2 };
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<OrderResponse>();
body!.Id.Should().NotBeEmpty();
}
}
Testing MediatR Handlers
Test handlers directly — do not mock MediatR itself:
[Fact]
public async Task CreateOrderHandler_WithValidCommand_ReturnsOrderId()
{
var mockInventory = new Mock<IInventoryPort>();
mockInventory.Setup(x => x.IsAvailableAsync(It.IsAny<Guid>(), It.IsAny<int>()))
.ReturnsAsync(true);
var handler = new CreateOrderCommandHandler(
_fixture.OrderRepository,
mockInventory.Object
);
var command = new CreateOrderCommand(Guid.NewGuid(), quantity: 1);
var result = await handler.Handle(command, CancellationToken.None);
result.OrderId.Should().NotBeEmpty();
}
Testing FluentValidation
[Fact]
public void Validate_WhenQuantityIsZero_ShouldHaveValidationError()
{
var validator = new CreateOrderCommandValidator();
var command = new CreateOrderCommand(Guid.NewGuid(), quantity: 0);
var result = validator.Validate(command);
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle(e =>
e.PropertyName == nameof(CreateOrderCommand.Quantity) &&
e.ErrorMessage.Contains("greater than"));
}
Common mistakes
-
❌ UseInMemoryDatabase() for integration tests — misses SQL constraints and FK violations
-
✅ Use a real SQL Server in Docker (Testcontainers or a dedicated test instance)
-
❌ [Theory] with 20 inline data cases that all test the same code path
-
✅ Group data-driven tests by behavior, not by data variation
-
❌ Static shared state between tests (static List<Order> _orders)
-
✅ Each test creates its own data; transaction rollback handles cleanup
-
❌ Mocking IOrderRepository in integration tests to avoid the database
-
✅ That makes it a unit test — name it and place it accordingly
-
❌ Assert.True(result != null) — no context when it fails
-
✅ result.Should().NotBeNull("because a valid command must return an order")
Definition of Done
A test suite built with this skill is complete only when:
Reference files
Load on demand:
references/testcontainers-setup.md — SQL Server in Docker for integration tests
references/handler-testing-patterns.md — full patterns for CQRS handler test suites
references/coverage-thresholds.md — configuring coverage gates in Azure Pipelines