In short: construct real ActionExecutingContext and ActionExecutedContext objects, replace only your filter's dependencies, and supply a controlled ActionExecutionDelegate. Assert both the result and the control flow: whether next() ran, what context it returned, and what happened afterward. Add a small number of WebApplicationFactory tests to verify routing, model binding, dependency injection, filter registration, and response serialization together.

If you first need to understand the filter lifecycle, short-circuiting, registration, and execution order, start with Implementing Async Action Filters in ASP.NET Core.

What should an action filter test verify?

An action filter is control-flow code. A useful test must therefore prove more than the type assigned to context.Result.

For an IAsyncActionFilter, cover the observable decisions:

  • which action arguments and request values the filter reads;

  • which dependencies it calls and which cancellation token it passes;

  • whether it assigns context.Result;

  • whether it calls next(), and how many times;

  • what it does with the returned ActionExecutedContext;

  • whether registration and behavior remain correct in the real MVC pipeline.

This naturally creates two layers of tests:

Layer

Best for

Does not prove

Direct filter test

Branches, dependency calls, short-circuiting, next(), and after-action behavior

Routing, model binding, filter registration, formatters, or final HTTP response

HTTP integration test

The assembled application and its externally visible response

Every internal branch or precise collaborator interaction

The goal is not to choose one layer. It is to use each layer for the risk it can test efficiently.

The filter under test

The example filter reads a model-bound accountId, resolves the account asynchronously, and stops the controller action when the request cannot continue.

public sealed class RequireAccountFilter(IAccountLookup accounts)
    : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        if (!context.ActionArguments.TryGetValue("accountId", out var value) ||
            value is not Guid accountId ||
            accountId == Guid.Empty)
        {
            context.Result = new BadRequestObjectResult(new ProblemDetails
            {
                Title = "Invalid account identifier",
                Detail = "The action requires a non-empty accountId value.",
                Status = StatusCodes.Status400BadRequest
            });

            return;
        }

        var account = await accounts.FindByIdAsync(
            accountId,
            context.HttpContext.RequestAborted);

        if (account is null)
        {
            context.Result = new NotFoundObjectResult(new ProblemDetails
            {
                Title = "Account not found",
                Detail = $"Account '{accountId}' does not exist.",
                Status = StatusCodes.Status404NotFound
            });

            return;
        }

        context.HttpContext.SetResolvedAccount(account);

        await next();
    }
}

The important branches are explicit:

  1. An invalid identifier produces 400 without calling the lookup or the action pipeline.

  2. An unknown account produces 404 without calling the action pipeline.

  3. An existing account is stored in HttpContext and the pipeline continues exactly once.

Create real MVC filter contexts

You usually do not need to mock HttpContext, ActionExecutingContext, or ActionExecutedContext. They are state containers with public constructors. Creating real instances produces simpler and more trustworthy tests.

internal static class FilterTestContext
{
    public static ActionExecutingContext CreateExecuting(
        IDictionary<string, object?>? actionArguments = null,
        HttpContext? httpContext = null)
    {
        var actionDescriptor = new ActionDescriptor
        {
            DisplayName = "AccountsController.GetSummary"
        };

        var actionContext = new ActionContext(
            httpContext ?? new DefaultHttpContext(),
            new RouteData(),
            actionDescriptor);

        return new ActionExecutingContext(
            actionContext,
            filters: [],
            actionArguments ?? new Dictionary<string, object?>(),
            controller: new object());
    }

    public static ActionExecutedContext CreateExecuted(
        ActionExecutingContext executingContext,
        IActionResult? result = null,
        Exception? exception = null,
        bool shortCircuited = false,
        bool exceptionHandled = false)
    {
        return new ActionExecutedContext(
            executingContext,
            filters: [],
            controller: new object())
        {
            Result = result,
            Exception = exception,
            Canceled = shortCircuited,
            ExceptionHandled = exceptionHandled
        };
    }
}

This helper keeps each test focused on behavior. It also makes exceptional and downstream short-circuit outcomes explicit instead of hiding them behind a large mock setup. Here, ActionExecutedContext.Canceled means that a subsequent action filter short-circuited the action-filter pipeline. It is unrelated to request cancellation through HttpContext.RequestAborted.

Prefer real framework contexts: mock or stub your own interfaces, such as IAccountLookup. Mocking MVC context objects often couples a test to property access instead of the behavior that matters.

Test a short-circuit path

A filter short-circuits action execution by assigning ActionExecutingContext.Result and returning without calling next(). The test should prove both conditions.

Debugger view showing a NotFoundObjectResult assigned by RequireAccountFilter while next has not been called

Debugger view showing a NotFoundObjectResult assigned by RequireAccountFilter while next has not been called

[Fact]
public async Task MissingAccountId_ShortCircuitsWithoutCallingNext()
{
    var lookup = new StubAccountLookup();
    var filter = new RequireAccountFilter(lookup);
    var context = FilterTestContext.CreateExecuting();
    var nextCalls = 0;

    await filter.OnActionExecutionAsync(context, Next);

    var result = Assert.IsType<BadRequestObjectResult>(context.Result);
    var problem = Assert.IsType<ProblemDetails>(result.Value);
    Assert.Equal(StatusCodes.Status400BadRequest, problem.Status);
    Assert.Equal(0, nextCalls);
    Assert.Equal(0, lookup.Calls);
    return;

    Task<ActionExecutedContext> Next()
    {
        nextCalls++;
        return Task.FromResult(FilterTestContext.CreateExecuted(context));
    }
}

The assertions answer three different questions:

  • Did the filter create the expected result?

  • Did it avoid unnecessary dependency work?

  • Did it prevent the remaining action pipeline from running?

Checking only the result would miss a serious regression where the controller action still executes after the filter produces an error response.

Test the continuation path

The successful path should verify the prepared request state and the transition to the remaining pipeline.

[Fact]
public async Task ExistingAccount_StoresAccountAndCallsNextOnce()
{
    var account = new Account(Guid.NewGuid(), "Northwind");
    var lookup = new StubAccountLookup(account);
    var filter = new RequireAccountFilter(lookup);
    var context = FilterTestContext.CreateExecuting(
        new Dictionary<string, object?> { ["accountId"] = account.Id });
    var nextCalls = 0;

    await filter.OnActionExecutionAsync(context, Next);

    Assert.Null(context.Result);
    Assert.Equal(1, nextCalls);
    Assert.Same(account, context.HttpContext.GetRequiredAccount());
    return;

    Task<ActionExecutedContext> Next()
    {
        nextCalls++;
        return Task.FromResult(FilterTestContext.CreateExecuted(
            context,
            new OkResult()));
    }
}

The delegate is deliberately small. A direct filter test does not execute MVC or a controller. It supplies the outcome that the remaining pipeline would return.

Verify cancellation propagation

If the filter performs asynchronous I/O, verify that it forwards HttpContext.RequestAborted:

[Fact]
public async Task ExistingAccount_PropagatesRequestCancellationToken()
{
    using var cancellation = new CancellationTokenSource();
    var httpContext = new DefaultHttpContext
    {
        RequestAborted = cancellation.Token
    };
    var account = new Account(Guid.NewGuid(), "Northwind");
    var lookup = new StubAccountLookup(account);
    var filter = new RequireAccountFilter(lookup);
    var context = FilterTestContext.CreateExecuting(
        new Dictionary<string, object?> { ["accountId"] = account.Id },
        httpContext);

    await filter.OnActionExecutionAsync(
        context,
        () => Task.FromResult(FilterTestContext.CreateExecuted(context)));

    Assert.Equal(cancellation.Token, lookup.LastCancellationToken);
}

This test protects a production behavior that response-only integration tests rarely expose.

Test code after the action

Filters that run code after await next() receive an ActionExecutedContext. Tests should cover the properties the filter actually uses: Result, Canceled, Exception, and ExceptionHandled. In this context, Canceled records a downstream filter short-circuit; it does not report cancellation of the HTTP request.

Debugger view showing ActionExecutedContext after await next with a successful result and no exception

After await next(), the filter can inspect the result, downstream short-circuit state, and exception state.

This audit filter writes one entry before the remaining pipeline and classifies its outcome afterward:

public sealed class ActionAuditFilter(IActionAuditSink auditSink)
    : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next)
    {
        var actionName = context.ActionDescriptor.DisplayName ?? "Unknown action";
        var cancellationToken = context.HttpContext.RequestAborted;

        await auditSink.WriteAsync(
            new ActionAuditEntry(actionName, "Started"),
            cancellationToken);

        var executedContext = await next();

        await auditSink.WriteAsync(
            new ActionAuditEntry(actionName, Classify(executedContext)),
            cancellationToken);
    }

    private static string Classify(ActionExecutedContext context)
    {
        if (context.Canceled)
        {
            return "Short-circuited";
        }

        if (context.Exception is null)
        {
            return "Succeeded";
        }

        return context.ExceptionHandled ? "Handled exception" : "Failed";
    }
}

The first test proves the execution order rather than merely checking that two records exist:

[Fact]
public async Task SuccessfulAction_WritesBeforeAndAfterEntriesInOrder()
{
    var events = new List<string>();
    var sink = new RecordingAuditSink(events);
    var filter = new ActionAuditFilter(sink);
    var context = FilterTestContext.CreateExecuting();

    await filter.OnActionExecutionAsync(context, Next);

    Assert.Equal(
        ["Audit: Started", "Action", "Audit: Succeeded"],
        events);
    return;

    Task<ActionExecutedContext> Next()
    {
        events.Add("Action");
        return Task.FromResult(FilterTestContext.CreateExecuted(
            context,
            new OkResult()));
    }
}

A parameterized test can then cover successful, downstream short-circuited, failed, and handled-exception outcomes without duplicating the setup:

[Theory]
[InlineData(false, false, false, "Succeeded")]
[InlineData(true, false, false, "Short-circuited")]
[InlineData(false, true, false, "Failed")]
[InlineData(false, true, true, "Handled exception")]
public async Task CompletedPipeline_ClassifiesActionExecutedContext(
    bool shortCircuited,
    bool hasException,
    bool exceptionHandled,
    string expectedStage)
{
    var sink = new RecordingAuditSink();
    var filter = new ActionAuditFilter(sink);
    var context = FilterTestContext.CreateExecuting();

    await filter.OnActionExecutionAsync(
        context,
        () => Task.FromResult(FilterTestContext.CreateExecuted(
            context,
            exception: hasException ? new InvalidOperationException() : null,
            shortCircuited: shortCircuited,
            exceptionHandled: exceptionHandled)));

    Assert.Equal(expectedStage, sink.Entries[^1].Stage);
}

Test the correct exception path: when code after await next() is supposed to inspect ActionExecutedContext.Exception, return a context containing the exception. Making the test delegate throw directly exercises a different path and prevents the filter's after-action code from running.

Test the real pipeline with WebApplicationFactory

Direct tests call the filter method themselves. They cannot prove that the application registered the filter, model binding populated ActionArguments, the controller received the prepared state, or the configured formatters created the expected HTTP response.

WebApplicationFactory<TEntryPoint> hosts the application with ASP.NET Core's test server and creates an HttpClient for requests against the in-memory host.

public sealed class AccountsEndpointTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public AccountsEndpointTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task UnknownAccount_ReturnsProblemDetails()
    {
        var response = await _client.GetAsync(
            $"/api/accounts/{Guid.NewGuid()}/summary");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();

        Assert.NotNull(problem);
        Assert.Equal("Account not found", problem.Title);
        Assert.Equal(StatusCodes.Status404NotFound, problem.Status);
    }
}

This single test covers more assembly risk than a larger mocked test:

  1. endpoint routing selects the controller action;

  2. model binding creates the Guid action argument;

  3. ServiceFilter<RequireAccountFilter> resolves through DI;

  4. the filter short-circuits the action;

  5. MVC serializes ProblemDetails into the HTTP response.

For top-level statements, expose the generated entry-point type to the test project:

app.Run();

public partial class Program;

When should you use a unit test or an integration test?

Question

Recommended test

Does invalid state prevent next()?

Direct filter test

Was a dependency called with the correct argument and cancellation token?

Direct filter test

Does after-action code handle success, downstream short-circuiting, and exceptions?

Direct filter test with controlled ActionExecutedContext

Is the filter registered and applied to the intended endpoint?

HTTP integration test

Does model binding put the expected value in ActionArguments?

HTTP integration test

Does the client receive the correct status, headers, and JSON contract?

HTTP integration test

A practical suite usually contains several fast direct tests for branches and a few integration tests for wiring. If constructing the direct context becomes more complicated than sending an HTTP request, that is a useful signal to move that scenario to WebApplicationFactory.

Common mistakes when testing action filters

1. Asserting the result but not whether next() ran

Short-circuiting is a control-flow guarantee. Count delegate invocations or use a collaborator that records execution. A response assertion alone does not prove that the action was skipped.

2. Mocking every MVC type

Mocking ActionExecutingContext tends to produce large setups coupled to property access. Construct real framework contexts and replace only application dependencies.

3. Returning an unrealistic executed context

The delegate's result should represent the scenario under test. Populate Result, Canceled, Exception, and ExceptionHandled deliberately. Set Canceled only when modeling a downstream action-filter short-circuit.

4. Treating a direct filter test as an MVC pipeline test

Calling OnActionExecutionAsync does not perform routing, model binding, filter discovery, controller activation, result execution, or response formatting. Use an HTTP integration test when those boundaries matter.

5. Testing framework behavior instead of your policy

You do not need to prove that ASP.NET Core invokes filters. Test what your filter decides and add one integration test proving that your application assembled it correctly.

6. Blocking asynchronous code

Make the test return Task and await the filter. Avoid .Result, .Wait(), and async void. They can hide exceptions or create behavior that does not resemble production execution.

7. Forgetting request cancellation

When a filter performs I/O, verify that RequestAborted reaches the dependency. This is cheap to test directly and easy to omit accidentally.

8. Sharing mutable state between integration tests

WebApplicationFactory can be shared as an xUnit fixture, but mutable application data still needs isolation. Replace stateful services per test or reset their state explicitly.

Frequently asked questions

Should I mock ActionExecutingContext?

Usually no. Create an ActionContext with DefaultHttpContext, RouteData, and ActionDescriptor, then construct a real ActionExecutingContext. Mock or stub your own dependencies instead.

How do I test that an action filter short-circuits?

Assert that context.Result contains the expected result and that the supplied ActionExecutionDelegate was not called.

How do I test code after await next()?

Supply a delegate that returns a configured ActionExecutedContext. Set its Result, Canceled, Exception, and ExceptionHandled properties to represent the outcome you need. Remember that Canceled means a downstream action filter short-circuited execution; request cancellation is represented separately by HttpContext.RequestAborted.

Do I need WebApplicationFactory for every filter test?

No. Use direct tests for branches and collaborator interactions. Use a small number of integration tests for registration, model binding, filter execution, and the final HTTP contract.

Can I use Moq or NSubstitute instead of hand-written stubs?

Yes. The testing pattern remains the same. A small hand-written stub keeps the sample transparent, while a mocking library can be convenient when an interface has many interactions or argument checks.

Does this approach work for synchronous action filters?

The same principle applies: construct real contexts and test your policy. For IActionFilter, call OnActionExecuting and OnActionExecuted separately because there is no delegate connecting the two methods.

Final recommendations

Test action filters as control-flow components. Build real MVC context objects, isolate only your dependencies, and make the continuation delegate observable. This keeps direct tests fast without pretending that they cover the complete framework pipeline.

Then add a few HTTP-level tests. They catch the failures that mocks cannot: a missing DI registration, an attribute applied at the wrong scope, an unexpected model-binding result, or a response contract that differs from the intended one.

Testing rule: direct tests prove the filter's decisions. Integration tests prove that the application actually reaches those decisions.

References