Testing ASP.NET Core Action Filters with xUnit

Master the art of testing ASP.NET Core Action Filters with examples and code insights. Learn how to ensure your filters work perfectly every time.

Introduction

Testing is an integral part of modern web development, especially in complex applications using ASP.NET Core.

Action Filters, a crucial feature in ASP.NET Core, allow developers to execute custom pre- and post-processing logic on controller actions.

This article provides a deep dive into testing Action Filters using xUnit, covering two practical examples: AsyncQueryStringModifierFilter and AuditLoggingFilter.

Installing xUnit for ASP.NET Core Testing

Before diving into the practical aspects of testing Action Filters, it's important to set up your testing environment with xUnit, a popular testing framework for .NET applications.

Here's a quick guide on installing and configuring xUnit in your ASP.NET Core project.

Step 1: Creating a Test Project

Create a separate test project in your solution for your unit tests.

This keeps your testing code isolated from your main application code.

Step 2: Installing xUnit and Related Packages

In your test project, you need to install several NuGet packages:

  1. xUnit: The core testing framework.

Install-Package xUnit

xUnit.runner.visualstudio: A test runner that allows the tests to be run in Visual Studio.

Install-Package xUnit.runner.visualstudio

Microsoft.NET.Test.Sdk: The test SDK for .NET.

Install-Package Microsoft.NET.Test.Sdk

NSubstitute: a friendly substitute for .NET mocking frameworks

Install-Package NSubstitute

If you're using the .NET Core CLI, you can run dotnet add package commands instead:

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

Step 3: Adding ASP.NET Core Dependencies (If Needed)

For testing ASP.NET Core specific features, such as MVC controllers or Action Filters, you might also need to add references to ASP.NET Core packages like Microsoft.AspNetCore.Mvc.

Install-Package Microsoft.AspNetCore.Mvc

Example 1: Testing the AsyncQueryStringModifierFilter

This filter modifies or adds a query string parameter asynchronously before executing an action.

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);
        }
    }
}

Testing the Filter

The goal is to verify that the filter correctly modifies the query string of the request.

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 the AuditLoggingFilter

This filter logs the execution time of an action method, demonstrating the testing of asynchronous code and logging behavior.

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);
    }
}

Testing the Filter

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 in Testing Action Filters

  1. Isolation: Test each filter in isolation to ensure that it behaves as expected in a controlled environment.
  2. Mocking Dependencies: Properly mock all external dependencies to focus on the filter's functionality.
  3. Simulating Context: Accurately simulate the filter's runtime context, including HTTP requests and action contexts.
  4. Asserting Side Effects: Whether it's modifying HTTP context or logging, ensure your assertions accurately capture the filter's side effects.

Conclusion

Testing Action Filters is essential for ensuring the reliability and correctness of your ASP.NET Core applications.

Through these examples, we've seen how to effectively write tests for different types of filters, focusing on their unique behaviors and the common patterns in testing.

🌐 Explore More: Interested in learning about ASP .NET Core and other web development insights? Explore our blog for a wealth of information and expert advice.

↑ Top ↑