Testing Vertical Slices — Handler Tests, Integration Tests, and Test Isolation
Test Vertical Slice features effectively: unit-testing handlers in isolation, integration testing with WebApplicationFactory, test data builders for domain objects, and the testing strategy that matches the architecture.
Testing Philosophy for Vertical Slices
Vertical Slice gives you a natural testing boundary:
Each slice = one unit of functionality = one test class
Test levels:
Handler tests: fast, no HTTP, no database. Test business rules.
Integration tests: real database (Testcontainers), no HTTP. Test full slice.
API tests: HTTP via WebApplicationFactory. Test endpoint behavior.
You do NOT need to test every layer separately.
The handler is the business logic — test it.
The endpoint is thin — test it through HTTP, not in isolation.Handler Unit Tests
// Test the handler directly — no HTTP, no database needed for business rule tests
public sealed class CreatePrescriptionHandlerTests
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _uow;
private readonly IMediator _mediator;
private readonly CreatePrescriptionHandler _sut;
public CreatePrescriptionHandlerTests()
{
// In-memory or Testcontainers — see integration test section
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new ApplicationDbContext(options);
_uow = Substitute.For<IUnitOfWork>();
_mediator = Substitute.For<IMediator>();
_sut = new CreatePrescriptionHandler(_db, _uow, _mediator);
}
[Fact]
public async Task Handle_WhenPatientNotFound_ReturnsFailure()
{
var command = new CreatePrescriptionCommand(
PatientId: Guid.NewGuid(), // patient not in DB
MedicationName: "Warfarin",
DoseAmount: 5m,
DoseUnit: "mg",
PrescriberId: Guid.NewGuid(),
Instructions: null);
var result = await _sut.Handle(command, CancellationToken.None);
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Patient.NotFound");
}
[Fact]
public async Task Handle_WhenValid_CreatesPrescriptionAndReturnsResponse()
{
var patient = PatientBuilder.Active().Build();
var prescriber = ClinicianBuilder.Active().Build();
_db.Patients.Add(patient);
_db.Clinicians.Add(prescriber);
await _db.SaveChangesAsync();
var command = new CreatePrescriptionCommand(
patient.Id.Value, "Warfarin", 5m, "mg", prescriber.Id.Value, null);
var result = await _sut.Handle(command, CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value.MedicationName.Should().Be("Warfarin");
result.Value.DoseAmount.Should().Be(5m);
await _mediator.Received(1)
.Publish(Arg.Is<PrescriptionCreatedNotification>(n =>
n.MedicationName == "Warfarin"), Arg.Any<CancellationToken>());
}
}Test Data Builders
// Builders for constructing valid domain objects in tests
// Use when creating test entities requires multiple constructor arguments
public sealed class PatientBuilder
{
private string _mrn = "MRN-001";
private string _firstName = "Jane";
private string _lastName = "Smith";
private DateTime _dob = new DateTime(1980, 1, 1);
private Guid _wardId = Guid.NewGuid();
public static PatientBuilder Active() => new();
public PatientBuilder WithMrn(string mrn) { _mrn = mrn; return this; }
public PatientBuilder WithFirstName(string name) { _firstName = name; return this; }
public PatientBuilder InWard(Guid wardId) { _wardId = wardId; return this; }
public Patient Build()
=> Patient.Create(_mrn, _firstName, _lastName, _dob, new WardId(_wardId)).Value;
}
// Usage in tests:
var patient = PatientBuilder.Active()
.WithMrn("TEST-MRN-001")
.InWard(specificWardId)
.Build();Integration Tests with Real Database
// Use Testcontainers for real SQL Server in tests
// NuGet: Testcontainers.SqlServer
public sealed class CreatePrescriptionIntegrationTests
: IClassFixture<SqlServerContainerFixture>
{
private readonly SqlServerContainerFixture _fixture;
public CreatePrescriptionIntegrationTests(SqlServerContainerFixture fixture)
=> _fixture = fixture;
[Fact]
public async Task CreatePrescription_PersistsToDatabase()
{
// Arrange
using var db = _fixture.CreateDbContext();
var patient = PatientBuilder.Active().Build();
var prescriber = ClinicianBuilder.Active().Build();
db.Patients.Add(patient);
db.Clinicians.Add(prescriber);
await db.SaveChangesAsync();
var uow = new UnitOfWork(db);
var mediator = Substitute.For<IMediator>();
var handler = new CreatePrescriptionHandler(db, uow, mediator);
var command = new CreatePrescriptionCommand(
patient.Id.Value, "Warfarin", 5m, "mg", prescriber.Id.Value, null);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
var saved = await db.Prescriptions
.FirstOrDefaultAsync(p => p.Id.Value == result.Value.PrescriptionId);
saved.Should().NotBeNull();
saved!.MedicationName.Should().Be("Warfarin");
saved.Dose.Amount.Should().Be(5m);
saved.IsActive.Should().BeTrue();
}
}API-Level Tests with WebApplicationFactory
public sealed class CreatePrescriptionApiTests
: IClassFixture<ClinicalApiFactory>
{
private readonly HttpClient _client;
public CreatePrescriptionApiTests(ClinicalApiFactory factory)
=> _client = factory.CreateAuthenticatedClient("Clinician");
[Fact]
public async Task Post_CreatePrescription_Returns201WithLocation()
{
var request = new
{
patientId = factory.Seed.PatientId,
medicationName = "Warfarin",
doseAmount = 5.0m,
doseUnit = "mg",
prescriberId = factory.Seed.ClinicianId,
};
var response = await _client.PostAsJsonAsync("api/prescriptions", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var body = await response.Content.ReadFromJsonAsync<CreatePrescriptionResponse>();
body!.MedicationName.Should().Be("Warfarin");
}
[Fact]
public async Task Post_CreatePrescription_WithInvalidDoseUnit_Returns400()
{
var request = new
{
patientId = factory.Seed.PatientId,
medicationName = "Warfarin",
doseAmount = 5.0m,
doseUnit = "spoonfuls", // invalid
prescriberId = factory.Seed.ClinicianId,
};
var response = await _client.PostAsJsonAsync("api/prescriptions", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}Production issue I've seen: A team tested the validation behavior in the controller (mapping bad input to 400). When they moved from controllers to Minimal APIs + Vertical Slice, they forgot to test the validation pipeline behavior registration. The behavior was registered but the validators weren't added via
AddValidatorsFromAssembly(). All API validation silently passed — no 400 responses for invalid input — because theIEnumerable<IValidator<T>>was empty. A single integration test asserting that a known-invalid payload returns 400 would have caught this immediately.
Key Takeaway
Test handlers directly for business rules — no HTTP or real database needed for logic tests. Use test data builders for domain objects to avoid fragile test setup. Test that validation returns 400 via an API-level test — this validates that the pipeline behavior is wired correctly, not just that the validator logic is correct. Integration tests with Testcontainers verify real SQL Server behavior for persistence-sensitive features.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.