Testing ASP.NET Core Action Filters with xUnit

Action Filters in ASP.NET Core are a great way to add custom logic that runs before or after a controller action. They can handle logging, query string modifications, or validating requests. However, without proper testing, even well-written filters can cause unexpected issues. This guide will show you how to test your filters using xUnit.

Setting Up Your Testing Environment

Before writing tests, set up a dedicated test project for your application.

Here's how to get started:

1. Create a Test Project

Add a new test project to your solution using Visual Studio or .NET CLI.

2. Install Required Packages

Install the following NuGet packages in your test project:

  • xUnit for writing tests.
  • xUnit.runner.visualstudio for running tests in Visual Studio.
  • Microsoft.NET.Test.Sdk for test discovery.
  • NSubstitute for mocking dependencies.

Use the following commands:

dotnet add package xUnit
dotnet add package xUnit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package NSubstitute

3. Include ASP.NET Core Dependencies

If your filters interact with ASP.NET Core components (e.g., controllers, HTTP context), install Microsoft.AspNetCore.Mvc.

Example 1: Testing a Query String Modifier Filter#

Filter Code

This filter modifies query string parameters before an action is executed:

public class AsyncQueryStringModifierFilter : ActionFilterAttribute
{
    private readonly string _key;
    private readonly string _value;

    public AsyncQueryStringModifierFilter(string key, string value)
    {
        _key = key;
        _value = value;
    }

    public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var request = context.HttpContext.Request;

        // Asynchronous operation before action execution
        await ModifyQueryStringAsync(request);

        await next(); // Continue with action execution

        // Asynchronous operation after action execution (if needed)
    }

    private async Task ModifyQueryStringAsync(HttpRequest request)
    {
        // Example of async logic (if needed)
        await Task.Delay(10); // Simulate async work

        if (request.Query.ContainsKey(_key))
        {
            // Modify existing query string value
            var query = request.Query.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString());
            query[_key] = _value;
            request.QueryString = new QueryString(string.Join("&", query.Select(kvp => $"{kvp.Key}={kvp.Value}")));
        }
        else
        {
            // Add new query string parameter
            var newQuery = QueryHelpers.AddQueryString(request.QueryString.ToString(), _key, _value);
            request.QueryString = new QueryString(newQuery);
        }
    }
}

Writing the Test

This test ensures the filter correctly modifies the query string:

public class AsyncQueryStringModifierFilterTests
{
    [Fact]
    public async Task AsyncQueryStringModifierFilter_ModifiesQueryStringParametersAsync()
    {
        // Arrange
        var context = new DefaultHttpContext
        {
            Request =
            {
                QueryString = new QueryString("?existingKey=existingValue")
            }
        };
        
        var filter = new AsyncQueryStringModifierFilter("testKey", "testValue");
        
        var actionContext = new ActionExecutingContext(
            new ActionContext(context, new RouteData(), new ActionDescriptor()),
            new List<IFilterMetadata>(),
            new Dictionary<string, object>(),
            controller: null);

        var next = new ActionExecutionDelegate(() => Task.FromResult(new ActionExecutedContext(actionContext, new List<IFilterMetadata>(), null)));

        // Act
        await filter.OnActionExecutionAsync(actionContext, next);

        // Assert
        var modifiedQueryString = actionContext.HttpContext.Request.QueryString.ToString();
        Assert.Contains("testKey=testValue", modifiedQueryString);
        Assert.Contains("existingKey=existingValue", modifiedQueryString);
    }
}

Key Points in Testing

  • Setup: Mocking the necessary context, like HttpContext and ActionExecutingContext.
  • Execution: Simulating the execution of the filter by calling OnActionExecutionAsync.
  • Assertion: Checking the modified query string to confirm the filter's behavior.

Example 2: Testing an Audit Logging Filter#

Filter Code

This filter logs how long an action takes to execute:

public class AuditLoggingFilter : ActionFilterAttribute
{
    public string CustomMessage { get; set; }

    public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var logger = context.HttpContext.RequestServices.GetService<ILogger<AuditLoggingFilter>>();
        var stopwatch = Stopwatch.StartNew();

        await next(); // Execution of the action

        stopwatch.Stop();
        
        var message = String.IsNullOrEmpty(CustomMessage) 
            ? $"Action executed in {stopwatch.ElapsedMilliseconds} ms" 
            : CustomMessage.Replace("{elapsedTime}", stopwatch.ElapsedMilliseconds.ToString());

        logger.LogInformation(message);
    }
}

Writing the Test

Here, we focus on verifying that the filter logs the correct information after action execution.

[Fact]
public async Task AuditLoggingFilter_WithCustomMessage_LogsExecutionTime()
{
    // Arrange
    var logger = Substitute.For<ILogger<AuditLoggingFilter>>();
    var services = new ServiceCollection();
    services.AddSingleton(logger);
    var serviceProvider = services.BuildServiceProvider();

    var httpContext = new DefaultHttpContext
    {
        RequestServices = serviceProvider
    };

    var context = new ActionExecutingContext(
        new ActionContext(httpContext, new RouteData(), new ActionDescriptor()),
        new List<IFilterMetadata>(),
        new Dictionary<string, object>(),
        controller: null);

    var next = new ActionExecutionDelegate(() =>
    {
        // Simulate a delay to mimic action execution
        Task.Delay(100).Wait();
        return Task.FromResult(new ActionExecutedContext(context, new List<IFilterMetadata>(), null));
    });

    var filter = new AuditLoggingFilter
    {
        CustomMessage = "Execution time: {elapsedTime} ms"
    };

    // Act
    await filter.OnActionExecutionAsync(context, next);

    // Assert
    logger.ReceivedWithAnyArgs().Log(LogLevel.Information, default, null, null, null);
}

Key Points in Testing

  • Logger Mocking: Using NSubstitute to mock the ILogger dependency.
  • Async Behavior: Ensuring the asynchronous nature of the filter is handled correctly in the test.
  • Log Verification: Checking that the correct log messages are being generated.

Best Practices for Testing Action Filters#

  1. Mock Dependencies: Use libraries like NSubstitute to mock services, ensuring your tests focus on the filter's logic.
  2. Isolate Tests: Test each filter independently without external interference.
  3. Test Both Success and Failure Scenarios: Verify how the filter behaves in all possible cases.

Conclusion#

Action Filters are an essential part of ASP.NET Core applications, but their complexity means they need robust testing.

Using xUnit, you can ensure your filters work as intended, minimizing bugs and improving application quality.

Looking for more tips on ASP.NET Core development?

👉 Visit our blog for more tutorials, examples, and best practices.

↑ Top â†‘