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.
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.
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.
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.
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.
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.
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.
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.
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 aMemoryStream
- 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! 👨🏻💻