Testing Feature Slices in C#: Unit Tests, Integration Tests, and What to Test
One of the underappreciated benefits of testing feature slices in C# is how well the architecture guides what to test. In a layered application, you test controllers, services, and repositories as separate concerns -- and sometimes find that integration tests overlap significantly with the individual layers. In a feature-sliced application, the use case is the natural unit. Feature tests reduce this redundancy when the handler is the use-case boundary, though you may still benefit from testing persistence boundaries or mapping logic separately when the risk warrants it.
This article covers a practical testing strategy for feature-sliced .NET applications: unit tests for handler logic, integration tests for the full HTTP stack, and the criteria for choosing between them.
Why Feature Slices Are Easy to Test
A feature handler is a class that takes a request and produces a result. No HTTP context, no routing, no response serialization. The dependencies are explicit in the constructor. This is the ideal shape for a unit test target.
Compare the test surface for a traditional layered service:
// Layered: testing TaskService.CompleteTaskAsync means
// - the method touches _repository (interface to mock)
// - _repository talks to _db (another dependency)
// - potentially _logger, _eventPublisher, etc.
// - method might call other service methods internally
With a feature handler:
// Feature slice: testing CompleteTaskHandler.HandleAsync means
// - the handler takes AppDbContext (testable with EF in-memory)
// - it takes TimeProvider (testable with FakeTimeProvider)
// - that's it
The constructor is the test fixture definition. Every dependency is visible and controllable.
Unit Testing a Feature Handler
A unit test for a feature handler uses an in-memory or SQLite database rather than mocking the DbContext. EF Core's in-memory provider is convenient for basic logic tests, but it is not relational -- it can silently pass queries and constraints that would fail against a real database. SQLite in-memory mode gives higher fidelity and is preferred when your tests exercise queries or constraints. For production-equivalent testing, Testcontainers (covered later in this article) gives the full database behavior.
Here is a unit test for CompleteTaskHandler:
// Tests/Features/Tasks/CompleteTask/CompleteTaskHandlerTests.cs
namespace TaskTracker.Tests.Features.Tasks.CompleteTask;
public sealed class CompleteTaskHandlerTests : IDisposable
{
private readonly AppDbContext _db;
private readonly FakeTimeProvider _time;
private readonly CompleteTaskHandler _handler;
public CompleteTaskHandlerTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new AppDbContext(options);
_time = new FakeTimeProvider();
_handler = new CompleteTaskHandler(_db, _time);
}
public void Dispose() => _db.Dispose();
[Fact]
public async Task HandleAsync_ExistingIncompleteTask_CompletesTask()
{
// Arrange
var task = new TaskEntity
{
Id = Guid.NewGuid(),
Title = "Test task",
ProjectId = Guid.NewGuid(),
CreatedAt = _time.GetUtcNow(),
IsCompleted = false
};
_db.Tasks.Add(task);
await _db.SaveChangesAsync();
var expectedCompletedAt = _time.GetUtcNow().AddHours(1);
_time.Advance(TimeSpan.FromHours(1));
// Act
var result = await _handler.HandleAsync(task.Id);
// Assert
result.Found.ShouldBeTrue();
result.AlreadyCompleted.ShouldBeFalse();
var updated = await _db.Tasks.FindAsync(task.Id);
updated!.IsCompleted.ShouldBeTrue();
updated.CompletedAt.ShouldBe(expectedCompletedAt);
}
[Fact]
public async Task HandleAsync_TaskNotFound_ReturnsFalse()
{
// Arrange
var nonExistentId = Guid.NewGuid();
// Act
var result = await _handler.HandleAsync(nonExistentId);
// Assert
result.Found.ShouldBeFalse();
}
[Fact]
public async Task HandleAsync_AlreadyCompletedTask_ReturnsAlreadyCompleted()
{
// Arrange
var task = new TaskEntity
{
Id = Guid.NewGuid(),
Title = "Already done",
ProjectId = Guid.NewGuid(),
CreatedAt = _time.GetUtcNow(),
IsCompleted = true,
CompletedAt = _time.GetUtcNow()
};
_db.Tasks.Add(task);
await _db.SaveChangesAsync();
// Act
var result = await _handler.HandleAsync(task.Id);
// Assert
result.Found.ShouldBeTrue();
result.AlreadyCompleted.ShouldBeTrue();
}
}
Note several things about this test structure:
- Each test creates a fresh in-memory database with a unique name -- no shared state between tests
FakeTimeProviderfromMicrosoft.Extensions.TimeProvider.TestingreplacesTimeProviderto control time (if unavailable in your target framework, implement a minimalTimeProvidersubclass that returns a fixedDateTimeOffset)- Tests assert both the returned result and the persisted state
- The test names follow the pattern:
MethodUnderTest_Condition_ExpectedResult
Unit Testing a Query Handler
Query handlers are often even simpler to test -- insert seed data, run the query, assert the result:
// Tests/Features/Tasks/GetTasks/GetTasksHandlerTests.cs
namespace TaskTracker.Tests.Features.Tasks.GetTasks;
public sealed class GetTasksHandlerTests : IDisposable
{
private readonly AppDbContext _db;
private readonly GetTasksHandler _handler;
private readonly Guid _projectId = Guid.NewGuid();
public GetTasksHandlerTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new AppDbContext(options);
_handler = new GetTasksHandler(_db);
}
public void Dispose() => _db.Dispose();
[Fact]
public async Task HandleAsync_WithTasks_ReturnsTasksForProject()
{
// Arrange
await SeedTasksAsync(
("Task 1", _projectId, false),
("Task 2", _projectId, true),
("Other project task", Guid.NewGuid(), false));
// Act
var results = await _handler.HandleAsync(new GetTasksQuery(_projectId));
// Assert
results.Count.ShouldBe(2);
results.Select(t => t.Title).ShouldContain("Task 1");
results.Select(t => t.Title).ShouldContain("Task 2");
}
[Fact]
public async Task HandleAsync_FilterByCompleted_ReturnsOnlyCompletedTasks()
{
// Arrange
await SeedTasksAsync(
("Incomplete", _projectId, false),
("Complete", _projectId, true));
var query = new GetTasksQuery(_projectId, IsCompleted: true);
// Act
var results = await _handler.HandleAsync(query);
// Assert
results.ShouldHaveSingleItem();
results[0].Title.ShouldBe("Complete");
}
private async Task SeedTasksAsync(params (string Title, Guid ProjectId, bool IsCompleted)[] tasks)
{
foreach (var (title, projectId, isCompleted) in tasks)
{
_db.Tasks.Add(new TaskEntity
{
Id = Guid.NewGuid(),
Title = title,
ProjectId = projectId,
IsCompleted = isCompleted,
CreatedAt = DateTimeOffset.UtcNow
});
}
await _db.SaveChangesAsync();
}
}
Integration Testing the Full HTTP Stack
Unit tests cover handler logic. Integration tests verify that the HTTP pipeline works end to end: routing, model binding, validation, authentication, and the response code/shape.
ASP.NET Core's WebApplicationFactory<T> provides a test HTTP server that runs your full application in-process:
// Tests/Integration/Tasks/CreateTaskIntegrationTests.cs
namespace TaskTracker.Tests.Integration.Tasks;
public sealed class CreateTaskIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public CreateTaskIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Post_CreateTask_Returns201WithTaskId()
{
// Arrange
var client = _factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// Replace real DB with in-memory for integration tests
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"test-{Guid.NewGuid()}"));
});
})
.CreateClient();
var projectId = await CreateProjectAsync(client);
var request = new
{
Title = "Write tests",
Description = "Test the feature slice",
ProjectId = projectId
};
// Act
var response = await client.PostAsJsonAsync("/tasks", request);
// Assert
response.StatusCode.ShouldBe(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<CreateTaskResponseDto>();
body.ShouldNotBeNull();
body.TaskId.ShouldNotBe(Guid.Empty);
body.Title.ShouldBe("Write tests");
response.Headers.Location!.ToString()
.ShouldBe($"/tasks/{body.TaskId}");
}
[Fact]
public async Task Post_CreateTask_WithEmptyTitle_Returns400()
{
// Arrange
var client = CreateTestClient();
var request = new { Title = "", ProjectId = Guid.NewGuid() };
// Act
var response = await client.PostAsJsonAsync("/tasks", request);
// Assert
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
private HttpClient CreateTestClient()
{
return _factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"test-{Guid.NewGuid()}"));
});
})
.CreateClient();
}
private static async Task<Guid> CreateProjectAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync("/projects",
new { Name = "Test Project" });
response.EnsureSuccessStatusCode();
var project = await response.Content.ReadFromJsonAsync<CreateProjectResponseDto>();
return project!.ProjectId;
}
// Local DTO for deserialization -- avoids coupling tests to production types
private sealed record CreateTaskResponseDto(Guid TaskId, string Title, DateTimeOffset CreatedAt);
private sealed record CreateProjectResponseDto(Guid ProjectId, string Name);
}
Shared Test Infrastructure
For integration tests across many features, extract the common setup into a shared fixture:
// Tests/Integration/TestWebAppFactory.cs
namespace TaskTracker.Tests.Integration;
public sealed class TestWebAppFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"test-{Guid.NewGuid()}"));
});
}
}
Then use it in test classes:
public sealed class CompleteTaskIntegrationTests : IClassFixture<TestWebAppFactory>
{
private readonly TestWebAppFactory _factory;
private readonly HttpClient _client;
public CompleteTaskIntegrationTests(TestWebAppFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
// ...
}
Organizing Tests to Mirror the Feature Structure
The test project structure mirrors the main project's Features/ folder:
TaskTracker.Tests/
Features/
Tasks/
CreateTask/
CreateTaskHandlerTests.cs
CompleteTask/
CompleteTaskHandlerTests.cs
GetTasks/
GetTasksHandlerTests.cs
Projects/
CreateProject/
CreateProjectHandlerTests.cs
Integration/
Tasks/
CreateTaskIntegrationTests.cs
CompleteTaskIntegrationTests.cs
Projects/
CreateProjectIntegrationTests.cs
TestWebAppFactory.cs
This structure means finding the tests for a feature is as predictable as finding the feature code itself. When CreateTask changes, you know where to update the tests.
When to Write Unit Tests vs. Integration Tests
Both test types serve different purposes. This table summarizes when to use each for feature slices:
| Scenario | Unit Test | Integration Test |
|---|---|---|
| Handler business logic | ✅ | Optional |
| Routing (correct URL maps to correct handler) | ❌ | ✅ |
| Model binding (JSON deserializes correctly) | ❌ | ✅ |
| Validation (invalid input returns 400) | Validator unit test | ✅ |
| Response shape (correct JSON returned) | ❌ | ✅ |
| Authorization (401 for unauthenticated requests) | ❌ | ✅ |
| Complex business logic with multiple branches | ✅ | Optional |
| Database queries returning correct data | ✅ (in-memory) | Optional |
The general principle: use unit tests for logic, integration tests for wiring. A unit test that starts an HTTP server to test a business rule is doing too much. An integration test that mocks AppDbContext to verify that a POST returns 201 is doing too little.
This mirrors the testing approach recommended in vertical slice development for modern teams, where the use case is the natural boundary for each test suite.
Testing Validation
Validation logic deserves its own unit tests that do not involve HTTP or the handler:
// Tests/Features/Tasks/CreateTask/CreateTaskValidatorTests.cs
namespace TaskTracker.Tests.Features.Tasks.CreateTask;
public sealed class CreateTaskValidatorTests
{
private readonly CreateTaskValidator _validator = new();
[Fact]
public void Validate_ValidRequest_ReturnsSuccess()
{
var request = new CreateTaskRequest(
Title: "Implement feature",
Description: "Test it too",
ProjectId: Guid.NewGuid());
var result = _validator.Validate(request);
result.IsValid.ShouldBeTrue();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Validate_EmptyTitle_ReturnsFailure(string? title)
{
var request = new CreateTaskRequest(
Title: title!,
Description: null,
ProjectId: Guid.NewGuid());
var result = _validator.Validate(request);
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.PropertyName == nameof(CreateTaskRequest.Title));
}
}
These tests run fast, require no database setup, and document the validation rules clearly.
Using TestContainers for Production-Equivalent Tests
In-memory EF Core databases skip some SQL behaviors (constraints, specific query translation). For tests where production-equivalent behavior matters, Testcontainers for .NET spins up a real SQL Server or PostgreSQL instance in Docker:
// Tests/Integration/DatabaseFixture.cs
namespace TaskTracker.Tests.Integration;
public sealed class DatabaseFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync() => await _container.StartAsync();
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
}
Use the real database for integration tests, in-memory for unit tests. The feature-sliced structure makes swapping the database backend in test setup straightforward because handler dependencies are explicit constructor parameters.
Connecting Tests to the Plugin Architecture Pattern
The same testing principles apply when working with plugin architectures in C#. Plugin handlers and feature handlers share a design property: their dependencies are explicit and injectable, making them natural unit test targets. The testing plugin architectures in C# article covers how the same WebApplicationFactory approach works for extensible plugin systems.
For the relationship between clean architecture and testability, C# Clean Architecture with MediatR shows how layered dependency rules can complement the testing approach used here.
Frequently Asked Questions
What is the best testing strategy for feature slices in C#?
Use unit tests for handler business logic with EF Core in-memory or SQLite. Use integration tests with WebApplicationFactory<T> to cover routing, model binding, validation responses, and authentication. This combination gives you fast, focused unit tests and high-confidence integration tests without redundancy.
Should I mock AppDbContext in unit tests for feature handlers?
Generally no. Mocking DbContext requires setting up DbSet<T> mocks that are complex and fragile. EF Core's in-memory provider or SQLite in-memory mode gives you a real DbContext with working query behavior and is straightforward to set up in test constructors.
How do I isolate tests so they don't share database state?
Create a fresh in-memory database for each test or test class by using a unique database name (e.g., Guid.NewGuid().ToString()). This ensures no shared state between tests without needing database cleanup code.
Should I test the endpoint or the handler?
Test both, but for different things. Handler tests verify business logic: does the handler correctly update state, return the right result, and handle edge cases? Endpoint tests (integration tests) verify wiring: does the HTTP route exist, does it bind the request model correctly, and does it return the expected status code?
How does testing feature slices compare to testing MediatR handlers?
Feature slice handlers are slightly easier to test because there is no mediator dispatch to configure. You instantiate the handler class directly and call HandleAsync. With MediatR, you either mock IMediator (which tests nothing useful) or configure a real mediator in tests (which adds complexity). Direct handlers are simpler test targets.
How should I name test methods for feature slice tests?
Use the pattern MethodUnderTest_Condition_ExpectedResult for non-parameterized tests: HandleAsync_TaskNotFound_ReturnsFalse. For parameterized tests (Theory), use MethodUnderTest_ScenarioBeingExercised: HandleAsync_InvalidTitleVariants. This naming makes test output readable when tests fail in CI.
Can I use Testcontainers for feature slice integration tests?
Yes. Replace the in-memory EF Core provider in your WebApplicationFactory configuration with a real database connection string from your Testcontainers fixture. Feature slice integration tests work identically with Testcontainers -- the test structure does not change, only the database backend does.

