TL;DR:
This guide teaches you how to test ASP.NET Core middleware using both unit tests and integration tests.
You’ll learn how to:
- Mock
HttpContext
andRequestDelegate
for isolated unit testing. - Use TestServer and WebApplicationFactory for realistic integration testing.
- Handle middleware with dependencies like
IMemoryCache
orILogger
. - Test error handling, performance, and memory usage.
- Create reusable test doubles to simplify your test setup.
Bottom line: Unit tests catch logic bugs fast, while integration tests ensure your middleware works in the real pipeline. Use both for confidence.
You know that feeling when you write some middleware and then realize you have no idea how to test it? Yeah, I’ve been there. ASP.NET Core middleware testing is one of those things that seems like it should be simple, but then you dive in and realize middleware lives in this weird space between HTTP requests, dependency injection, and the pipeline.
Let me walk you through what I’ve learned about testing middleware effectively.
We’ll cover unit testing ASP.NET Core middleware, integration testing ASP.NET Core middleware, and how to create solid test doubles in ASP.NET Core that won’t break every time you change something.
Why Is ASP.NET Core Middleware Testing Hard?
Here’s the thing, middleware isn’t like testing a regular service class. It’s sitting right in the middle of the HTTP pipeline, which means it needs HttpContext, it needs to call the next middleware, and it probably talks to other services too. That’s a lot of moving parts.
The main headaches you’ll run into:
- Pipeline headaches: Your middleware needs that RequestDelegate to call the next thing in line
- HttpContext complexity: Creating realistic HttpContext for tests is more involved than you’d think
- Everything’s async: Because of course it is
- Side effects everywhere: Middleware loves to modify requests, responses, and log stuff
Let me show you a typical middleware we’ll work with throughout this guide:
public class LoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<LoggingMiddleware> _logger;
public LoggingMiddleware(RequestDelegate next, ILogger<LoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
// Log what's coming in
_logger.LogInformation("Processing request: {Method} {Path}",
context.Request.Method, context.Request.Path);
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation("Request completed in {ElapsedMilliseconds}ms",
stopwatch.ElapsedMilliseconds);
}
}
}
Nothing too fancy, but it gives us something real to work with.
How to Unit Test ASP.NET Core Middleware
When you’re doing unit testing ASP.NET Core middleware, you’re basically testing your middleware logic without worrying about the rest of the pipeline. This is where xUnit middleware unit testing shines.
The trick is mocking HttpContext in middleware tests and creating a middleware fake RequestDelegate. Here’s how I usually set this up:
public class LoggingMiddlewareTests
{
private readonly Mock<ILogger<LoggingMiddleware>> _loggerMock;
private readonly Mock<RequestDelegate> _nextMock;
public LoggingMiddlewareTests()
{
_loggerMock = new Mock<ILogger<LoggingMiddleware>>();
_nextMock = new Mock<RequestDelegate>();
}
[Fact]
public async Task Should_Log_Request_Details()
{
// Arrange
var middleware = new LoggingMiddleware(_nextMock.Object, _loggerMock.Object);
var context = new DefaultHttpContext();
context.Request.Method = "GET";
context.Request.Path = "/api/users";
// Act
await middleware.InvokeAsync(context);
// Assert - this is where the magic happens
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((o, t) => o.ToString().Contains("Processing request: GET /api/users")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
// Make sure we called the next middleware
_nextMock.Verify(x => x(context), Times.Once);
}
[Fact]
public async Task Should_Log_Completion_Time()
{
// Arrange
var middleware = new LoggingMiddleware(_nextMock.Object, _loggerMock.Object);
var context = new DefaultHttpContext();
// Let's simulate slow downstream middleware
_nextMock.Setup(x => x(It.IsAny<HttpContext>()))
.Returns(Task.Delay(100));
// Act
await middleware.InvokeAsync(context);
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((o, t) => o.ToString().Contains("Request completed in") &&
o.ToString().Contains("ms")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
}
The key insight here is that Mock<RequestDelegate>
lets you fake the next middleware in the pipeline. This is super useful for testing timing behavior or making sure your middleware plays nice with others.
How to Integration Test Middleware in ASP.NET Core
Custom middleware unit tests are great, but sometimes you need to see how everything works together. That’s where integration testing ASP.NET Core middleware comes in, and TestServer integration testing is your best friend here.
Microsoft.AspNetCore.TestHost usage lets you spin up a test server and hit your middleware with real HTTP requests:
public class MiddlewareIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public MiddlewareIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Should_Process_Request_Through_Pipeline()
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Swap out real services for test ones
services.AddSingleton<ILogger<LoggingMiddleware>>(
new Mock<ILogger<LoggingMiddleware>>().Object);
});
}).CreateClient();
// Act
var response = await client.GetAsync("/api/test");
// Assert
response.EnsureSuccessStatusCode();
// You can check headers, response content, whatever
}
}
Sometimes you want more control over the pipeline. Here’s how to create your own test server for middleware request pipeline testing:
public class CustomMiddlewareIntegrationTests
{
[Fact]
public async Task Should_Execute_Middleware_In_Correct_Order()
{
// Arrange
var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton<ILogger<LoggingMiddleware>>(
new Mock<ILogger<LoggingMiddleware>>().Object);
var app = builder.Build();
// Set up the pipeline exactly how you want it
app.UseMiddleware<LoggingMiddleware>();
app.Use(async (context, next) =>
{
context.Response.Headers.Add("X-Custom-Header", "test-value");
await next();
});
app.MapGet("/test", () => "Hello World");
// Act
var testServer = new TestServer(app);
var client = testServer.CreateClient();
var response = await client.GetAsync("/test");
// Assert
Assert.Equal("test-value", response.Headers.GetValues("X-Custom-Header").First());
}
}
This approach gives you an in-memory server for integration tests that’s perfect for testing how your middleware behaves in the real pipeline.
Testing Middleware with Dependencies like IMemoryCache
Real middleware usually depends on other services. Let’s look at a rate-limiting middleware that uses IMemoryCache. This is where dependency injection in middleware testing gets interesting:
public class RateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly ILogger<RateLimitingMiddleware> _logger;
public RateLimitingMiddleware(RequestDelegate next, IMemoryCache cache,
ILogger<RateLimitingMiddleware> logger)
{
_next = next;
_cache = cache;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var clientId = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var cacheKey = $"rate_limit_{clientId}";
if (_cache.TryGetValue(cacheKey, out int requestCount))
{
if (requestCount >= 10)
{
context.Response.StatusCode = 429; // Too Many Requests
await context.Response.WriteAsync("Rate limit exceeded");
return;
}
_cache.Set(cacheKey, requestCount + 1, TimeSpan.FromMinutes(1));
}
else
{
_cache.Set(cacheKey, 1, TimeSpan.FromMinutes(1));
}
await _next(context);
}
}
Testing this requires some creativity with mocking:
public class RateLimitingMiddlewareTests
{
[Fact]
public async Task Should_Allow_Request_When_Under_Limit()
{
// Arrange
var cacheMock = new Mock<IMemoryCache>();
var loggerMock = new Mock<ILogger<RateLimitingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
// Tell the cache mock to return false (key doesn't exist)
cacheMock.Setup(x => x.TryGetValue(It.IsAny<object>(), out It.Ref<object>.IsAny))
.Returns(false);
var middleware = new RateLimitingMiddleware(nextMock.Object, cacheMock.Object, loggerMock.Object);
var context = new DefaultHttpContext();
context.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1");
// Act
await middleware.InvokeAsync(context);
// Assert
nextMock.Verify(x => x(context), Times.Once);
cacheMock.Verify(x => x.Set(It.IsAny<object>(), 1, It.IsAny<TimeSpan>()), Times.Once);
}
[Fact]
public async Task Should_Block_Request_When_Over_Limit()
{
// Arrange
var cacheMock = new Mock<IMemoryCache>();
var loggerMock = new Mock<ILogger<RateLimitingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
// Simulate hitting the rate limit
object existingValue = 10;
cacheMock.Setup(x => x.TryGetValue(It.IsAny<object>(), out existingValue))
.Returns(true);
var middleware = new RateLimitingMiddleware(nextMock.Object, cacheMock.Object, loggerMock.Object);
var context = new DefaultHttpContext();
context.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1");
context.Response.Body = new MemoryStream();
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.Equal(429, context.Response.StatusCode);
nextMock.Verify(x => x(context), Times.Never);
}
}
How to Test Error Handling in ASP.NET Core Middleware
Middleware testing best practices include testing error scenarios. Here’s a simple error-handling middleware:
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ErrorHandlingMiddleware> _logger;
public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Something went wrong processing the request");
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Internal Server Error");
}
}
}
Testing the error handling:
[Fact]
public async Task Should_Handle_Downstream_Exception()
{
// Arrange
var loggerMock = new Mock<ILogger<ErrorHandlingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
var testException = new InvalidOperationException("Test exception");
nextMock.Setup(x => x(It.IsAny<HttpContext>()))
.ThrowsAsync(testException);
var middleware = new ErrorHandlingMiddleware(nextMock.Object, loggerMock.Object);
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.Equal(500, context.Response.StatusCode);
loggerMock.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
testException,
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
[Fact]
public async Task Should_Handle_Cancellation_Token()
{
// Arrange
var loggerMock = new Mock<ILogger<ErrorHandlingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
var cts = new CancellationTokenSource();
nextMock.Setup(x => x(It.IsAny<HttpContext>()))
.Returns(async () =>
{
await Task.Delay(1000, cts.Token);
});
var middleware = new ErrorHandlingMiddleware(nextMock.Object, loggerMock.Object);
var context = new DefaultHttpContext();
context.RequestAborted = cts.Token;
// Act
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() => middleware.InvokeAsync(context));
}
How to Performance Test Middleware in ASP.NET Core
Automated middleware testing strategies should include performance testing, especially for middleware that runs on every request:
public class MiddlewarePerformanceTests
{
[Fact]
public async Task Should_Complete_Within_Acceptable_Time()
{
// Arrange
var loggerMock = new Mock<ILogger<LoggingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
var middleware = new LoggingMiddleware(nextMock.Object, loggerMock.Object);
var context = new DefaultHttpContext();
var stopwatch = Stopwatch.StartNew();
// Act
await middleware.InvokeAsync(context);
stopwatch.Stop();
// Assert
Assert.True(stopwatch.ElapsedMilliseconds < 10,
$"Middleware took {stopwatch.ElapsedMilliseconds}ms, expected < 10ms");
}
[Fact]
public async Task Should_Not_Leak_Memory_Under_Load()
{
// Arrange
var loggerMock = new Mock<ILogger<LoggingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
var middleware = new LoggingMiddleware(nextMock.Object, loggerMock.Object);
var initialMemory = GC.GetTotalMemory(true);
// Act - let's simulate some load
var tasks = new Task[1000];
for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = Task.Run(async () =>
{
var context = new DefaultHttpContext();
await middleware.InvokeAsync(context);
});
}
await Task.WhenAll(tasks);
GC.Collect();
GC.WaitForPendingFinalizers();
var finalMemory = GC.GetTotalMemory(true);
// Assert
var memoryIncrease = finalMemory - initialMemory;
Assert.True(memoryIncrease < 1_000_000, // 1MB threshold
$"Memory increased by {memoryIncrease} bytes, expected < 1MB");
}
}
Best Practices for Middleware Test Doubles in ASP.NET Core
Writing middleware test doubles gets tedious if you’re creating HttpContext from scratch every time. Here’s a builder pattern that makes life easier:
public class HttpContextBuilder
{
private readonly DefaultHttpContext _context;
public HttpContextBuilder()
{
_context = new DefaultHttpContext();
}
public HttpContextBuilder WithMethod(string method)
{
_context.Request.Method = method;
return this;
}
public HttpContextBuilder WithPath(string path)
{
_context.Request.Path = path;
return this;
}
public HttpContextBuilder WithHeader(string name, string value)
{
_context.Request.Headers[name] = value;
return this;
}
public HttpContextBuilder WithIpAddress(string ipAddress)
{
_context.Connection.RemoteIpAddress = IPAddress.Parse(ipAddress);
return this;
}
public HttpContext Build() => _context;
}
// Using it in tests
[Fact]
public async Task Should_Log_Request_With_Custom_Context()
{
// Arrange
var context = new HttpContextBuilder()
.WithMethod("POST")
.WithPath("/api/orders")
.WithHeader("User-Agent", "TestClient/1.0")
.Build();
var loggerMock = new Mock<ILogger<LoggingMiddleware>>();
var nextMock = new Mock<RequestDelegate>();
var middleware = new LoggingMiddleware(nextMock.Object, loggerMock.Object);
// Act
await middleware.InvokeAsync(context);
// Assert
loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((o, t) => o.ToString().Contains("POST /api/orders")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
For middleware test isolation techniques, you can also use shared fixtures:
public class MiddlewareTestFixture : IDisposable
{
public Mock<ILogger<LoggingMiddleware>> LoggerMock { get; }
public Mock<RequestDelegate> NextMock { get; }
public MiddlewareTestFixture()
{
LoggerMock = new Mock<ILogger<LoggingMiddleware>>();
NextMock = new Mock<RequestDelegate>();
}
public void Dispose()
{
// Clean up if needed
}
}
public class LoggingMiddlewareTests : IClassFixture<MiddlewareTestFixture>
{
private readonly MiddlewareTestFixture _fixture;
public LoggingMiddlewareTests(MiddlewareTestFixture fixture)
{
_fixture = fixture;
}
// Your tests use _fixture.LoggerMock and _fixture.NextMock
}
Understanding the ASP.NET Core Middleware Pipeline
Understanding ASP.NET Core middleware lifecycle testing means knowing how the pipeline flows:
graph TD A[HTTP Request] --> B[Middleware 1] B --> C[Middleware 2] C --> D[Middleware 3] D --> E[Controller/Endpoint] E --> F[Response] F --> G[Middleware 3 Response] G --> H[Middleware 2 Response] H --> I[Middleware 1 Response] I --> J[HTTP Response] style A fill:#e1f5fe style J fill:#e8f5e8 style E fill:#fff3e0
Unit Test vs Integration Test for Middleware: Which Should You Use?
Here’s my take on middleware unit vs integration testing:
Aspect | Unit Testing | Integration Testing |
---|---|---|
Speed | Super fast | Slower but still reasonable |
What you’re testing | Individual middleware logic | How everything works together |
Setup complexity | Pretty simple | More setup required |
Dependencies | All mocked | Real or test versions |
Debugging | Easy to find issues | Harder to isolate problems |
Confidence level | Logic is correct | End-to-end works |
Maintenance | Low maintenance | More work to maintain |
Common Middleware Testing Mistakes (And How to Avoid Them)
Mistake 1: Forgetting to dispose HttpContext
// This can cause memory leaks
var context = new DefaultHttpContext();
await middleware.InvokeAsync(context);
// Better approach
using var context = new DefaultHttpContext();
await middleware.InvokeAsync(context);
Mistake 2: Over-specific mock verification
// This breaks when you change the log message
_loggerMock.Verify(x => x.LogInformation("Processing request: GET /api/users"));
// More flexible approach
_loggerMock.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((o, t) => o.ToString().Contains("Processing request")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
Mistake 3: Not setting up the response stream
// If your middleware writes to the response, set this up
var responseBody = new MemoryStream();
context.Response.Body = responseBody;
await middleware.InvokeAsync(context);
// Then you can read what was written
responseBody.Seek(0, SeekOrigin.Begin);
var content = await new StreamReader(responseBody).ReadToEndAsync();
ASP.NET Core Middleware Testing Strategy: Final Thoughts
Here’s how I think about middleware testing now:
Unit tests are like testing individual workers on an assembly line. You want to make sure each person knows their job and does it right. These tests are fast, focused, and catch logic bugs early.
Integration tests are like testing the whole assembly line working together. You want to make sure the workers don’t step on each other’s toes and the final product comes out right.
When to use unit tests:
- Testing your middleware logic without external dependencies
- Checking error handling and edge cases
- Fast feedback while you’re coding
- Testing complex business logic inside your middleware
When to use integration tests:
- Making sure middleware runs in the right order
- Testing how your middleware plays with the rest of your app
- Validating the full request/response cycle
- Testing middleware configuration and DI setup
Think of it this way: unit tests give you confidence that your code is correct, integration tests give you confidence that your app actually works.
The sweet spot is having both, but start with unit tests for the core logic, then add integration tests to make sure everything plays nice together. This approach has saved me countless hours of debugging production issues that could have been caught with the right tests.
Related Posts
- Applying OOP in ASP.NET Core
- Add & Modify HTTP Headers in ASP.NET Core Middleware
- ASP.NET Core Middleware Order: Fix Pipeline Issues and Debug Execution Flow
- Recommended Middleware Order in ASP.NET Core for Secure, Fast, and Correct Pipelines
- ASP.NET Core HTTP Logging Middleware: 13 Practical Micro Tips