When exploring best practices for automated testing and Test-Driven Development (TDD), we often refer to the Test Pyramid. This diagram describes the different kinds of tests, and the relative number of them we should aim for.

The general idea is that different tests have different levels of granularity, and the less granular our tests, the more integration points they have and therefore the slower they become:

Testing Pyramid, Fowler

We strive for fast feedback loops, and fast tests give us fast feedback. Unfortunately, over the years, this desire has led to some rather convoluted designs. Developers end up sacrificing quality for speed as they introduce more and more abstractions to mock in their unit tests to artificially speed them up. These kinds of tests, in which each interaction is mocked, lead to over-specification and brittleness. The test failures no longer mean a break in behavior, but a break in implementation.

With Vertical Slice Architecture, we remove much of the problem of brittle tests by focusing on behavior instead of implementation. The result are tests that are decoupled from the details of individual handlers, allowing us to refactor handlers without affecting them.

Setting up vertical slice tests

In a Vertical Slice Architecture, the behavior for a slice is encapsulated behind a pattern of a single input, a handler, and a single output:

Request-Response

The basic setup of these tests is to build a Request, pass to a handler, and then assert on the Response. However, our handler typically interacts with something else—a database, an API, or some other external dependency. Our setup needs to ensure that these dependencies are properly isolated and initialized to some known state before the assertion phase of the test.

When our handler interacts with dependencies, we have to examine: Is this a dependency that we can isolate and control? If we cannot isolate and control a dependency, we should consider substituting a fake/stub instance in which the inputs/outputs are set explicitly in the test.

If we can isolate and control the dependency (a database, for example), then we should set up the data appropriately. All this setup can be summarized as “Fixture Setup,” where we define a known set of conditions under which the code we’re testing should execute.

Vertical slices rarely execute in isolation. Typically, they’re executed inside of some host application. With modern applications, there’s quite a bit of code that goes into setting up the environment for our running application. We could execute tests at the outermost layer of our system: HTTP, AMQP etc. That would ensure that we capture all of the code executing in our system during the test. However, the interesting bits of our application are the things inside our handler, so we want to focus on that.

To ensure our handler executes as close as possible to the conditions under which we run our application, we should execute our handlers as the application does—with dependency injection, request scopes, and all of our potential behaviors running:

test handler

In order to ensure our local test fixture matches our application environment, we can take advantage of the Microsoft.AspNetCore.Mvc.Testing package, which includes an in-memory test host and server for our .NET Core application. This package is primarily designed for end-to-end tests, where we use HTTP to interact with a test server, but we can work one level below that to write subcutaneous tests at the Handler level.

Building the test fixture

A test fixture represents all the things we need to have in place in order to execute and assert in our test. Our test fixture will include the test host that provides the main setup for building out our handler pipeline. We can use the WebApplicationFactory to build the test server, and override any configuration that is specific to our Vertical Slice tests:

public class ContosoTestApplicationFactory 
    : WebApplicationFactory<Startup>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureAppConfiguration((context, configBuilder) =>
        {
            configBuilder.AddInMemoryCollection(new Dictionary<string, string>
            {
                {"ConnectionStrings:DefaultConnection", _connectionString}
            });
        });
    }
}

Our connection string connects to a local test database that we can wipe before each test if we like. Next, we define our collection fixture as an xUnit Collection Fixture to ensure our test server is initialized only once per test run:

[CollectionDefinition(nameof(SliceFixture))]
public class SliceFixtureCollection : ICollectionFixture<SliceFixture> { }

public class SliceFixture : IAsyncLifetime
{
    private readonly Checkpoint _checkpoint;
    private readonly IConfiguration _configuration;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly WebApplicationFactory<Startup> _factory;

    public SliceFixture()
    {
        _factory = new ContosoTestApplicationFactory();

        _configuration = _factory.Services.GetRequiredService<IConfiguration>();
        _scopeFactory = _factory.Services.GetRequiredService<IServiceScopeFactory>();

        _checkpoint = new Checkpoint();
    }
}

This “SliceFixture” is the injected fixture we pass in to all of our tests. In its constructor, we build the custom WebApplicationFactory, grab some needed dependencies, and finally create a Respawn checkpoint for resetting our test database between test runs:

public Task InitializeAsync() 
    => _checkpoint.Reset(
         _configuration.GetConnectionString("DefaultConnection"));

public Task DisposeAsync()
{
    _factory?.Dispose();
    return Task.CompletedTask;
}

With a test fixture in place, we now want a way to execute a handler as close as possible to our production application. In a production environment, each request creates a new IServiceScope, which gives a scoped set of dependencies that are properly disposed of at the end of the request. Many .NET Core dependencies are registered as scoped, including EF Core DbContext objects.

In a typical application using MediatR, the UI component (Controller or Razor Page) calls out to the IMediator instance, all from inside a request scope. We can emulate that behavior by first defining a way to execute code from inside a request scope from our fixture:

public async Task ExecuteScopeAsync(Func<IServiceProvider, Task> action)
{
    using var scope = _scopeFactory.CreateScope();
    var dbContext = scope.ServiceProvider.GetService<SchoolContext>();

    try
    {
        await dbContext.BeginTransactionAsync();

        await action(scope.ServiceProvider);

        await dbContext.CommitTransactionAsync();
    }
    catch (Exception)
    {
        dbContext.RollbackTransaction(); 
        throw;
    }
}

With this method, we pass in a delegate that represents the asynchronous action to perform. Inside the method, we emulate what happens during a normal request—we create a scope, grab a DbContext, and execute a transaction around our executing method. With this basic method in place, we can build more complex versions that send a MediatR request and return the response:

public Task<TResponse> SendAsync<TResponse>(IRequest<TResponse> request)
{
    return ExecuteScopeAsync(sp =>
    {
        var mediator = sp.GetService<IMediator>();

        return mediator.Send(request);
    });
}

In this method, we mimic the IMediator interface to send a request and receive the response, but the implementation will send the request from inside a scope, just like our real application. As we need more and more scoped actions and helper methods in our tests, we can continue to extend our fixture.

Vertical slice test example

Now that we have our fixture in place, we can start to write vertical slice tests for our system. Let’s look at two different examples—one for reads and one for writes. In a typical test for reads, we can assert only on the result being returned from the request. To get the biggest “bang for the buck” in our tests, we want to make sure that the way they’re set up and run matches, as closely as possible, how the application works. We don’t want to set up test data that can’t happen in real life, so, ideally, we add that data in the same vertical slices that a user would interact with.

For example, we have a test that shows a list of items. We can set up some data using the normal commands used by the UI:

[Fact]
public async Task Should_return_all_courses()
{
    var adminId = await _fixture.SendAsync(
        new ContosoUniversity.Pages.Instructors.CreateEdit.Command
        {
            FirstMidName = "George",
            LastName = "Jones",
            HireDate = DateTime.Today
        });
}

From here, we find that we can’t set up the rest of the test data through the UI. Instead, we’ll need to create it directly:

var history = new Course
{
    Credits = 4,
    Department = historyDept,
    Id = _fixture.NextCourseNumber(),
    Title = "History 101"
};
await _fixture.InsertAsync(
    englishDept, 
    historyDept, 
    english, 
    history);

The InsertAsync method above uses a scope to insert all entities inside a transaction. With our test data set up, we can now send our Request (query) and assert the result:

var result = await _fixture.SendAsync(new Index.Query());

result.ShouldNotBeNull();
result.Courses.Count.ShouldBeGreaterThanOrEqualTo(2);

var courseIds = result.Courses.Select(c => c.Id).ToList();
courseIds.ShouldContain(english.Id);
courseIds.ShouldContain(history.Id);

We’re careful to only make assertions about what the data should contain, but ignore other data that it can contain. Our test data might have other data from other tests, so we should not make assumptions that the results contain an exact list that we set up. The data returned is a superset of our data plus other tests, so we can only assert the data we set up.

For commands, where our handler mutates data but may or may not return anything, we’ll want to make another round trip to the database to assert the expected side effects. The “Act” step looks similar, sending a request to our vertical slice:

var command = new Edit.Command
{
    Id = course.Id,
    Credits = 5,
    Title = "English 202"
};

await _fixture.ExecuteDbContextAsync(async (ctxt, mediator) =>
{
    command.Department = await ctxt.Departments.FindAsync(newDept.Id);

    await mediator.Send(command);
});

This test’s command is a little more complex in that it has a property that is an entity from DbContext, so we need to ensure that reference comes from the scope executed.

Finally, our assertion isn’t on the result, but on the expected side effects:

var edited = await _fixture.FindAsync<Course>(course.Id);

edited.ShouldNotBeNull();
edited.DepartmentId.ShouldBe(newDept.Id);
edited.Credits.ShouldBe(command.Credits.GetValueOrDefault());
edited.Title.ShouldBe(command.Title);

In these vertical slice tests, our individual test methods execute several database transactions (just as a user interacting with our system would), with round trips for each interaction. With interactions for separate transactions, our test matches very closely with how the real application behaves. On the other hand, if we were to use a single transaction, in-memory database fake/mocks, or transaction rollbacks, our test would no longer resemble our application runtime and the value would be diminished by false positives/negatives.

We do tend to have a greater percentage of these tests against unit tests, but only because many slices do not need additional testing beyond what the vertical slice test provides. If we were to refactor our code, we could test those smaller classes, but our vertical slice test would remain untouched.

Vertical Slice Architecture ensures that each slice completely encapsulates the implementation detail from other slices. Thus, our vertical slice tests are shielded from those implementation details in our test code. This allows our implementations to be refactored, modified, or even replaced, while our test code is none the wiser.

Let's Talk

Have a tech-oriented question? We'd love to talk.