Instruction file imported from klod68/littlerae (
.github/instructions/tdd.instructions.md). Copyright stays with the author.
--- scope: "tdd" applyTo: "tests/,src//*.cs" priority: "feature"
Test-Driven Development — Standards
Apply these rules for all .NET projects using xUnit. TDD is not optional — it is the workflow. Red → Green → Refactor, in that order.
The Discipline
- Red — Write a failing test that describes the desired behavior. It must fail for the right reason (not a compile error — a behavioral assertion failure).
- Green — Write the minimum production code to make the test pass. No more.
- Refactor — Improve the structure of both test and production code without changing behavior. Run tests after every change.
Test Boundary Matrix
| Layer | Test Type | Project | Tools | What to Test |
|---|---|---|---|---|
| Domain | Unit | {Solution}.Tests.Unit |
xUnit, FluentAssertions | Entity factory methods, invariant enforcement, domain method outcomes, value object equality |
| Application | Unit | {Solution}.Tests.Unit |
xUnit, FluentAssertions, NSubstitute | Handler logic, validator rules, pipeline behavior ordering, Result outcomes |
| Infrastructure | Integration | {Solution}.Tests.Integration |
xUnit, EF Core InMemory or TestContainers | Repository queries, EF mappings, DbContext configuration |
| Presentation | Minimal | {Solution}.Tests.Unit |
bUnit (Blazor) | Component rendering, user interaction events (smoke only) |
Do NOT test:
- Private methods (tested through public behavior)
- EF Core internal mechanics (trust the framework)
- DI container wiring (tested implicitly via integration tests)
- Framework routing, middleware behavior
Test Project Structure
tests/
├── {Solution}.Tests.Unit/
│ ├── Domain/
│ │ ├── Entities/
│ │ │ └── {EntityName}Tests.cs
│ │ └── ValueObjects/
│ │ └── {ValueObjectName}Tests.cs
│ ├── Application/
│ │ ├── Commands/
│ │ │ └── {CommandName}HandlerTests.cs
│ │ ├── Queries/
│ │ │ └── {QueryName}HandlerTests.cs
│ │ └── Validators/
│ │ └── {CommandName}ValidatorTests.cs
│ └── Builders/ ← Test Data Builders
│ └── {EntityName}Builder.cs
└── {Solution}.Tests.Integration/
└── Repositories/
└── {EntityName}RepositoryTests.cs
Naming Convention
Format: {MethodUnderTest}_{Scenario}_{ExpectedResult}
// Correct
[Fact]
public async Task Handle_ValidCommand_ReturnsSuccessResult() { }
[Fact]
public async Task Handle_NullTitle_ReturnsValidationFailure() { }
[Fact]
public async Task GetByIdAsync_ExistingId_ReturnsHydratedEntity() { }
[Fact]
public async Task GetByIdAsync_NonExistentId_ReturnsNull() { }
// Wrong — too vague, no scenario, no expected result
[Fact]
public async Task TestHandle() { }
Unit Test Templates
Domain Entity — Factory Method Tests
public sealed class {EntityName}Tests
{
public sealed class Create
{
[Fact]
public void Create_ValidInputs_ReturnsSuccessWithPopulatedEntity()
{
// Arrange
const string title = "Valid Title";
// Act
var result = {EntityName}.Create(title);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Title.Should().Be(title);
result.Value.Id.Should().NotBe(default);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Create_EmptyOrNullTitle_ReturnsFailure(string? title)
{
// Act
var result = {EntityName}.Create(title!);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be({EntityName}Errors.TitleRequired.Code);
}
}
}
Application Handler — Unit Tests (NSubstitute)
public sealed class Create{EntityName}HandlerTests
{
private readonly I{EntityName}Repository _repository =
Substitute.For<I{EntityName}Repository>();
private readonly Create{EntityName}Handler _sut;
public Create{EntityName}HandlerTests()
{
_sut = new Create{EntityName}Handler(_repository);
}
[Fact]
public async Task Handle_ValidCommand_PersistsAndReturnsId()
{
// Arrange
var command = new Create{EntityName}Command("Valid Title");
_repository
.AddAsync(Arg.Any<{EntityName}>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(default({EntityId}));
await _repository
.Received(1)
.AddAsync(Arg.Any<{EntityName}>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_CancellationRequested_PropagatesCancellation()
{
// Arrange
var cts = new CancellationTokenSource();
cts.Cancel();
var command = new Create{EntityName}Command("Valid Title");
// Act & Assert
await _sut
.Invoking(h => h.Handle(command, cts.Token))
.Should()
.ThrowAsync<OperationCanceledException>();
}
}
Integration Test (EF Core InMemory)
public sealed class {EntityName}RepositoryTests : IDisposable
{
private readonly {DbContextName} _db;
private readonly EfCore{EntityName}Repository _sut;
public {EntityName}RepositoryTests()
{
var options = new DbContextOptionsBuilder<{DbContextName}>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) // unique per test
.Options;
_db = new {DbContextName}(options);
_sut = new EfCore{EntityName}Repository(_db);
}
[Fact]
public async Task GetByIdAsync_ExistingEntity_ReturnsHydratedEntity()
{
// Arrange
var entity = {EntityName}.Create("Test").Value;
await _sut.AddAsync(entity, CancellationToken.None);
await _db.SaveChangesAsync();
// Act
var retrieved = await _sut.GetByIdAsync(entity.Id, CancellationToken.None);
// Assert
retrieved.Should().NotBeNull();
retrieved!.Id.Should().Be(entity.Id);
}
public void Dispose() => _db.Dispose();
}
Coverage Targets
| Layer | Line Coverage | Branch Coverage | Enforcement |
|---|---|---|---|
| Domain | ≥ 90% | ≥ 85% | CI pipeline gate |
| Application | ≥ 85% | ≥ 80% | CI pipeline gate |
| Infrastructure | ≥ 70% | ≥ 60% | Advisory |
| Presentation | ≥ 40% | — | Advisory |
Mandatory Test Scenarios (All Features)
These six scenarios form the minimum viable test surface for any feature. They are not exhaustive — they are the scenarios most likely to catch real defects at the cheapest testing layer. Coverage tools measure lines, not intent; these scenarios measure intent.
| # | Scenario | Why |
|---|---|---|
| 1 | Happy path — main success scenario | Proves the feature works |
| 2 | At least one validation failure | Proves guards are enforced |
| 3 | Entity not found (for queries/updates) | Proves null safety |
| 4 | CancellationToken propagation | Proves async hygiene |
| 5 | Domain invariant violation | Proves domain rules are enforced |
| 6 | Repository: add + retrieve round-trip | Proves EF mapping is correct |
Tools Reference
| Tool | Purpose | NuGet |
|---|---|---|
| xUnit | Test runner | xunit, xunit.runner.visualstudio |
| FluentAssertions | Readable assertions | FluentAssertions |
| NSubstitute | Mock/stub creation | NSubstitute |
| FluentValidation.TestHelper | Validator testing | FluentValidation.TestHelper |
| EF Core InMemory | Integration test DB | Microsoft.EntityFrameworkCore.InMemory |
| bUnit | Blazor component testing | bunit |
| Coverlet | Coverage collection | coverlet.collector |
Common Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
| Testing private methods directly | Test through the public surface; if impossible, SRP is violated |
| Shared mutable state between tests | Each test self-contained; use IDisposable for teardown |
| Mocking types you don't own | Wrap external types behind your own interface first |
[Fact] with if/for in the body |
Use [Theory] + [InlineData] instead |
| Missing CancellationToken scenario | Required for every async operation |
| Using real database in unit tests | Use NSubstitute mocks; use EF InMemory / TestContainers for integration |
| Asserting on private state | Assert on public behavior and return values only |
See Also
cqrs.md— CQRS via MediatR, command/query separation, pipeline behaviorsdesign-patterns.md— Approved GoF patterns: Factory, Repository, Decorator, Strategy, Specificationnaming.md— Naming conventions for types, methods, properties, namespaceseval-driven-development.md— Eval-driven development for AI agent behavior validationresult-error-handling.md— Result object pattern, error constants, exception boundaries