VSATutorialSeptember 2026 · 9 min read

VSA Todo App — Part 20: Testing VSA Slices — Integration, Handler & Validation Tests

TL;DR

Part 20 completes the VSA Todo App tutorial with testing. We cover WebApplicationFactory integration tests that exercise the full HTTP stack, unit tests for handlers with an in-memory DbContext, FluentValidation test helpers for validator rules, and authorization tests that verify role requirements. VSA's self-contained slices make all of this simpler than layered architectures.

Part 20 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 19

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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.

Key Takeaways

  • VSA handlers take one dependency (DbContext) — test setup is trivial compared to layered architectures
  • Handler tests with InMemory EF Core verify business rules: progress auto-complete, not-found handling, file cleanup
  • FluentValidation's TestValidate pins down every validation rule — name required, enums valid, ranges enforced
  • WebApplicationFactory integration tests verify the full stack: routing, auth, binding, handlers, and responses
  • Test the unauthorized 401 first — it proves your per-slice authorization from Part 18 actually works

Frequently Asked Questions

Q: How to test VSA handlers?

Construct the handler with an in-memory EF Core DbContext and call its Handle/HandleAsync method directly with a request object. Assert the response and the database state. Handlers take one dependency, so no mocking framework is needed — just a seeded DbContext.

Q: WebApplicationFactory or unit tests for VSA?

Both. Use unit tests for handlers and validators (fast, precise, cover business rules). Use WebApplicationFactory integration tests for a few critical paths per slice — authorization, create, list, detail, delete — to verify the full HTTP stack works end-to-end. The pyramid: many unit tests, few integration tests.

Q: How to mock DbContext in VSA tests?

You don't need a mocking framework. Use EF Core's InMemory provider: build a DbContextOptions<AppDbContext> with UseInMemoryDatabase(Guid.NewGuid().ToString()), construct the context, seed test data, and pass it to the handler. Each test gets an isolated, empty database.

Q: How to test validation in VSA?

Instantiate the validator directly and use FluentValidation's TestValidate helper. Assert with ShouldHaveValidationErrorFor for each invalid case and ShouldNotHaveAnyValidationErrors for a valid case. This documents the validation contract and prevents regression when rules change.

Part 20 of 20 in the VSA Todo App Tutorial Series|← Previous: Part 19

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use.

Star on GitHub