Skip to content
fullstackhero

Recipe

Writing new tests

Step-by-step recipes for adding a unit test, an integration test, or an architecture rule — copy-paste-ready.

views 0 Last updated

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

Terminal window
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.cs

2. 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

Terminal window
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.cs

2. 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

Terminal window
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

  1. Smallest possible test. A domain invariant on an aggregate is a unit test, not an integration test.
  2. One arrangement per test. If a single test asserts six things across three setups, split it into three tests.
  3. Name the test by what would fail. Resolve_Should_ThrowConflict_When_TicketIsAlreadyClosed tells you the intent. TicketsTest1 doesn’t.