Backend guides
Testing a feature
Unit-test handlers and aggregates with the fast suite, and cover endpoints with Testcontainers integration tests.
On this page
Three test projects, two speeds
A vertical slice is tested at the level its risk lives. Pure logic, an aggregate
invariant, an error mapping, a validator, runs as a millisecond unit test. Anything that
touches the real schema, ASP.NET Identity or the domain-event flow runs against a real
Postgres in a container. The split maps onto three projects under api/tests/:
api/tests/
Slicekit.Unit.Tests/ # pure logic: aggregates, handlers with mocked ports, validators
Slicekit.Architecture.Tests/ # NetArchTest rules: slices stay isolated, layers stay clean
Slicekit.Feature.Tests/ # integration: real Postgres via Testcontainers, no HTTP
The first two are the fast suite. They need no Docker and finish in a few seconds, so they are the loop you run on every change:
dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo
The third, Slicekit.Feature.Tests, is slower (Testcontainers spins up a Postgres per
fixture) and is the boundary where you prove the slice works against the real database.
This page walks all three: a unit test for a handler and an aggregate, the architecture tests that keep slices honest, and an integration test backed by Testcontainers. For the shape of the slice itself, see Adding a vertical slice.
1. Unit-test the aggregate
The aggregate owns its invariants, so test it in isolation with no infrastructure. These
live in Slicekit.Unit.Tests/Domain/. Construct the aggregate, call a method, assert on the
resulting state:
using Slicekit.Core.Domain;
namespace Slicekit.Unit.Tests.Domain;
public sealed class UserTests
{
[Fact]
public void CreateLocal_Sets_Defaults_For_New_Local_Account()
{
var user = User.CreateLocal("[email protected]");
user.Id.ShouldNotBe(Guid.Empty);
user.Email.ShouldBe("[email protected]");
user.Enabled.ShouldBeTrue();
user.IsAdmin.ShouldBeFalse();
}
[Fact]
public void AssignPermission_Is_Idempotent_And_Returns_False()
{
var user = User.CreateLocal("[email protected]");
var permission = new Permission { Id = 7, Name = "Test.Permission" };
user.AssignPermission(user.Id, permission);
var added = user.AssignPermission(user.Id, permission);
added.ShouldBeFalse();
user.UserPermissions.ShouldHaveSingleItem();
}
}
Assertions use Shouldly. Globals.cs in the project
glob-imports Shouldly, Xunit, Slicekit.Core.Common and the error namespaces, so those
types are unqualified inside test files.
2. Unit-test the handler
A handler that does not touch the database can also be a unit test: inject its ports as
NSubstitute mocks and assert on what it dispatched. These live next to the slice they
cover, for example Slicekit.Unit.Tests/Auth/:
using NSubstitute;
using Slicekit.Core.Domain.Events;
using Slicekit.Core.Features.Auth.RevokeAllRefreshTokens;
using Wolverine;
namespace Slicekit.Unit.Tests.Auth;
public sealed class RevokeSessionsOnUserDisabledHandlerTests
{
[Fact]
public async Task Publishes_Revoke_Command_When_User_Disabled()
{
var bus = Substitute.For<IMessageBus>();
var userId = Guid.NewGuid();
var handler = new RevokeSessionsOnUserDisabledHandler(bus);
await handler.Handle(new UserEnabledChangedEvent(Guid.NewGuid(), userId, Enabled: false));
await bus.Received(1).PublishAsync(
Arg.Is<RevokeAllRefreshTokensCommand>(c => c.UserId == userId),
Arg.Any<DeliveryOptions?>());
}
}
The rule of thumb: if you can mock the handler’s ports and still test the behaviour you care about, keep it here. The moment the assertion is about EF behaviour, the schema, or cross-aggregate state, promote it to an integration test (section 4).
3. Let the architecture tests guard the slice
Slicekit.Architecture.Tests uses NetArchTest
to enforce the boundaries that make vertical slices work. You do not write a new test per
feature: the existing rules scan every type in Slicekit.Core and fail if your slice breaks
one. The two that bite most often:
[Fact]
public void Feature_Slices_Must_Not_Depend_On_Each_Other()
{
foreach (var ns in FeatureNamespaces)
{
var otherFeatures = FeatureNamespaces.Where(f => f != ns).ToArray();
var result = Types.InAssembly(CoreAssembly)
.That().ResideInNamespace(ns)
.ShouldNot().HaveDependencyOnAny(otherFeatures)
.GetResult();
result.IsSuccessful.ShouldBeTrue();
}
}
If your new slice references a type from another slice, this fails and names the offending
type. The fix is to share through Slicekit.Core.Domain, not across feature folders. A
companion rule, Domain_Must_Not_Depend_On (a theory with one case per forbidden
dependency), keeps the domain model free of EF Core, Identity, Wolverine and HTTP. Run the fast suite and
these pass or point straight at the line to fix.
4. Integration-test the endpoint with Testcontainers
When the slice touches the database, identity or the domain-event flow, write an
integration test in Slicekit.Feature.Tests/Features/<Area>/. These instantiate the handler
directly against a real Postgres (Testcontainers), call HandleAsync, and assert on the
Result, the queued OutgoingMessages, and any domain events the aggregate raised. No
HTTP, no mocking of EF.
The fixture
DatabaseFixture spins up one Postgres container per xUnit collection, applies every
migration, and seeds the permission catalog. Tests share the container; each test gets a
fresh AppDbContext and truncates the user-data tables on setup. Inherit
FeatureTestBase and you get a clean Db per test:
[Collection("Database")]
public abstract class FeatureTestBase(DatabaseFixture db) : IAsyncLifetime
{
protected AppDbContext Db { get; private set; } = null!;
public async Task InitializeAsync()
{
Db = db.CreateDbContext();
await db.TruncateUserDataAsync();
}
public async Task DisposeAsync() => await Db.DisposeAsync();
}
The [Collection("Database")] attribute is required and is inherited from the base class.
Without it xUnit treats the class as parallel-isolated and spins up its own container.
The test
Arrange state with the seeding helpers, call the handler, assert on all three outputs:
public sealed class CreateProjectTests(DatabaseFixture db) : FeatureTestBase(db)
{
[Fact]
public async Task Happy_Path()
{
// Arrange: seed, save, clear the change tracker
var user = User.CreateLocal("[email protected]");
Db.Users.Add(user);
await Db.SaveChangesAsync();
Db.ChangeTracker.Clear();
// Act: instantiate the handler, call HandleAsync
var handler = new CreateProjectCommandHandler(Db);
var (result, messages) = await handler.HandleAsync(new CreateProjectCommand(user.Id, "First"));
// Assert: Result, OutgoingMessages, domain events
result.IsSuccess.ShouldBeTrue();
Db.DomainEvents().OfType<ProjectCreated>().ShouldHaveSingleItem();
}
}
IdentityHelper builds the ASP.NET Identity plumbing that registering users needs:
var userManager = IdentityHelper.BuildUserManager(Db);
var (appUser, domainUser) = await IdentityHelper.SeedUserAsync(Db, userManager, "[email protected]", "Password1!");
SeedUserAsync creates both the ApplicationUser and the User aggregate and links them
by Id. Pass password: null for a passwordless (OAuth-only) account. For a domain-only
seed with no Identity row, use IdentityHelper.SeedDomainUser(Db, "[email protected]").
Domain events
The fixture wires a capturing interceptor into every AppDbContext, snapshotting
IDomainEvents on SaveChanges at the same point Wolverine’s transactional middleware
uses in production. Read them back with the extension:
var events = Db.DomainEvents();
events.OfType<UserCreatedEvent>().ShouldHaveSingleItem();
DomainEvents() is cumulative per Db instance; call Db.ClearDomainEvents() for a
clean slate between multiple SaveChanges calls in one test.
Outgoing messages
Command handlers return (Result<T>, OutgoingMessages). The OutgoingMessages are the
messages the handler would dispatch via Wolverine. Assert on them directly; the dispatch
itself does not run, which is the right boundary because the message contract is what your
slice owns and the recipient’s behaviour is its own test:
var (result, messages) = await handler.HandleAsync(command);
result.IsSuccess.ShouldBeTrue();
var email = messages.OfType<SendTemplateEmailCommand>().ShouldHaveSingleItem();
email.Recipient.ShouldBe("[email protected]");
Mocking ports, not EF
External dependencies (IAuditService, breached-password checks, OAuth providers) arrive
as interfaces on the handler constructor. Mock those with NSubstitute:
var audit = Substitute.For<IAuditService>();
var handler = new UpdateConsentCommandHandler(Db, audit);
await handler.HandleAsync(command);
await audit.Received(1).EmitAsync(
Arg.Is<AuditEvent>(e => e.Category == AuditCategory.Consent && e.Action == "Consent.Granted"),
Arg.Any<CancellationToken>());
Do not mock AppDbContext, and do not mock UserManager. Using the real Postgres through
the fixture and the helpers is the whole point of an integration test.
Running the suites
# Fast loop: unit + architecture, no Docker, a few seconds
dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo
# Integration: needs Docker running for Testcontainers
dotnet test api/tests/Slicekit.Feature.Tests --nologo
If Postgres will not start, docker compose ps confirms Docker is up. The Testcontainers
Postgres is its own container, separate from the one in docker-compose.yml.
Checklist
- Aggregate invariants and pure logic covered by unit tests in
Slicekit.Unit.Tests. - Handlers with mockable ports unit-tested with NSubstitute; no EF or
UserManagermocks. - Fast suite green:
dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo. - Architecture tests pass, so the new slice does not reach across feature boundaries.
- Database, identity or domain-event behaviour covered by an integration test in
Slicekit.Feature.Tests, inheritingFeatureTestBasewith[Collection("Database")]. - Asserted on all three outputs that matter: the
Result, theOutgoingMessages, and the raised domain events. dotnet test api/tests/Slicekit.Feature.Tests --nologopasses with Docker running.