| name | dck-testing |
| description | Use when adding or fixing AHKFlowApp tests with xUnit, WebApplicationFactory, Testcontainers, or bUnit. |
Testing (.NET 10)
Core Principles
- Integration tests are highest-value — A single
WebApplicationFactory test covers routing, binding, the validating use-case decorator, handler, and SQL Server persistence in one shot.
- SQL Server Testcontainers — never in-memory — Use
MsSqlContainer from Testcontainers.MsSql. In-memory providers hide real SQL Server behavior (transactions, constraints, retry logic).
- AAA pattern is mandatory — Every test: Arrange, Act, Assert — separated by blank lines.
- Test behavior, not implementation — Assert on HTTP responses, database state, and
Result status. Not which internal methods were called.
- FluentAssertions over Assert — More readable failure messages.
result.IsSuccess.Should().BeTrue() not Assert.True(result.IsSuccess).
Test Projects
tests/
AHKFlowApp.API.Tests/ # Integration tests — WebApplicationFactory + Testcontainers
AHKFlowApp.Application.Tests/ # Validator unit tests + handler unit tests
AHKFlowApp.Domain.Tests/ # Domain entity logic unit tests
AHKFlowApp.Infrastructure.Tests/ # EF Core + migration tests with Testcontainers
AHKFlowApp.UI.Blazor.Tests/ # Blazor component tests (bUnit)
Patterns
WebApplicationFactory Fixture (SQL Server)
[CollectionDefinition("WebApi")]
public sealed class WebApiCollection : ICollectionFixture<SqlContainerFixture>;
public sealed class CustomWebApplicationFactory(
SqlContainerFixture sqlFixture) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Auth:UseTestProvider", "false");
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.RemoveAll<AppDbContext>();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(sqlFixture.ConnectionString,
sql => sql.EnableRetryOnFailure()));
});
}
}
Integration Test (API Endpoint)
[Collection("WebApi")]
public sealed class HotstringsEndpointsTests(SqlContainerFixture sqlFixture) : IDisposable
{
private readonly CustomWebApplicationFactory _factory = new(sqlFixture);
private HttpClient CreateAuthed(Guid? oid = null) =>
_factory.WithTestAuth(b => b.WithOid(oid ?? Guid.NewGuid())).CreateClient();
[Fact]
public async Task Post_CreatesAndReturns201WithLocation()
{
using HttpClient client = CreateAuthed();
var dto = new CreateHotstringDto("btw", "by the way");
var response = await client.PostAsJsonAsync("/api/v1/hotstrings", dto);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<HotstringDto>();
result.Should().NotBeNull();
result!.Trigger.Should().Be("btw");
}
[Fact]
public async Task Post_InvalidBody_Returns400()
{
using HttpClient client = CreateAuthed();
var dto = new CreateHotstringDto("", "by the way");
var response = await client.PostAsJsonAsync("/api/v1/hotstrings", dto);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task GetById_NotFound_Returns404()
{
using HttpClient client = CreateAuthed();
var response = await client.GetAsync("/api/v1/hotstrings/99999");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
public void Dispose() => _factory.Dispose();
}
Use-Case Handler Unit Test
Test the handler directly with a real (Testcontainers) database. Assert on Result status.
public sealed class CreateHotstringHandlerTests : IAsyncLifetime
{
private readonly MsSqlContainer _mssql = new MsSqlBuilder().Build();
private AppDbContext _db = null!;
public async Task InitializeAsync()
{
await _mssql.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_mssql.GetConnectionString())
.Options;
_db = new AppDbContext(options);
await _db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
await _mssql.DisposeAsync();
}
[Fact]
public async Task Handle_ValidCommand_ReturnsSuccess()
{
var handler = new CreateHotstringHandler(_db);
var command = new CreateHotstringCommand("btw", "by the way");
var result = await handler.ExecuteAsync(command, CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value.Trigger.Should().Be("btw");
result.Value.Replacement.Should().Be("by the way");
}
[Fact]
public async Task Handle_DuplicateTrigger_ReturnsConflict()
{
var handler = new CreateHotstringHandler(_db);
var command = new CreateHotstringCommand("btw", "by the way");
await handler.ExecuteAsync(command, CancellationToken.None);
var result = await handler.ExecuteAsync(command, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
result.Status.Should().Be(ResultStatus.Conflict);
}
}
FluentValidation Validator Unit Test
Validators are pure functions — no database needed.
public sealed class CreateHotstringValidatorTests
{
private readonly CreateHotstringValidator _validator = new();
[Fact]
public async Task Validate_ValidCommand_PassesValidation()
{
var command = new CreateHotstringCommand("btw", "by the way");
var result = await _validator.ValidateAsync(command);
result.IsValid.Should().BeTrue();
}
[Theory]
[InlineData("", "by the way")]
[InlineData("btw", "")]
public async Task Validate_InvalidFields_FailsValidation(string trigger, string replacement)
{
var command = new CreateHotstringCommand(trigger, replacement);
var result = await _validator.ValidateAsync(command);
result.IsValid.Should().BeFalse();
}
}
Ardalis.Result Assertions
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.Trigger.Should().Be("btw");
result.IsSuccess.Should().BeFalse();
result.Status.Should().Be(ResultStatus.NotFound);
result.Status.Should().Be(ResultStatus.Invalid);
result.ValidationErrors.Should().NotBeEmpty();
result.Status.Should().Be(ResultStatus.Conflict);
Shared Fixture for Expensive Setup
Use IClassFixture<T> when multiple test classes share the same database container.
[Collection("WebApi")]
public sealed class HotstringsEndpointsTests(SqlContainerFixture sqlFixture) : IDisposable
{
private readonly CustomWebApplicationFactory _factory = new(sqlFixture);
}
Testing Time-Dependent Code
[Fact]
public async Task Handle_SetsCreatedAt()
{
var clock = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero));
var handler = new CreateHotstringHandler(_db, clock);
var command = new CreateHotstringCommand("btw", "by the way");
var result = await handler.ExecuteAsync(command, CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value.CreatedAt.Should().Be(clock.GetUtcNow());
}
Test Naming Convention
Pattern: MethodName_Scenario_ExpectedResult
[Fact] public async Task Create_ValidRequest_Returns201() { }
[Fact] public async Task Create_EmptyTrigger_Returns400() { }
[Fact] public async Task GetById_HotstringExists_Returns200() { }
[Fact] public async Task GetById_NotFound_Returns404() { }
[Fact] public async Task Handle_DuplicateTrigger_ReturnsConflict() { }
[Fact] public async Task Validate_EmptyTrigger_FailsValidation() { }
Anti-patterns
Don't Use In-Memory Database
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(_mssql.GetConnectionString()));
Don't Mock What You Own
var mockDb = Substitute.For<AppDbContext>();
var db = new AppDbContext(options);
Don't Test Implementation Details
mockCreateHotstringUseCase.Received(1)
.ExecuteAsync(Arg.Any<CreateHotstringCommand>(), Arg.Any<CancellationToken>());
response.StatusCode.Should().Be(HttpStatusCode.Created);
var persisted = await db.Hotstrings.FirstOrDefaultAsync(h => h.Trigger == "btw");
persisted.Should().NotBeNull();
Don't Use PostgreSQL Testcontainers
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().Build();
private readonly MsSqlContainer _mssql = new MsSqlBuilder().Build();
Decision Guide
| Scenario | Recommendation |
|---|
| Testing an API endpoint | WebApplicationFactory + MsSqlContainer integration test |
| Testing a handler | Real AppDbContext + MsSqlContainer |
| Testing a validator | Unit test — no database needed |
| Testing domain entity logic | Unit test — pure C# |
| Database-dependent tests | Testcontainers.MsSql — never in-memory |
| Time-dependent logic | FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing |
| Shared expensive fixture | IClassFixture<T> with IAsyncLifetime |
| Asserting Result outcomes | result.IsSuccess.Should().BeTrue(), result.Status.Should().Be(ResultStatus.NotFound) |
| Parameterized cases | [Theory] with [InlineData] |