Overview
Testing authentication, authorization, middleware, filters, validation, and error responses is about verifying that an ASP.NET Core application behaves correctly across the full HTTP request/response pipeline, not only inside individual methods.
In real applications, a request often passes through many layers before the controller, minimal API handler, or endpoint logic runs:
- Middleware handles concerns such as exception handling, routing, CORS, authentication, authorization, logging, correlation IDs, request limits, and response headers.
- Endpoint routing selects the target endpoint and exposes endpoint metadata.
- Authentication identifies the caller.
- Authorization decides whether the caller can access the endpoint.
- Model binding and validation transform request data into .NET objects and validate them.
- MVC filters or endpoint filters run before and after selected stages.
- The action, handler, or endpoint executes.
- Result execution, response formatting, exception handling, and status-code behavior shape the final response.
This topic matters because many production bugs do not live in the business logic itself. They appear at the boundaries: a protected endpoint accidentally allows anonymous access, a policy is not applied, middleware is ordered incorrectly, validation errors return inconsistent payloads, exception handling leaks internal details, or a filter short-circuits a request unexpectedly.
For interviews, this topic is important because it shows whether a developer understands the difference between unit tests and integration tests. A unit test can verify a validator, service, or authorization requirement in isolation. An integration test can verify that routing, model binding, authentication, authorization, filters, middleware, dependency injection, and response formatting work together as the application actually runs.
A strong candidate should be able to explain when to mock or replace authentication, when to use real authorization policies, when to test middleware through the full pipeline, how to assert validation and error response contracts, and how to avoid tests that pass while the real application remains broken.
Core Concepts
Integration Testing the ASP.NET Core Request Pipeline
ASP.NET Core integration tests commonly use WebApplicationFactory<TEntryPoint> from Microsoft.AspNetCore.Mvc.Testing. The factory starts the application in a test host and gives the test an HttpClient that can send requests through the application pipeline.
This style of test is useful when the behavior depends on more than one component. For example, testing an endpoint that requires authentication and validates a JSON request should usually exercise:
- routing
- middleware ordering
- authentication middleware
- authorization middleware
- endpoint metadata
- model binding
- validation
- filters
- response formatting
- exception handling
Example test project setup:
public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task GetHealth_ReturnsOk()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
In minimal hosting applications, the test project usually needs access to the Program class. A common approach is to add a public partial Program class at the end of Program.cs:
public partial class Program { }
This allows WebApplicationFactory<Program> to locate the application entry point.
Unit Tests vs Integration Tests for Pipeline Behavior
Unit tests are fast and focused. They are appropriate for pure business logic, validators, policy handlers, mapping logic, and custom helper classes.
Integration tests are broader. They are appropriate when behavior depends on ASP.NET Core infrastructure. Examples include:
- Does an unauthenticated request return
401 Unauthorizedor redirect to login? - Does a user without the required policy receive
403 Forbidden? - Does a validation failure return the expected
400 Bad Requestresponse body? - Does the exception handling middleware return the expected
ProblemDetailsresponse? - Does a custom middleware add a correlation ID header?
- Does a custom filter short-circuit the request as expected?
A common mistake is trying to unit test everything by manually constructing HttpContext, controller contexts, filters, and service providers. That can be useful for isolated logic, but it often misses real behavior caused by routing, model binding, filters, result execution, middleware order, or dependency injection configuration.
Testing Authentication
Authentication answers the question: "Who is the caller?"
In production, authentication might use JWT bearer tokens, cookies, OpenID Connect, API keys, client certificates, or another scheme. In integration tests, you usually do not want every test to depend on a real identity provider. Instead, you can replace authentication with a test authentication handler.
A test authentication handler creates a ClaimsPrincipal for the request:
public sealed class TestAuthHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Test";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-123"),
new Claim(ClaimTypes.Name, "Test User"),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("scope", "orders.read")
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
Then the test factory can register the test scheme:
var client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName,
options => { });
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue(TestAuthHandler.SchemeName);
This approach allows tests to focus on application authorization behavior without requiring a real login flow.
Important habits:
- Use test authentication for most API integration tests.
- Keep a smaller number of end-to-end tests for the real identity provider or token validation path.
- Use different test users for anonymous, authenticated, wrong-role, wrong-scope, and valid-access scenarios.
- Disable automatic redirects when you need to assert the first response status code.
- Do not accidentally remove authorization checks just to make tests easier.
Testing Authorization
Authorization answers the question: "Is this caller allowed to do this action?"
Authorization can be based on:
[Authorize][AllowAnonymous]- roles
- claims
- policies
- scopes
- custom authorization requirements
- resource-based authorization
- endpoint metadata
Authorization tests should cover both successful and failing access paths.
Example endpoint:
app.MapGet("/admin/reports", () => Results.Ok(new[] { "Report A" }))
.RequireAuthorization("AdminOnly");
Example policy:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
{
policy.RequireRole("Admin");
});
});
Useful integration test cases:
[Fact]
public async Task GetAdminReports_WhenAnonymous_ReturnsUnauthorized()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var response = await client.GetAsync("/admin/reports");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetAdminReports_WhenUserIsNotAdmin_ReturnsForbidden()
{
var client = CreateAuthenticatedClient(role: "User");
var response = await client.GetAsync("/admin/reports");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task GetAdminReports_WhenUserIsAdmin_ReturnsOk()
{
var client = CreateAuthenticatedClient(role: "Admin");
var response = await client.GetAsync("/admin/reports");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
The difference between 401 and 403 is a common interview point:
401 Unauthorizedmeans the request is not authenticated or authentication failed.403 Forbiddenmeans the user is authenticated but does not have permission.
Testing Claims, Roles, and Policies
Many authorization bugs happen because the app expects one claim type, while the token or test identity uses another claim type.
For example, an app may check ClaimTypes.Role, but the token contains roles, role, or groups. A policy may expect scope, but the identity provider may send scp.
A flexible test authentication setup can help:
public sealed class TestUserOptions
{
public string UserId { get; set; } = "user-123";
public string[] Roles { get; set; } = [];
public Dictionary<string, string> Claims { get; set; } = new();
}
Then individual tests can create users with different claims:
var client = CreateAuthenticatedClient(new TestUserOptions
{
Roles = ["Manager"],
Claims =
{
["department"] = "Finance",
["scope"] = "invoices.approve"
}
});
Good tests should verify the real policy configuration. Avoid replacing the authorization service with a fake that always succeeds, because that can hide broken policy registration, missing metadata, or wrong claim mappings.
Testing Middleware
Middleware is code that runs in the ASP.NET Core request pipeline. It can inspect, change, short-circuit, or pass along requests and responses.
Common middleware concerns include:
- exception handling
- request logging
- correlation IDs
- authentication
- authorization
- CORS
- rate limiting
- response compression
- response headers
- static files
- endpoint routing
Middleware behavior often depends on ordering. For example, authentication must normally run before authorization, and exception handling should be registered early enough to catch downstream exceptions.
A custom middleware can be tested in two ways:
- Unit test the middleware class directly with
DefaultHttpContext. - Integration test the middleware through the full application pipeline.
Example middleware:
public sealed class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers.TryGetValue("X-Correlation-Id", out var value)
? value.ToString()
: Guid.NewGuid().ToString("N");
context.Response.Headers["X-Correlation-Id"] = correlationId;
await _next(context);
}
}
Integration test:
[Fact]
public async Task Request_AddsCorrelationIdHeader()
{
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
request.Headers.Add("X-Correlation-Id", "test-correlation-id");
var response = await client.SendAsync(request);
Assert.True(response.Headers.TryGetValues("X-Correlation-Id", out var values));
Assert.Contains("test-correlation-id", values);
}
Middleware tests should verify externally observable behavior. Avoid testing private implementation details unless the middleware contains complex logic that deserves separate unit tests.
Testing Filters
Filters are part of the MVC and Razor Pages pipeline. They run around specific MVC stages and are usually closer to controllers/actions than middleware.
Common filter types include:
- authorization filters
- resource filters
- action filters
- exception filters
- result filters
Filters are useful for cross-cutting behavior that depends on MVC concepts such as action arguments, model state, controller results, action metadata, or result execution.
Example action filter:
public sealed class RequireHeaderAttribute : ActionFilterAttribute
{
private readonly string _headerName;
public RequireHeaderAttribute(string headerName)
{
_headerName = headerName;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.HttpContext.Request.Headers.ContainsKey(_headerName))
{
context.Result = new BadRequestObjectResult(new
{
error = $"Missing required header: {_headerName}"
});
}
}
}
Example controller:
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
[HttpPost]
[RequireHeader("X-Client-Id")]
public IActionResult CreateOrder(CreateOrderRequest request)
{
return Created("/api/orders/1", new { id = 1 });
}
}
Integration test:
[Fact]
public async Task CreateOrder_WhenClientHeaderMissing_ReturnsBadRequest()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/orders", new
{
productId = 10,
quantity = 2
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Filters can also be unit tested by manually creating filter contexts, but integration tests are often better when the filter depends on model binding, routing, or result execution.
How Filters Differ from Middleware in Tests
Middleware sees almost every request and is part of the global HTTP pipeline. Filters run inside the MVC or Razor Pages pipeline for selected actions or pages.
Testing middleware usually means making a request and asserting behavior at the HTTP pipeline level. Testing filters usually means making a request to an MVC action or Razor Page that has the filter applied.
Key differences:
A common mistake is placing logic in a filter when it should be middleware, or testing middleware as if it only affects one controller action.
Testing Validation
Validation testing verifies that invalid input is rejected and that error responses follow the expected contract.
In ASP.NET Core APIs, validation often involves:
- JSON deserialization
- model binding
- data annotations
- nullable reference types
- custom validation attributes
- FluentValidation or similar libraries
[ApiController]automatic model validation responses- custom validation filters or endpoint filters
ProblemDetailsorValidationProblemDetails
Example request DTO:
public sealed class CreateProductRequest
{
[Required]
[StringLength(100, MinimumLength = 3)]
public string Name { get; init; } = string.Empty;
[Range(0.01, 100_000)]
public decimal Price { get; init; }
}
Example validation test:
[Fact]
public async Task CreateProduct_WhenRequestIsInvalid_ReturnsValidationProblem()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/products", new
{
name = "",
price = -5
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
Assert.NotNull(problem);
Assert.Equal(400, problem.Status);
Assert.Contains("Name", problem.Errors.Keys);
Assert.Contains("Price", problem.Errors.Keys);
}
Good validation tests should check both status code and response shape. They should avoid asserting every exact error message unless those messages are part of a stable public contract, because framework and localization changes can make message text fragile.
Testing Error Responses
Error response tests verify that the application returns safe, consistent, and useful responses when something fails.
Common error cases include:
- invalid request data
- unauthorized request
- forbidden request
- not found
- conflict
- unhandled exception
- domain/business rule error
- dependency failure
- timeout
Modern ASP.NET Core APIs often use ProblemDetails for standardized error responses.
Example error contract:
{
"type": "https://httpstatuses.com/404",
"title": "Resource not found",
"status": 404,
"detail": "The requested product was not found.",
"traceId": "00-..."
}
Example test:
[Fact]
public async Task GetProduct_WhenProductDoesNotExist_ReturnsProblemDetails404()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/products/999999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal(404, problem.Status);
Assert.Equal("Resource not found", problem.Title);
}
For unhandled exceptions, tests should verify that production-style responses do not leak stack traces, connection strings, SQL queries, secrets, or internal exception details.
Testing Exception Handling Middleware
Exception handling middleware should be tested through the pipeline because the behavior depends on middleware order, environment, and response state.
Example test-only endpoint:
app.MapGet("/test/throw", () =>
{
throw new InvalidOperationException("Something failed internally.");
});
Example test:
[Fact]
public async Task ThrowingEndpoint_InProduction_ReturnsGenericProblemDetails()
{
var client = _factory.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Production");
}).CreateClient();
var response = await client.GetAsync("/test/throw");
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("Something failed internally", body);
Assert.DoesNotContain("InvalidOperationException", body);
}
Important test cases:
- The error handler returns the expected status code.
- The response uses the expected content type.
- The response follows the expected error contract.
- Sensitive details are hidden in production.
- Development-only details are not accidentally enabled in production tests.
- Exceptions thrown after response headers are sent are treated differently and may not be recoverable into a normal error response.
Testing Automatic 400 Responses from [ApiController]
When controllers use [ApiController], ASP.NET Core can automatically return a 400 Bad Request response when model validation fails. This means the action method may not execute.
That behavior should be tested at the HTTP level, not only by unit testing the action method.
Example:
[ApiController]
[Route("api/customers")]
public sealed class CustomersController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateCustomerRequest request)
{
return Created("/api/customers/1", new { id = 1 });
}
}
If the request body is invalid, the action can be skipped and the framework returns a validation response.
Test:
[Fact]
public async Task CreateCustomer_WhenEmailMissing_ReturnsBadRequestBeforeActionRuns()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/customers", new
{
name = "Minh"
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
This is important because a controller unit test that directly calls Create(request) will bypass model binding and automatic validation behavior.
Testing Endpoint Filters in Minimal APIs
Minimal APIs can use endpoint filters to run logic before or after endpoint handlers. Endpoint filters are useful for validation, logging, request shaping, and short-circuiting behavior close to a specific endpoint or endpoint group.
Example endpoint filter:
public sealed class RequireClientIdEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
if (!context.HttpContext.Request.Headers.ContainsKey("X-Client-Id"))
{
return Results.BadRequest(new { error = "Missing X-Client-Id header." });
}
return await next(context);
}
}
Example usage:
app.MapPost("/api/orders", (CreateOrderRequest request) =>
{
return Results.Created("/api/orders/1", new { id = 1 });
})
.AddEndpointFilter<RequireClientIdEndpointFilter>();
Integration test:
[Fact]
public async Task CreateOrder_WhenClientIdHeaderMissing_ReturnsBadRequest()
{
var client = _factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/orders", new
{
productId = 1,
quantity = 2
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Endpoint filters should be tested through HTTP requests when they depend on actual endpoint metadata, model binding, or response behavior.
Testing Middleware Ordering
Middleware order can change the behavior of the entire app. A test may pass when calling a service directly but fail through HTTP because the middleware pipeline is wrong.
Example production order for many APIs:
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors("FrontendPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Important ordering scenarios to test:
- Authentication runs before authorization.
- CORS runs at the correct point in the pipeline.
- Exception handling is registered before downstream code throws exceptions.
- Custom header middleware runs before the response is sent.
- Static files or terminal middleware do not accidentally bypass security behavior.
- Endpoint-specific authorization metadata is respected.
Example test for a protected endpoint:
[Fact]
public async Task ProtectedEndpoint_WhenAnonymous_DoesNotReachAction()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var response = await client.GetAsync("/api/account/me");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
This verifies more than the controller. It verifies that the endpoint is protected in the real pipeline.
Testing CORS and Browser-Facing Security Behavior
CORS behavior is usually important for browser-based clients. It is not a replacement for authentication or authorization, but a browser-enforced sharing policy.
CORS tests can verify expected preflight behavior:
[Fact]
public async Task CorsPreflight_FromAllowedOrigin_ReturnsCorsHeaders()
{
var client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Options, "/api/products");
request.Headers.Add("Origin", "https://frontend.example.com");
request.Headers.Add("Access-Control-Request-Method", "POST");
var response = await client.SendAsync(request);
Assert.True(response.Headers.Contains("Access-Control-Allow-Origin"));
}
Do not rely only on CORS tests for security. CORS controls browser access to responses. Non-browser clients can still send requests, so protected endpoints still need authentication and authorization.
Testing Response Contracts, Not Implementation Details
For APIs, tests should focus on observable contracts:
- HTTP status code
- response headers
- content type
- response body shape
- important error fields
- security behavior
- whether access is allowed or denied
Avoid tests that depend too heavily on internal implementation details such as exact method calls, private class names, or full exception text. Those tests are brittle and may pass even when the API contract is broken.
Good assertion:
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
Assert.Contains("email", validationProblem.Errors.Keys, StringComparer.OrdinalIgnoreCase);
Brittle assertion:
Assert.Equal("The Email field is required.", exactErrorMessage);
Exact messages should only be asserted when they are part of a public contract, localization is fixed, and the team intentionally wants message changes to break tests.
Test Data Setup for Security and Pipeline Tests
Security and pipeline tests need clear test data because the same endpoint can behave differently depending on user identity, roles, claims, request payload, existing database records, and configuration.
Useful test data patterns:
- helper methods to create anonymous clients
- helper methods to create authenticated clients
- test user builders with roles and claims
- database seed helpers
- factory methods for valid request DTOs
- small modifications to make an otherwise valid request invalid
- per-test database isolation
- explicit environment configuration
Example request builder:
private static CreateOrderRequest ValidCreateOrderRequest() => new()
{
ProductId = 10,
Quantity = 2,
ShippingAddress = "123 Test Street"
};
Example invalid variation:
var request = ValidCreateOrderRequest() with
{
Quantity = 0
};
For records that must exist in the database, seed them in the test setup instead of relying on production-like shared data. Shared mutable data can make tests flaky.
Replacing Services for Test Scenarios
Integration tests can replace services when external dependencies would make tests slow, flaky, expensive, or unsafe.
Examples:
- replace real email sender with a fake email sender
- replace payment gateway with a test fake
- replace clock/time provider with a fixed time provider
- replace file storage with local or in-memory storage
- replace authentication with a test scheme
- replace database connection with an isolated test database
Example service override:
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
})
.CreateClient();
Be careful not to replace the behavior you are trying to test. If the goal is to test authorization, do not replace authorization with a fake that always succeeds. If the goal is to test validation, do not bypass model binding and validation. If the goal is to test error formatting, do not catch exceptions directly in the test instead of letting the pipeline handle them.
Common Testing Mistakes
Common mistakes include:
- Testing controllers directly and assuming middleware, filters, and model binding were tested.
- Replacing authentication and accidentally bypassing authorization.
- Only testing successful access, not
401and403cases. - Using
AllowAutoRedirect = truewhen the test needs to assert the first response. - Asserting exact framework validation messages too aggressively.
- Testing implementation details instead of HTTP contracts.
- Forgetting to test production error behavior separately from development behavior.
- Forgetting that exception handlers cannot always change a response after headers are sent.
- Sharing mutable test data across tests.
- Mocking too much and hiding real integration problems.
- Not testing middleware order.
- Not testing that protected endpoints are actually protected.
Best Practices
Good practices include:
- Use unit tests for isolated logic and integration tests for pipeline behavior.
- Use
WebApplicationFactory<Program>for API integration tests. - Keep helper methods for authenticated clients, anonymous clients, roles, and claims.
- Test
401,403, and successful access separately. - Test validation through HTTP when model binding and automatic validation matter.
- Assert API error contracts using
ProblemDetailsor a documented custom format. - Disable automatic redirects when testing authentication challenge behavior.
- Keep a small number of real end-to-end identity tests if the app uses an external identity provider.
- Replace external dependencies, but not the behavior being tested.
- Use production-like environment settings when testing production error responses.
- Prefer stable assertions over fragile exact framework messages.
- Test middleware and filters through externally observable behavior.