Three recipes — unit test, integration test, architecture rule. Each is copy-paste-ready and follows the conventions the rest of the kit uses.
Recipe 1 — A unit test for a new handler
You’ve just added a handler under Modules.Catalog/Features/v1/Products/AdjustProductStock/ and want to verify its logic without infrastructure.
1. Create the test file
Unit test projects group by kind, not by feature path — Domain/ for aggregate tests, Handlers/ for handler tests, Services/ for service tests (see Identity.Tests for the fullest example):
src/Tests/Catalog.Tests/Handlers/AdjustProductStockCommandHandlerTests.cs(If the module has no test project yet, create it with dotnet new xunit -n Catalog.Tests -o src/Tests/Catalog.Tests, add it to the solution, and reference the module plus Shouldly, NSubstitute, and AutoFixture — versions come from central package management.)
2. Write the test
namespace Catalog.Tests.Handlers;
public sealed class AdjustProductStockCommandHandlerTests{ private readonly ICatalogDbContext _db = Substitute.For<ICatalogDbContext>();
[Fact] public async Task Handle_Should_AdjustStock_And_SaveChanges_When_ProductExists() { // Arrange var product = Product.Create(/* … create a product with Stock: 100 … */); _db.Products.FindAsync(Arg.Is<object[]>(o => (Guid)o[0] == product.Id), Arg.Any<CancellationToken>()) .Returns(ValueTask.FromResult<Product?>(product));
var command = new AdjustProductStockCommand(product.Id, Delta: 5m); var sut = new AdjustProductStockCommandHandler(_db);
// Act await sut.Handle(command, CancellationToken.None);
// Assert product.Stock.ShouldBe(105m); await _db.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>()); product.DomainEvents.ShouldContain(e => e is ProductStockAdjustedDomainEvent); }
[Fact] public async Task Handle_Should_ThrowNotFound_When_ProductDoesNotExist() { _db.Products.FindAsync(Arg.Any<object[]>(), Arg.Any<CancellationToken>()) .Returns(ValueTask.FromResult<Product?>(null));
var sut = new AdjustProductStockCommandHandler(_db);
await Should.ThrowAsync<NotFoundException>(async () => await sut.Handle(new AdjustProductStockCommand(Guid.NewGuid(), 5m), CancellationToken.None)); }}3. Run it
dotnet test src/Tests/Catalog.Tests/ \ --filter "FullyQualifiedName~AdjustProductStockCommandHandlerTests"Should pass sub-second.
Recipe 2 — An integration test for a new endpoint
You’ve added PATCH /api/v1/catalog/products/{productId}/stock and want to verify the HTTP round-trip.
1. Create the test file
Integration tests group by module under src/Tests/Integration.Tests/Tests/:
src/Tests/Integration.Tests/Tests/Catalog/AdjustProductStockEndpointTests.cs2. Write the test
Join the shared collection, take the factory in the constructor, and authenticate through AuthHelper — that’s the whole harness contract (see Fixtures & seed data):
using Integration.Tests.Infrastructure;using Integration.Tests.Infrastructure.Extensions;
namespace Integration.Tests.Tests.Catalog;
[Collection(FshCollectionDefinition.Name)]public sealed class AdjustProductStockEndpointTests{ private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth;
public AdjustProductStockEndpointTests(FshWebApplicationFactory factory) { _factory = factory; _auth = new AuthHelper(factory); }
[Fact] public async Task AdjustStock_Should_UpdateStock_When_PayloadIsValid() { using var client = await _auth.CreateRootAdminClientAsync();
// Arrange — create the product this test owns (unique SKU, no shared seed) var createResponse = await client.PostAsJsonAsync( $"{TestConstants.CatalogBasePath}/products", new { sku = $"TEST-{Guid.NewGuid():N}"[..12], name = "Stock test product", priceAmount = 10m, priceCurrency = "USD", stock = 100m, }); createResponse.EnsureSuccessStatusCode(); var productId = await createResponse.DeserializeAsync<Guid>();
// Act var response = await client.PatchAsJsonAsync( $"{TestConstants.CatalogBasePath}/products/{productId}/stock", new { delta = 5m });
// Assert HTTP, then assert state via GET response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var get = await client.GetAsync($"{TestConstants.CatalogBasePath}/products/{productId}"); var dto = await get.DeserializeAsync<ProductDto>(); dto.Stock.ShouldBe(105m); }
[Fact] public async Task AdjustStock_Should_Return401_When_NoTokenProvided() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId);
var response = await client.PatchAsJsonAsync( $"{TestConstants.CatalogBasePath}/products/{Guid.NewGuid()}/stock", new { delta = 5m });
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); }}For permission-denial (403) coverage, create a user without the permission via the API and authenticate with CreateAuthenticatedClientAsync — see Tests/Authorization/PermissionEnforcementTests.cs for the patterns.
3. Run it
dotnet test src/Tests/Integration.Tests/ \ --filter "FullyQualifiedName~AdjustProductStockEndpointTests"Docker must be running — the suite starts its Postgres + MinIO containers once and shares them across every test.
Recipe 3 — A new architecture rule
You’ve adopted a convention — say, “every Mediator handler must be public sealed” — and want CI to enforce it.
1. Pick the file
Architecture tests live flat in src/Tests/Architecture.Tests/, one file per rule family:
src/Tests/Architecture.Tests/HandlerShapeTests.cs2. Write the rule
Follow the shipped pattern: iterate ModuleAssemblyDiscovery.GetModuleAssemblies(), collect violations, fail with the offending types named:
namespace Architecture.Tests;
public sealed class HandlerShapeTests{ /// <summary> /// Specification: every Mediator handler must be public sealed. /// Why: source-gen requires concrete types; sealed prevents accidental /// inheritance which would break the generator. /// </summary> [Fact] public void Mediator_Handlers_Should_Be_Public_Sealed() { var failures = new List<string>();
foreach (var module in ModuleAssemblyDiscovery.GetModuleAssemblies()) { var handlers = module.GetTypes() .Where(t => t.IsClass && !t.IsAbstract) .Where(t => t.GetInterfaces().Any(i => i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>) || i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>))));
failures.AddRange(handlers .Where(h => !h.IsPublic || !h.IsSealed) .Select(h => h.FullName!)); }
failures.ShouldBeEmpty( $"Handlers must be public sealed: {string.Join(", ", failures)}"); }}Three conventions the shipped rules all follow:
- XML doc comment stating the rule and why — the readable specification.
- Failure message that names the offenders — so the fix is obvious from the CI log.
- Explicit allowlists for exceptions (see
HandlerValidatorPairingTests.KnownMissingCommandHandlers) — exemptions are code-reviewed, never silent.
For pure dependency rules (“X must not reference Y”), NetArchTest’s fluent API is shorter:
var result = Types.InAssembly(module) .That().ResideInNamespaceContaining(".Domain") .ShouldNot().HaveDependencyOn("Microsoft.EntityFrameworkCore") .GetResult();result.IsSuccessful.ShouldBeTrue();3. Run it
dotnet test src/Tests/Architecture.Tests/ \ --filter "FullyQualifiedName~HandlerShapeTests"Runs in seconds — no infrastructure. If it fails, the message names the offending types.
Three rules of thumb
- Smallest possible test. A domain invariant on an aggregate is a unit test, not an integration test.
- One arrangement per test. If a single test asserts six things across three setups, split it into three tests.
- Name the test by what would fail.
Resolve_Should_ThrowConflict_When_TicketIsAlreadyClosedtells you the intent.TicketsTest1doesn’t.
Related
- Unit tests — conventions and assertion style.
- Integration tests — the harness and authenticated clients.
- Architecture tests — the rule families that ship.
- Running tests —
dotnet testinvocations.