使用服务定位器时如何为 ActionFilter 编写单元测试

How to write unit test for ActionFilter when using Service Locator

我打算写一个 ActionFilter 用于业务验证,其中一些服务将通过服务定位器解决(我知道这不是好的做法,我会尽可能避免使用服务定位器模式,但是对于这种情况,我想使用它)。

OnActionExecuting 过滤器的方法是这样的:

    public override void OnActionExecuting(ActionExecutingContext actionContext)
    {
        // get validator for input;
        var validator = actionContext.HttpContext.RequestServices.GetService<IValidator<TypeOfInput>>();// i will ask another question for this line
        if(!validator.IsValid(input))
        {
            //send errors
        }
    }

是否可以为以上内容编写单元测试ActionFilter以及如何编写?

这里是一个关于如何创建模拟(使用 XUnit 和 Moq 框架)以验证 IsValid 方法被调用以及模拟 returns 和 false 的示例。

using Dealz.Common.Web.Tests.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using System;
using Xunit;

namespace Dealz.Common.Web.Tests.ActionFilters
{
    public class TestActionFilter
    {
        [Fact]
        public void ActionFilterTest()
        {
            /****************
             * Setup
             ****************/

            // Create the userValidatorMock
            var userValidatorMock = new Mock<IValidator<User>>();
            userValidatorMock.Setup(validator => validator
                // For any parameter passed to IsValid
                .IsValid(It.IsAny<User>())
            )
            // return false when IsValid is called
            .Returns(false)
            // Make sure that `IsValid` is being called at least once or throw error
            .Verifiable();

            // If provider.GetService(typeof(IValidator<User>)) gets called, 
            // IValidator<User> mock will be returned
            var serviceProviderMock = new Mock<IServiceProvider>();
            serviceProviderMock.Setup(provider => provider.GetService(typeof(IValidator<User>)))
                .Returns(userValidatorMock.Object);

            // Mock the HttpContext to return a mockable 
            var httpContextMock = new Mock<HttpContext>();
            httpContextMock.SetupGet(context => context.RequestServices)
                .Returns(serviceProviderMock.Object);


            var actionExecutingContext = HttpContextUtils.MockedActionExecutingContext(httpContextMock.Object, null);

            /****************
             * Act
             ****************/
            var userValidator = new ValidationActionFilter<User>();
            userValidator.OnActionExecuting(actionExecutingContext);

            /****************
             * Verify
             ****************/

            // Make sure that IsValid is being called at least once, otherwise this throws an exception. This is a behavior test
            userValidatorMock.Verify();

            // TODO: Also Mock HttpContext.Response and return in it's Body proeprty a memory stream where 
            // your ActionFilter writes to and validate the input is what you desire.
        }
    }

    class User
    {
        public string Username { get; set; }
    }

    class ValidationActionFilter<T> : IActionFilter where T : class, new()
    {
        public void OnActionExecuted(ActionExecutedContext context)
        {
            throw new NotImplementedException();
        }

        public void OnActionExecuting(ActionExecutingContext actionContext)
        {
            var type = typeof(IValidator<>).MakeGenericType(typeof(T));

            var validator = (IValidator<T>)actionContext.HttpContext
                .RequestServices.GetService<IValidator<T>>();

            // Get your input somehow
            T input = new T();

            if (!validator.IsValid(input))
            {
                //send errors
                actionContext.HttpContext.Response.WriteAsync("Error");
            }
        }
    }

    internal interface IValidator<T>
    {
        bool IsValid(T input);
    }
}

HttpContextUtils.cs

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using System.Collections.Generic;

namespace Dealz.Common.Web.Tests.Utils
{
    public class HttpContextUtils
    {
        public static ActionExecutingContext MockedActionExecutingContext(
            HttpContext context,
            IList<IFilterMetadata> filters,
            IDictionary<string, object> actionArguments,
            object controller
        )
        {
            var actionContext = new ActionContext() { HttpContext = context };

            return new ActionExecutingContext(actionContext, filters, actionArguments, controller);
        }
        public static ActionExecutingContext MockedActionExecutingContext(
            HttpContext context,
            object controller
        )
        {
            return MockedActionExecutingContext(context, new List<IFilterMetadata>(), new Dictionary<string, object>(), controller);
        }
    }
}

如您所见,它非常混乱,您需要创建大量模拟来模拟实际的不同响应 类,才能单独测试 ActionAttribute。

我喜欢@Tseng 的上述回答,但考虑到再给出一个答案,因为他的回答涵盖了更多场景(如泛型)并且可能会让一些用户不知所措。

这里我有一个动作过滤器属性,它通过设置 Result 属性 在上下文中。在过滤器中,我尝试使用 ServiceLocator 模式让记录器记录一些数据(有些人可能不喜欢这样,但这是一个例子)

过滤器

public class ValidationFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ValidationFilterAttribute>>();
            logger.LogWarning("some message here");

            context.Result = new JsonResult(new InvalidData() { Message = "some messgae here" })
            {
                StatusCode = 400
            };
        }
    }
}

public class InvalidData
{
    public string Message { get; set; }
}

单元测试

[Fact]
public void ValidationFilterAttributeTest_ModelStateErrors_ResultInBadRequestResult()
{
    // Arrange
    var serviceProviderMock = new Mock<IServiceProvider>();
    serviceProviderMock
        .Setup(serviceProvider => serviceProvider.GetService(typeof(ILogger<ValidationFilterAttribute>)))
        .Returns(Mock.Of<ILogger<ValidationFilterAttribute>>());
    var httpContext = new DefaultHttpContext();
    httpContext.RequestServices = serviceProviderMock.Object;
    var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
    var actionExecutingContext = new ActionExecutingContext(
        actionContext,
        filters: new List<IFilterMetadata>(), // for majority of scenarios you need not worry about populating this parameter
        actionArguments: new Dictionary<string, object>(), // if the filter uses this data, add some data to this dictionary
        controller: null); // since the filter being tested here does not use the data from this parameter, just provide null
    var validationFilter = new ValidationFilterAttribute();

    // Act
    // Add an erorr into model state on purpose to make it invalid
    actionContext.ModelState.AddModelError("Age", "Age cannot be below 18 years.");
    validationFilter.OnActionExecuting(actionExecutingContext);

    // Assert
    var jsonResult = Assert.IsType<JsonResult>(actionExecutingContext.Result);
    Assert.Equal(400, jsonResult.StatusCode);
    var invalidData = Assert.IsType<InvalidData>(jsonResult.Value);
    Assert.Equal("some messgae here", invalidData.Message);
}