Testing ASP.NET Core middleware - part 5

Testing ASP.NET Core middleware - part 5

Introduction

This is the last in a series of posts that explains how ASP.NET Core middleware components can be written.


Articles in this series

Part 1 - Introduction to ASP.NET Core middleware
Part 2 - Writing inline ASP.NET Core middleware using lambda expressions
Part 3 - Writing convention-based ASP.NET Core middleware
Part 4 - Writing factory-based ASP.NET Core middleware
Part 5 - Testing ASP.NET Core middleware


The previous parts covered topics such as dependency injection, the lifetime of a middleware class, and other nuances. One aspect was deliberately been left out, namely that of testability.

🔎 Retrospect: The three ways of writing a middleware component are 1) inline using lambda expressions, 2) convention-based, and 3) factory-based by implementing the IMiddleware interface.

Writing unit and integration tests is an art form in itself. Discussions about this often take on religious forms. What test coverage should the application have? Should one rigorously follow the red-green-refactor approach derived from test-driven development and write the tests first before the implementation?

Every developer or team has to answer these questions for themselves. But there is probably general agreement that tests significantly and measurably increase code quality. A study by Microsoft has shown that the defect density (ratio between the number of bugs and lines of code) can be reduced by between 40% and 90% through the use of TDD.

Therefore, the time investment in tests is worthwhile, and your middleware classes should also be subjected to extensive testing. This is also because a faulty middleware can put the entire ASP.NET Core application in an unusable state. To ensure correct functionality, these should be checked in isolation (in the form of a unit test) and with the entire request3 pipeline (in the form of integration tests).

Demonstration example

Let's take an illustrative middleware that should perform health checks, which mimics the ASP.NET Core's built-in middleware with the same name.

The exemplary middleware depends on IHealthCheckService, which provides information about the current state of the application and other rudimentary metrics concerning CPU and memory usage. The middleware responds to the /health path and writes metrics to the HTTP response. It also adds the custom header X-Health-Check with a value of Healthy or Degraded.

🔎 Directly defining a path in the middleware is not recommended and is used here only for demonstration purposes. Instead, you should use endpoint routing, which offers much more flexibility.
// IHealthCheckService.cs 
public interface IHealthCheckService
{
    public bool IsHealthy();

    public string GetMetrics();
}

// HealthCheckService.cs
public class HealthCheckService : IHealthCheckService
{
    public virtual bool IsHealthy() => true;

    public virtual string GetMetrics()
    {
        var process = Process.GetCurrentProcess();
        
        return $"CPU[ms]: {process.TotalProcessorTime.Milliseconds}, MEM[b]: {process.WorkingSet64}";
    }
}

// HealthCheckMiddleware.cs
public class HealthCheckMiddleware
{
    private readonly RequestDelegate _next;

    public HealthCheckMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context, IHealthCheckService service)
    {
        if (context.Request.Path.StartsWithSegments("/health"))
        {
            context.Response.Headers.Add("X-Health-Check", service.IsHealthy() ? "Healthy" : "Degraded");

            await context.Response.WriteAsync(service.GetMetrics());
        }
        else
        {
            await _next.Invoke(context);    
        }
    }
}

Unit Testing

The goal of a unit test is to verify a small piece of code (also known as a unit) in an isolated manner, whereas the test's runtime should be short (Khorikov, 2020, p.21).

Usually, middleware has domain-specific dependencies (such as IHealthCheckService from the example) and framework dependencies (HTTP context, request delegate). To reach full isolation, these must be simulated with helper objects, such as mocks or stubs.

Creating an artificial HTTP context

The introducing part of this series explained how Kestrel wraps a client request into an HttpContext object and how it's getting passed from middleware to middleware.

By looking at the signature of this class, you will notice that it's marked as abstract and therefore can't be instantiated for further use by a test.

Theoretically, you could derive a dedicated type for testing purposes. However, this is not recommended due to the required initialization effort that would come with it.

A much more convenient variant is to use the default implementation DefaultHttpContext, which is already derived from HttpContext.

var context = new DefaultHttpContext();
context.Request.Path = "/health"; 
// ... further customizations

Alternatively, the HttpContext class can be mocked with libraries like Moq.

A simple example

The unit test below checks whether the pipeline is actually short-circuited in case an HTTP request is directed to the /health path.

For this purpose, the request path is set manually in the HTTP context to simulate the client request. The subsequent middleware (RequestDelegate) and the HealthCheckService are replaced by a mock.

[Fact]
public async Task HealthCheckMiddleware_should_terminate()
{
    // arrange
    var context = new DefaultHttpContext();
    context.Request.Path = "/health";
    
    var serviceMock = new Mock<HealthCheckService>();
    serviceMock.Setup(s => s.GetMetrics()).Returns("Fake Metric");
    
    var nextMock = new Mock<RequestDelegate>();
    
    var middleware = new HealthCheckMiddleware(nextMock.Object);

    // act
    await middleware.InvokeAsync(context, serviceMock.Object);

    // assert
    nextMock.Verify(n => n.Invoke(context), Times.Never);
}
Unit test

Based on the example middleware presented earlier, further unit tests could validate that...

• ...the header is set correctly
• ...the header is not set if the path differs from /health
• ...the injected service is called when there is a request to /health
• ...the response body returns key metrics for requests to /health
• ...the injected service is not called for other paths
• ...a potential subsequent middleware is called without errors
• ...an exception is thrown if the middleware calls an uninstantiated service
• ...

The unit test above examined the behavior of the middleware. If you want to check that the service writes key metrics to the response body, things become a bit more complex.

Reading the HTTP payload

The body of an HTTP message can be accessed by using streams (System.IO). These streams are exposed by the HttpContext.Request.Body respectively the HttpContext.Response.Body properties.

At runtime, these streams are of type HttpRequestStream and HttpResponseStream. If for testing purposes, an HTTP context is created by using DefaultHttpContext, these properties are instantiated with the internal type NullStream, that requires special care.

var context = new DefaultHttpContext(); 

// Both are of type System.IO.Stream+NullStream
var requestBodyType = context.Request.Body.GetType().FullName;
var responseBodyType = context.Response.Body.GetType().FullName;
DefaultHttpContext and NullStream

Since a NullStream discards all data getting written, it must be replaced with a MemoryStream for testing purposes. This is the only way you'll be able to access the payload. The following unit test illustrates the usage.

[Fact]
public async Task HealthCheckMiddleware_should_return_metrics()
{
    // arrange
    var bodyStream = new MemoryStream();
    
    var context = new DefaultHttpContext();
    context.Request.Path = "/health";
    context.Response.Body = bodyStream;

    var nextMock = new Mock<RequestDelegate>();
    var serviceMock = new Mock<HealthCheckService>();
    serviceMock.Setup(s => s.GetMetrics()).Returns("Fake Metric");
    
    var middleware = new HealthCheckMiddleware(nextMock.Object);

    // act
    await middleware.InvokeAsync(context, serviceMock.Object);

    // assert
    bodyStream.Seek(0, SeekOrigin.Begin);
    using var stringReader = new StreamReader(bodyStream);
    var body = await stringReader.ReadToEndAsync();
    
    Assert.Equal("Fake Metric", body);
}
Reading the response body

Integration Testing

So far we've used several unit tests to ensure our middleware works in isolation. However, we can't say for sure, if our middleware works as expected with other middleware components. For this purpose, we'll need at least one integration test.

But how do you test the interaction of your own middleware class with the ASP.NET Core Framework and the rest of the pipeline?

For this purpose, Microsoft provides two practical tools that come in the form of two libraries Microsoft.AspNetCore.TestHost and Microsoft.AspNetCore.Mvc.Testing. With these, writing integration tests for ASP.NET Core can be greatly simplified.

In-memory ASP.NET Core Server

The TestHost library includes an in-memory host (TestServer) with which middleware classes can be tested in isolation. The TestServer allows the creation of an HTTP request pipeline, that only contains components required for the test case. In this way, specific requests can be sent to the middleware and its behavior can be examined.

The communication between the test client and the test server takes place exclusively in RAM. This has the advantage that developers do not have to deal with issues such as TCP port management or TLS certificates. And it further allows for keeping the test execution time to a minimum. Any exceptions thrown by the middleware will be handed back directly to the calling test. And in addition, the HttpContext can be manipulated directly in the test.

The test below demonstrates, how the extension method UseTestServer() can be used to create a test environment and integrate a middleware class. Next to the TestServer, the TestHost library contains a TestClient, which can be created by calling GetTestClient(). By issuing GetAsync("/health"), an HTTP GET request is sent to the pipeline and the response can be used for asserting.

[Fact]
public async Task HealthCheckMiddleware_should_set_header_and_return_metrics()
{
    // arrange
    var hostBuilder = new HostBuilder()
        .ConfigureWebHost(webHostBuilder =>
        {
            webHostBuilder.ConfigureServices(services => 
            {
                services.AddScoped<IHealthCheckService, HealthCheckService>();
            });
            webHostBuilder.Configure(applicationBuilder =>
            {
                applicationBuilder.UseMiddleware<HealthCheckMiddleware>();
            });
            webHostBuilder.UseTestServer(); 
        });

    var testHost = await hostBuilder.StartAsync();
    var client = testHost.GetTestClient();

    // act
    var response = await client.GetAsync("/health");

    // assert
    response.EnsureSuccessStatusCode();
    
    Assert.True(response.Headers.Contains("X-Health-Check"));
    
    var body = await response.Content.ReadAsStringAsync();
    Assert.NotEmpty(body);
}
Using the in-memory server for integration testing

By using SendAsync() the test server also allows for direct manipulation of the HTTP context. The example below sends the same request as the test from above. However, you have more granular control over the request sent.

[Fact]
public async Task HealthCheckMiddleware_should_set_header()
{
    // arrange & act
    using var host = await new HostBuilder()
        .ConfigureWebHost(webBuilder =>
        {
            webBuilder.ConfigureServices(services => 
            {
                services.AddScoped<IHealthCheckService, HealthCheckService>();
            });
            webBuilder.Configure(app =>
            {
                app.UseMiddleware<HealthCheckMiddleware>();
            });
            webBuilder.UseTestServer();
        }).StartAsync();

    var server = host.GetTestServer();
    var context = await server.SendAsync(context =>
    {
        context.Request.Method = HttpMethods.Get;
        context.Request.Path = "/health";
    });

    // assert
    Assert.True(context.Response.Headers.ContainsKey("X-Health-Check"));
}
TestServer and SendAsync example

Of course, multiple middleware classes can be added to the test pipeline and tested together (where appropriate). Factory-based middleware requires explicit registration with the DI container.

// arrange
var hostBuilder = new HostBuilder()
    .ConfigureWebHost(webHostBuilder =>
    {
        webHostBuilder.ConfigureServices(services =>
        {
            Services.AddScoped<IHealthCheckService, HealthCheckService>();
            services.AddScoped<LoggingMiddleware>();
        });
        
        webHostBuilder.Configure(applicationBuilder =>
        {
            applicationBuilder.UseMiddleware<HealthCheckMiddleware>();
            applicationBuilder.UseMiddleware<LoggingMiddleware>();
            applicationBuilder.Run(async context =>
            {
                await context.Response.WriteAsync("Terminating middleware");
            });
        });
        webHostBuilder.UseTestServer(); 
    });
    
// ..
Testing with multiple middleware classes

Using the WebApplicationFactory

The previous examples demonstrated how a test pipeline can be put together for different testing scenarios. If you'd like to include additional services, DI registrations, etc. in your testing, things get more complicated.

For such scenarios the WebApplicationFactory is of great help, which resides in the Microsoft.AspNetCore.Mvc.Testing library and enables in-memory testing of the entire ASP.NET Core application. The WebApplicationFactory uses the TestServer internally and allows including the real DI registrations, all configuration parameters, and of course, the pipeline itself.

[Fact]
public async Task ExampleApp_should_set_header_and_return_metrics()
{
    // arrange
    var application = new WebApplicationFactory<Program>();
    var client = application.CreateClient();
    
    // act
    var response = await client.GetAsync("/health");
    
    // assert
    response.EnsureSuccessStatusCode();
    Assert.True(response.Headers.Contains("X-Health-Check"));
    
    var body = await response.Content.ReadAsStringAsync();
    Assert.NotEmpty(body);
}
Simple integration test using the WebApplicationFactory

The test from above is not yet executable. Since ASP.NET Core 6, the Program class doesn't require an explicit definition anymore. That's why it needs to be made visible to the test project.

// ExampleApp.csproj
<itemGroup>
    <InternalsVisibleTo Include=”MyTestProject” />
</itemGroup>

// Program.cs
using HealthChecks.Middleware;
using HealthChecks.Services;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IHealthCheckService, HealthCheckService>();

var app = builder.Build();
app.UseMiddleware<HealthCheckMiddleware>();

app.Run();

public partial class Program { }
Making the Program class visible to the test project

Replacing dependencies

By using the WebApplicationFactory for integration testing, the SUT will behave like it's running in a productive environment. This implies that it also will make calls to any external API or database during the test execution.

Usually, you'll want to simulate these dependencies by mocking or stubbing them. Later, you can use the ConfigureTestServices method to add these test doubles to the DI container. Here is an example.

private class HealthCheckMock : IHealthCheckService
{
    public bool IsHealthy() => true;
    public string GetMetrics() => "Fake metrics";
}

[Fact]
public async Task ExampleApp_should_set_header()
{
    // arrange
    var application = new WebApplicationFactory<Program>()
        .WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                services.AddSingleton<IHealthCheckService, HealthCheckMock>();
            });
        });
    var client = application.CreateClient();
    
    // act
    var response = await client.GetAsync("/health");
    
    // assert
    response.EnsureSuccessStatusCode();
    Assert.True(response.Headers.Contains("X-Health-Check"));
    
    var body = await response.Content.ReadAsStringAsync();
    Assert.NotEmpty(body);
}

Conclusion

Let me summarize the most important points about testing middleware.

  • If you need to create an HTTP context object for testing, create one by using the default implementation DefaultHttpContext
  • To read the HTTP body you need to replace the NullStream with a MemoryStream
  • Testing with the WebApplicationFactory runs the entire ASP.NET Core application in memory
  • When testing with the WebApplicationFactory, the SUT will behave as in production and also make calls to any potential APIs and DBs
  • Use ConfigureTestServices to make use of test doubles during testing

This concludes my series about writing ASP.NET Core middleware components. I hope you enjoyed reading it!

Happy hacking! 👨🏻‍💻

Further reading

Test ASP.NET Core middleware
Learn how to test ASP.NET Core middleware with TestServer.
Integration tests in ASP.NET Core
Learn how integration tests ensure that an app’s components function correctly at the infrastructure level, including the database, file system, and network.
Realizing quality improvement through test driven development: results and experiences of four industrial teams - Empirical Software Engineering
Test-driven development (TDD) is a software development practice that has been used sporadically for decades. With this practice, a software engineer cycles minute-by-minute between writing failing unit tests and writing implementation code to pass those tests. Test-driven development has recently r…
Introduction to ASP.NET Core middleware - part 1
This article explains the high-level concepts of an ASP.NET Core middleware and is the first in a series of three articles.
Writing inline ASP.NET Core middleware using lambda expressions - part 2
This article is part of a series about ASP.NET Core middleware and explains how inline-middleware can be written using lambda expressions
Writing convention-based ASP.NET Core middleware - part 3
This article is part of a series that explains how to develop convention-based middleware components with ASP.NET Core
Writing factory-based ASP.NET Core middleware - part 4
This article is part of a series that explains how to develop factory-based ASP.NET Core middleware
Testing ASP.NET Core middleware - part 5
Introduction This is the last in a series of posts that explains how ASP.NET Core middleware components can be written. Articles in this series Part 1 - Introduction to ASP.NET Core middleware Part 2 - Writing inline ASP.NET Core middleware using lambda expressions Part 3 - Writing
Show Comments