在ASP.NET WebApi中测试路由配置

时间:2012-08-07 18:03:30

标签: unit-testing routes asp.net-web-api

我正在尝试对WebApi路由配置进行一些单元测试。我想测试路由"/api/super"是否映射到Get()的{​​{1}}方法。我已经设置了以下测试,并且遇到了一些问题。

SuperController

我的第一个问题是,如果我没有指定完全限定的URL public void GetTest() { var url = "~/api/super"; var routeCollection = new HttpRouteCollection(); routeCollection.MapHttpRoute("DefaultApi", "api/{controller}/"); var httpConfig = new HttpConfiguration(routeCollection); var request = new HttpRequestMessage(HttpMethod.Get, url); // exception when url = "/api/super" // can get around w/ setting url = "http://localhost/api/super" var routeData = httpConfig.Routes.GetRouteData(request); request.Properties[HttpPropertyKeys.HttpRouteDataKey] = routeData; var controllerSelector = new DefaultHttpControllerSelector(httpConfig); var controlleDescriptor = controllerSelector.SelectController(request); var controllerContext = new HttpControllerContext(httpConfig, routeData, request); controllerContext.ControllerDescriptor = controlleDescriptor; var selector = new ApiControllerActionSelector(); var actionDescriptor = selector.SelectAction(controllerContext); Assert.AreEqual(typeof(SuperController), controlleDescriptor.ControllerType); Assert.IsTrue(actionDescriptor.ActionName == "Get"); } ,则抛出httpConfig.Routes.GetRouteData(request);异常,并显示“相对URI不支持此操作。”

我显然错过了我的存根配置。我更喜欢使用相对URI,因为使用完全限定的URI进行路由测试似乎不合理。

我上面配置的第二个问题是我没有按照RouteConfig中的配置测试我的路线,而是使用:

InvalidOperationException

如何使用典型Global.asax中配置的指定var routeCollection = new HttpRouteCollection(); routeCollection.MapHttpRoute("DefaultApi", "api/{controller}/");

RouteTable.Routes

此外,我上面提到的内容可能不是最好的测试配置。如果有一种更简化的方法,我全都听见了。

7 个答案:

答案 0 :(得分:25)

我最近在测试我的Web API路由,这就是我的方法。

  1. 首先,我创建了一个帮助程序来移动所有Web API路由逻辑:
  2.     public static class WebApi
        {
            public static RouteInfo RouteRequest(HttpConfiguration config, HttpRequestMessage request)
            {
                // create context
                var controllerContext = new HttpControllerContext(config, Substitute.For<IHttpRouteData>(), request);
    
                // get route data
                var routeData = config.Routes.GetRouteData(request);
                RemoveOptionalRoutingParameters(routeData.Values);
    
                request.Properties[HttpPropertyKeys.HttpRouteDataKey] = routeData;
                controllerContext.RouteData = routeData;
    
                // get controller type
                var controllerDescriptor = new DefaultHttpControllerSelector(config).SelectController(request);
                controllerContext.ControllerDescriptor = controllerDescriptor;
    
                // get action name
                var actionMapping = new ApiControllerActionSelector().SelectAction(controllerContext);
    
                return new RouteInfo
                {
                    Controller = controllerDescriptor.ControllerType,
                    Action = actionMapping.ActionName
                };
            }
    
            private static void RemoveOptionalRoutingParameters(IDictionary<string, object> routeValues)
            {
                var optionalParams = routeValues
                    .Where(x => x.Value == RouteParameter.Optional)
                    .Select(x => x.Key)
                    .ToList();
    
                foreach (var key in optionalParams)
                {
                    routeValues.Remove(key);
                }
            }
        }
    
        public class RouteInfo
        {
            public Type Controller { get; set; }
    
            public string Action { get; set; }
        }
    
    1. 假设我有一个单独的类来注册Web API路由(默认情况下,它在Visual Studio ASP.NET MVC 4 Web应用程序项目中创建,位于App_Start文件夹中):
    2.     public static class WebApiConfig
          {
              public static void Register(HttpConfiguration config)
              {
                  config.Routes.MapHttpRoute(
                      name: "DefaultApi",
                      routeTemplate: "api/{controller}/{id}",
                      defaults: new { id = RouteParameter.Optional }
                  );
              }
          }
      
      1. 我可以轻松测试我的路线:
      2.     [Test]
            public void GET_api_products_by_id_Should_route_to_ProductsController_Get_method()
            {
                // setups
                var request = new HttpRequestMessage(HttpMethod.Get, "http://myshop.com/api/products/1");
                var config = new HttpConfiguration();
        
                // act
                WebApiConfig.Register(config);
                var route = WebApi.RouteRequest(config, request);
        
                // asserts
                route.Controller.Should().Be<ProductsController>();
                route.Action.Should().Be("Get");
            }
        
            [Test]
            public void GET_api_products_Should_route_to_ProductsController_GetAll_method()
            {
                // setups
                var request = new HttpRequestMessage(HttpMethod.Get, "http://myshop.com/api/products");
                var config = new HttpConfiguration();
        
                // act
                WebApiConfig.Register(config);
                var route = WebApi.RouteRequest(config, request);
        
                // asserts
                route.Controller.Should().Be<ProductsController>();
                route.Action.Should().Be("GetAll");
            }
        
            ....
        

        以下一些注意事项:

        • 是的,我正在使用绝对网址。但是我在这里看不到任何问题,因为这些是伪造的URL,我不需要为它们配置任何工作,它们代表对我们的Web服务的实际请求。
        • 如果在具有HttpConfiguration依赖关系的单独类中配置路由映射代码,则不需要将路由映射代码复制到测试中(如上例所示)。
        • 我在上面的示例中使用了NUnit,NSubstitute和FluentAssertions,但当然,对任何其他测试框架执行相同操作都是一件容易的事。

答案 1 :(得分:12)

ASP.NET Web API 2的最新答案(我只测试了该版本)。我使用了Nuget的MvcRouteTester.Mvc5,它为我完成了这项工作。你可以写下面的内容。

[TestClass]
public class RouteTests
{
    private HttpConfiguration config;
    [TestInitialize]
    public void MakeRouteTable()
    {
        config = new HttpConfiguration();
        WebApiConfig.Register(config);
        config.EnsureInitialized();
    }
    [TestMethod]
    public void GetTest()
    {
        config.ShouldMap("/api/super")
            .To<superController>(HttpMethod.Get, x => x.Get());
    }
}

我不得不将nuget包Microsoft Asp.Net MVC 5.0.0版添加到测试项目中。这不太漂亮,但我没有找到更好的解决方案,而且我可以接受。您可以在nuget包管理器控制台中安装这样的旧版本:

Get-Project Tests | install-package microsoft.aspnet.mvc -version 5.0.0

它也适用于System.Web.Http.RouteAttribute。

答案 2 :(得分:9)

此答案适用于WebAPI 2.0及更高版本

通过Whyleee的回答,我注意到这种方法是基于耦合和脆弱的假设:

  1. 该方法尝试重新创建操作选择,并假定Web API中的内部实现细节。
  2. 它假设正在使用默认控制器选择器,当有一个众所周知的公共扩展点允许替换它时。
  3. 另一种方法是使用轻量级功能测试。 这种方法的步骤是:

    1. 使用您的WebApiConfig.Register方法初始化测试HttpConfiguration对象,模仿应用程序在现实世界中初始化的方式。
    2. 将自定义身份验证筛选器添加到测试配置对象,该对象捕获该级别的操作信息。这可以通过开关直接在产品代码中注入或完成。 2.1身份验证过滤器会使任何过滤器和操作代码都短路,因此不必担心在操作方法本身中运行的实际代码。
    3. 使用内存服务器(HttpServer),并发出请求。这种方法使用内存中的通道,因此不会访问网络。
    4. 将捕获的操作信息与预期信息进行比较。
    5. [TestClass]
      public class ValuesControllerTest
      {
          [TestMethod]
          public void ActionSelection()
          {
              var config = new HttpConfiguration();
              WebApiConfig.Register(config);
      
              Assert.IsTrue(ActionSelectorValidator.IsActionSelected(
                  HttpMethod.Post,
                  "http://localhost/api/values/",
                  config,
                  typeof(ValuesController),
                  "Post"));
          }
       }
      

      此帮助程序执行管道,并验证由其捕获的数据 在身份验证过滤器中,也可以捕获其他属性 可以实现一个客户过滤器,通过在初始化时将lambda传递给过滤器,直接对每个测试进行验证。

       public class ActionSelectorValidator
       {
          public static bool IsActionSelected(
              HttpMethod method,
              string uri,
              HttpConfiguration config,
              Type controller,
              string actionName)
          {
              config.Filters.Add(new SelectedActionFilter());
              var server = new HttpServer(config);
              var client = new HttpClient(server);
              var request = new HttpRequestMessage(method, uri);
              var response = client.SendAsync(request).Result;
              var actionDescriptor = (HttpActionDescriptor)response.RequestMessage.Properties["selected_action"];
      
              return controller == actionDescriptor.ControllerDescriptor.ControllerType && actionName == actionDescriptor.ActionName;
          }
      }
      

      此过滤器会运行并阻止过滤器或操作代码的所有其他执行。

      public class SelectedActionFilter : IAuthenticationFilter
      {
          public Task AuthenticateAsync(
               HttpAuthenticationContext context,
               CancellationToken cancellationToken)
          {
              context.ErrorResult = CreateResult(context.ActionContext);
      
             // short circuit the rest of the authentication filters
              return Task.FromResult(0);
          }
      
          public Task ChallengeAsync(HttpAuthenticationChallengeContext context, CancellationToken cancellationToken)
          {
              var actionContext = context.ActionContext;
      
              actionContext.Request.Properties["selected_action"] = 
                  actionContext.ActionDescriptor;
              context.Result = CreateResult(actionContext); 
      
      
              return Task.FromResult(0);
          }
      
          private static IHttpActionResult CreateResult(
              HttpActionContext actionContext)
          {
              var response = new HttpResponseMessage()
                  { RequestMessage = actionContext.Request };
      
              actionContext.Response = response;
      
              return new ByPassActionResult(response);
          }
      
          public bool AllowMultiple { get { return true; } }
      }
      

      将导致执行短路的结果

      internal class ByPassActionResult : IHttpActionResult
      {
          public HttpResponseMessage Message { get; set; }
      
          public ByPassActionResult(HttpResponseMessage message)
          {
              Message = message;
          }
      
          public Task<HttpResponseMessage> 
             ExecuteAsync(CancellationToken cancellationToken)
          {
             return Task.FromResult<HttpResponseMessage>(Message);
          }
      }
      

答案 3 :(得分:3)

感谢whyleee上面的答案!

我将它与我在WebApiContrib.Testing库中语法上喜欢的一些元素结合起来,这对我来说无法生成以下帮助类。

这允许我编写像这样的轻量级测试......

[Test]
[Category("Auth Api Tests")]
public void TheAuthControllerAcceptsASingleItemGetRouteWithAHashString()
{
    "http://api.siansplan.com/auth/sjkfhiuehfkshjksdfh".ShouldMapTo<AuthController>("Get", "hash");
}

[Test]
[Category("Auth Api Tests")]
public void TheAuthControllerAcceptsAPost()
{
    "http://api.siansplan.com/auth".ShouldMapTo<AuthController>("Post", HttpMethod.Post);
}

我也稍微增强了它以允许在需要时测试参数(这是一个params数组,所以你可以添加你喜欢的所有内容,它只是检查它们是否存在)。这也适用于MOQ,纯粹因为它是我选择的框架......

using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Web.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Dispatcher;
using System.Web.Http.Hosting;
using System.Web.Http.Routing;

namespace SiansPlan.Api.Tests.Helpers
{
    public static class RoutingTestHelper
    {
        /// <summary>
        /// Routes the request.
        /// </summary>
        /// <param name="config">The config.</param>
        /// <param name="request">The request.</param>
        /// <returns>Inbformation about the route.</returns>
        public static RouteInfo RouteRequest(HttpConfiguration config, HttpRequestMessage request)
        {
            // create context
            var controllerContext = new HttpControllerContext(config, new Mock<IHttpRouteData>().Object, request);

            // get route data
            var routeData = config.Routes.GetRouteData(request);
            RemoveOptionalRoutingParameters(routeData.Values);

            request.Properties[HttpPropertyKeys.HttpRouteDataKey] = routeData;
            controllerContext.RouteData = routeData;

            // get controller type
            var controllerDescriptor = new DefaultHttpControllerSelector(config).SelectController(request);
            controllerContext.ControllerDescriptor = controllerDescriptor;

            // get action name
            var actionMapping = new ApiControllerActionSelector().SelectAction(controllerContext);

            var info = new RouteInfo(controllerDescriptor.ControllerType, actionMapping.ActionName);

            foreach (var param in actionMapping.GetParameters())
            {
                info.Parameters.Add(param.ParameterName);
            }

            return info;
        }

        #region | Extensions |

        /// <summary>
        /// Determines that a URL maps to a specified controller.
        /// </summary>
        /// <typeparam name="TController">The type of the controller.</typeparam>
        /// <param name="fullDummyUrl">The full dummy URL.</param>
        /// <param name="action">The action.</param>
        /// <param name="parameterNames">The parameter names.</param>
        /// <returns></returns>
        public static bool ShouldMapTo<TController>(this string fullDummyUrl, string action, params string[] parameterNames)
        {
            return ShouldMapTo<TController>(fullDummyUrl, action, HttpMethod.Get, parameterNames);
        }

        /// <summary>
        /// Determines that a URL maps to a specified controller.
        /// </summary>
        /// <typeparam name="TController">The type of the controller.</typeparam>
        /// <param name="fullDummyUrl">The full dummy URL.</param>
        /// <param name="action">The action.</param>
        /// <param name="httpMethod">The HTTP method.</param>
        /// <param name="parameterNames">The parameter names.</param>
        /// <returns></returns>
        /// <exception cref="System.Exception"></exception>
        public static bool ShouldMapTo<TController>(this string fullDummyUrl, string action, HttpMethod httpMethod, params string[] parameterNames)
        {
            var request = new HttpRequestMessage(httpMethod, fullDummyUrl);
            var config = new HttpConfiguration();
            WebApiConfig.Register(config);

            var route = RouteRequest(config, request);

            var controllerName = typeof(TController).Name;
            if (route.Controller.Name != controllerName)
                throw new Exception(String.Format("The specified route '{0}' does not match the expected controller '{1}'", fullDummyUrl, controllerName));

            if (route.Action.ToLowerInvariant() != action.ToLowerInvariant())
                throw new Exception(String.Format("The specified route '{0}' does not match the expected action '{1}'", fullDummyUrl, action));

            if (parameterNames.Any())
            {
                if (route.Parameters.Count != parameterNames.Count())
                    throw new Exception(
                        String.Format(
                            "The specified route '{0}' does not have the expected number of parameters - expected '{1}' but was '{2}'",
                            fullDummyUrl, parameterNames.Count(), route.Parameters.Count));

                foreach (var param in parameterNames)
                {
                    if (!route.Parameters.Contains(param))
                        throw new Exception(
                            String.Format("The specified route '{0}' does not contain the expected parameter '{1}'",
                                          fullDummyUrl, param));
                }
            }

            return true;
        }

        #endregion

        #region | Private Methods |

        /// <summary>
        /// Removes the optional routing parameters.
        /// </summary>
        /// <param name="routeValues">The route values.</param>
        private static void RemoveOptionalRoutingParameters(IDictionary<string, object> routeValues)
        {
            var optionalParams = routeValues
                .Where(x => x.Value == RouteParameter.Optional)
                .Select(x => x.Key)
                .ToList();

            foreach (var key in optionalParams)
            {
                routeValues.Remove(key);
            }
        }

        #endregion
    }

    /// <summary>
    /// Route information
    /// </summary>
    public class RouteInfo
    {
        #region | Construction |

        /// <summary>
        /// Initializes a new instance of the <see cref="RouteInfo"/> class.
        /// </summary>
        /// <param name="controller">The controller.</param>
        /// <param name="action">The action.</param>
        public RouteInfo(Type controller, string action)
        {
            Controller = controller;
            Action = action;
            Parameters = new List<string>();
        }

        #endregion

        public Type Controller { get; private set; }
        public string Action { get; private set; }
        public List<string> Parameters { get; private set; }
    }
}

答案 4 :(得分:3)

我采用了Keith Jackson的解决方案并将其修改为:

a)使用asp.net web api 2 - 属性路由以及作为旧学校路由

b)不仅要验证路由参数名称,还要验证它们的值

e.g。以下路线

    [HttpPost]
    [Route("login")]
    public HttpResponseMessage Login(string username, string password)
    {
        ...
    }


    [HttpPost]
    [Route("login/{username}/{password}")]
    public HttpResponseMessage LoginWithDetails(string username, string password)
    {
        ...
    }

您可以验证路由是否与正确的http方法,控制器,操作和参数匹配:

    [TestMethod]
    public void Verify_Routing_Rules()
    {
        "http://api.appname.com/account/login"
           .ShouldMapTo<AccountController>("Login", HttpMethod.Post);

        "http://api.appname.com/account/login/ben/password"
            .ShouldMapTo<AccountController>(
               "LoginWithDetails", 
               HttpMethod.Post, 
               new Dictionary<string, object> { 
                   { "username", "ben" }, { "password", "password" } 
               });
    }

修改Keith Jackson对whyleee解决方案的修改。

    public static class RoutingTestHelper
    {
        /// <summary>
        ///     Routes the request.
        /// </summary>
        /// <param name="config">The config.</param>
        /// <param name="request">The request.</param>
        /// <returns>Inbformation about the route.</returns>
        public static RouteInfo RouteRequest(HttpConfiguration config, HttpRequestMessage request)
        {
            // create context
            var controllerContext = new HttpControllerContext(config, new Mock<IHttpRouteData>().Object, request);

            // get route data
            var routeData = config.Routes.GetRouteData(request);
            RemoveOptionalRoutingParameters(routeData.Values);

            HttpActionDescriptor actionDescriptor = null;
            HttpControllerDescriptor controllerDescriptor = null;

            // Handle web api 2 attribute routes
            if (routeData.Values.ContainsKey("MS_SubRoutes"))
            {
                var subroutes = (IEnumerable<IHttpRouteData>)routeData.Values["MS_SubRoutes"];
                routeData = subroutes.First();
                actionDescriptor = ((HttpActionDescriptor[])routeData.Route.DataTokens.First(token => token.Key == "actions").Value).First();
                controllerDescriptor = actionDescriptor.ControllerDescriptor;
            }
            else
            {
                request.Properties[HttpPropertyKeys.HttpRouteDataKey] = routeData;
                controllerContext.RouteData = routeData;

                // get controller type
                controllerDescriptor = new DefaultHttpControllerSelector(config).SelectController(request);
                controllerContext.ControllerDescriptor = controllerDescriptor;

                // get action name
                actionDescriptor = new ApiControllerActionSelector().SelectAction(controllerContext);

            }

            return new RouteInfo
            {
                Controller = controllerDescriptor.ControllerType,
                Action = actionDescriptor.ActionName,
                RouteData = routeData
            };
        }


        #region | Extensions |

        public static bool ShouldMapTo<TController>(this string fullDummyUrl, string action, Dictionary<string, object> parameters = null)
        {
            return ShouldMapTo<TController>(fullDummyUrl, action, HttpMethod.Get, parameters);
        }

        public static bool ShouldMapTo<TController>(this string fullDummyUrl, string action, HttpMethod httpMethod, Dictionary<string, object> parameters = null)
        {
            var request = new HttpRequestMessage(httpMethod, fullDummyUrl);
            var config = new HttpConfiguration();
            WebApiConfig.Register(config);
            config.EnsureInitialized();

            var route = RouteRequest(config, request);

            var controllerName = typeof(TController).Name;
            if (route.Controller.Name != controllerName)
                throw new Exception(String.Format("The specified route '{0}' does not match the expected controller '{1}'", fullDummyUrl, controllerName));

            if (route.Action.ToLowerInvariant() != action.ToLowerInvariant())
                throw new Exception(String.Format("The specified route '{0}' does not match the expected action '{1}'", fullDummyUrl, action));

            if (parameters != null && parameters.Any())
            {
                foreach (var param in parameters)
                {
                    if (route.RouteData.Values.All(kvp => kvp.Key != param.Key))
                        throw new Exception(String.Format("The specified route '{0}' does not contain the expected parameter '{1}'", fullDummyUrl, param));

                    if (!route.RouteData.Values[param.Key].Equals(param.Value))
                        throw new Exception(String.Format("The specified route '{0}' with parameter '{1}' and value '{2}' does not equal does not match supplied value of '{3}'", fullDummyUrl, param.Key, route.RouteData.Values[param.Key], param.Value));
                }
            }

            return true;
        }

        #endregion


        #region | Private Methods |

        /// <summary>
        ///     Removes the optional routing parameters.
        /// </summary>
        /// <param name="routeValues">The route values.</param>
        private static void RemoveOptionalRoutingParameters(IDictionary<string, object> routeValues)
        {
            var optionalParams = routeValues
                .Where(x => x.Value == RouteParameter.Optional)
                .Select(x => x.Key)
                .ToList();

            foreach (var key in optionalParams)
            {
                routeValues.Remove(key);
            }
        }

        #endregion
    }

    /// <summary>
    ///     Route information
    /// </summary>
    public class RouteInfo
    {
        public Type Controller { get; set; }
        public string Action { get; set; }
        public IHttpRouteData RouteData { get; set; }
    }

答案 5 :(得分:1)

由于我无法弄清楚的一些细节,所有其他答案都失败了。

以下是使用GetRouteData()https://github.com/JayBazuzi/ASP.NET-WebApi-GetRouteData-example的完整示例,如下所示:

  1. 在VS 2013中,新项目 - &gt; Web,ASP.NET Web应用程序
  2. 选择WebAPI。选中“添加单元测试”。
  3. 添加以下单元测试:

    [TestMethod]
    public void RouteToGetUser()
    {
        var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:4567/api/users/me");
    
        var config = new HttpConfiguration();
        WebApiConfig.Register(config);
        config.EnsureInitialized();
    
        var result = config.Routes.GetRouteData(request);
    
        Assert.AreEqual("api/{controller}/{id}", result.Route.RouteTemplate);
    }
    

答案 6 :(得分:-1)

要从路径集合中获取路径数据,您需要在这种情况下提供完整的URI(只需使用“http:// localhost / api / super”)。

要测试来自RouteTable.Routes的路线,您可以执行以下操作:

var httpConfig = GlobalConfiguration.Configuration;
httpConfig.Routes.MapHttpRoute("DefaultApi", "api/{controller}/");

幕后发生的事情是GlobalConfiguration会将RouteTable.Routes改编为httpConfig.Routes。因此,当您向httpConfig.Routes添加路由时,它实际上会添加到RouteTable.Routes。但要实现这一点,您需要在ASP.NET中托管,以便填充诸如HostingEnvironment.ApplicationVirtualPath之类的环境设置。