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 and RequestDelegate for isolated unit testing.
  • Use TestServer and WebApplicationFactory for realistic integration testing.
  • Handle middleware with dependencies like IMemoryCache or ILogger.
  • 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:

AspectUnit TestingIntegration Testing
SpeedSuper fastSlower but still reasonable
What you’re testingIndividual middleware logicHow everything works together
Setup complexityPretty simpleMore setup required
DependenciesAll mockedReal or test versions
DebuggingEasy to find issuesHarder to isolate problems
Confidence levelLogic is correctEnd-to-end works
MaintenanceLow maintenanceMore 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.

About the Author

Abhinaw Kumar is a software engineer who builds real-world systems: from resilient ASP.NET Core backends to clean, maintainable Angular frontends. With over 11+ years in production development, he shares what actually works when you're shipping software that has to last.

Read more on the About page or connect on LinkedIn.

Related Posts