Testing is where VSA's self-contained structure pays its biggest dividend. In a layered architecture, testing "Create Todo" means mocking a controller, a service, a repository, and a DbContext — each with its own interface. In VSA, you test one handler. The handler is a plain class with a single dependency (the DbContext), a single method (Handle or HandleAsync), and a clear contract (request in, response out). This part shows how to test every layer of a VSA slice — from pure validator tests to full HTTP integration tests.
The testing pyramid for VSA looks like this: a few integration tests per slice (WebApplicationFactory) that verify the endpoints work end-to-end, a handful of handler unit tests that verify business rules, and validator tests that pin down input rules. Because slices are self-contained, you can test a slice without bootstrapping unrelated features — a luxury layered architectures don't have.
Handler Unit Tests with In-Memory EF Core
The most valuable tests are handler tests because handlers contain the business logic. Use EF Core's InMemory provider to give the handler a real DbContext with seeded data:
[Fact]
public async Task CreateTodo_WithValidRequest_ReturnsResponse()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var context = new AppDbContext(options);
var handler = new CreateTodoHandler(context);
var request = new CreateTodoRequest { Name = "Project Launch" };
var response = await handler.Handle(new CreateTodoCommand(request), CancellationToken.None);
Assert.NotNull(response);
Assert.Equal("Project Launch", response.Name);
Assert.Single(context.Todo);
Assert.Equal("Project Launch", context.Todo.Single().Name);
}
The Guid.NewGuid().ToString() database name isolates each test. The handler's constructor takes only the DbContext, so setup is trivial. This test verifies the full handler path: request in, entity created, response returned, database updated. For the manual-handler pattern (HandleAsync returning ApiResponse), the assertions check response.Success and response.Data instead.
Testing Business Rules in Handlers
Business rules from Part 16 become test cases. Each rule gets its own test that pins the behavior:
[Fact]
public async Task UpdateTodo_WithProgress100_MarksCompleted()
{
// Seed a todo with Progress = 50
// Act: update Progress to 100 via UpdateTodoHandler
// Assert: entity.IsCompleted == true
}
[Fact]
public async Task UpdateTodo_WithInvalidId_ReturnsSuccessFalse()
{
var response = await handler.Handle(
new UpdateTodoCommand(new UpdateTodoRequest { Id = "missing" }),
CancellationToken.None);
Assert.False(response.Success);
}
[Fact]
public async Task DeleteTodo_WithAttachments_DeletesPhysicalFiles()
{
// Seed a todo with a file attachment pointing to a temp file
// Act: call DeleteTodoHandler
// Assert: file no longer exists on disk, entity soft-deleted
}
Notice how each test reads like a requirement: "when progress hits 100, the todo is completed." The handler's single-method design makes these tests direct and fast. The file-cleanup test is particularly valuable — it catches the bug where a file is deleted from the database but left orphaned on disk.
FluentValidation Test Helpers
Validators are plain classes, so they test directly. FluentValidation's TestHelper makes rule verification concise:
[Fact]
public void CreateTodo_WithoutName_FailsValidation()
{
var validator = new CreateTodoValidator();
var result = validator.TestValidate(new CreateTodoRequest { Name = "" });
result.ShouldHaveValidationErrorFor(x => x.Name);
}
[Fact]
public void CreateTodo_WithInvalidPriority_FailsValidation()
{
var validator = new CreateTodoValidator();
var request = new CreateTodoRequest { Name = "Task", Priority = (TodoPriority)99 };
var result = validator.TestValidate(request);
result.ShouldHaveValidationErrorFor(x => x.Priority);
}
[Fact]
public void CreateTodo_WithValidData_PassesValidation()
{
var validator = new CreateTodoValidator();
var request = new CreateTodoRequest
{
Name = "Task", Priority = TodoPriority.High,
Category = TodoCategory.Work, Progress = 50
};
var result = validator.TestValidate(request);
result.ShouldNotHaveAnyValidationErrors();
}
These tests document the validation contract: name is required, enums must be valid, and a complete valid request passes. The TestValidate helper from FluentValidation's test package runs the validator synchronously and exposes assertion methods. Every rule you write gets a test that prevents regressions when someone loosens a constraint.
Integration Tests with WebApplicationFactory
Integration tests exercise the full HTTP stack — routing, auth, model binding, handlers, and responses. WebApplicationFactory boots the real application and replaces the database with a test database:
public class TodoApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public TodoApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace the real DbContext with InMemory
var descriptor = services.Single(d =>
d.ServiceType == typeof(DbContextOptions<AppDbContext>));
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(o =>
o.UseInMemoryDatabase("TestDb"));
});
});
}
[Fact]
public async Task GetTodos_WithoutAuth_ReturnsUnauthorized()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/todo");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetTodos_WithAuth_ReturnsList()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", GetTestJwt());
var response = await client.GetAsync("/api/todo");
response.EnsureSuccessStatusCode();
// Assert deserialized ApiResponse list
}
}
The first test verifies the authorization requirement from Part 18 — an unauthenticated call returns 401, proving the endpoint group's JWT requirement works. The second test authenticates with a test JWT and verifies a successful list response. These tests are the final integration checkpoint: if the endpoint wiring, handler, and DTOs all work together, the slice is production-ready.
Why VSA Makes Testing Simpler
Compare the setup effort: a layered test needs to construct and mock a controller, service, repository, and mapper. A VSA test constructs one handler with one DbContext. There are no interfaces to mock (handlers use concrete DbContext), no service layer to stub, and no cross-feature dependencies to satisfy. When a test fails, it fails inside the slice being tested — the failure tells you exactly which feature broke. This isolation is why VSA teams write more tests: the cost of each test is low, and the value is immediately visible. As the final part of this series, it's also the fitting conclusion — a well-tested VSA application is a sustainable one.