Тестирование конфигурации маршрута в ASP.NET WebApi
Я пытаюсь выполнить частичное тестирование конфигурации маршрута WebApi. Я хочу проверить, что маршрут "/api/super"
соответствует методу Get()
моего SuperController
. Я установил ниже тест, и у меня есть несколько проблем.
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");
}
Моя первая проблема заключается в том, что если я не укажу полный URL-адрес httpConfig.Routes.GetRouteData(request);
, выдает исключение InvalidOperationException
с сообщением "Эта операция не поддерживается для относительного URI".
Мне явно не хватает чего-то с моей заглубленной конфигурацией. Я бы предпочел использовать относительный URI, поскольку не представляется разумным использовать полный URI для тестирования маршрута.
Моя вторая проблема с моей конфигурацией выше: я не тестирую свои маршруты как настроенные в моем RouteConfig, но вместо этого использую:
var routeCollection = new HttpRouteCollection();
routeCollection.MapHttpRoute("DefaultApi", "api/{controller}/");
Как использовать назначенный RouteTable.Routes
как настроено в типичном Global.asax:
public class MvcApplication : HttpApplication
{
protected void Application_Start()
{
// other startup stuff
RouteConfig.RegisterRoutes(RouteTable.Routes);
}
}
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
// route configuration
}
}
Дальше то, что я пропустил выше, может быть не лучшей тестовой конфигурацией. Если есть более оптимизированный подход, я все уши.
Ответы
Ответ 1
Недавно я тестировал маршруты веб-API, и вот как я это сделал.
- Во-первых, я создал помощника для перемещения всей логики маршрутизации Web API:
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; }
}
- Предполагая, что у меня есть отдельный класс для регистрации маршрутов веб-API (он создается по умолчанию в проекте веб-приложения Visual Studio ASP.NET MVC 4 в папке App_Start):
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
}
}
- Я могу легко проверить свои маршруты:
[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-адреса. Но я не вижу здесь никаких проблем, потому что это поддельные URL-адреса, мне не нужно ничего настраивать для их работы, и они представляют реальные запросы к нашим веб-службам.
- Вам не нужно копировать код сопоставления маршрута в тесты, если они настроены в отдельном классе с зависимостью HttpConfiguration (как в примере выше).
- В приведенном выше примере я использую NUnit, NSubstitute и FluentAssertions, но, конечно, это простая задача сделать то же самое с любыми другими тестовыми платформами.
Ответ 2
Поздний ответ для ASP.NET Web API 2 (я тестировал только эту версию). Я использовал MvcRouteTester.Mvc5 от Nuget, и он выполняет эту работу для меня. вы можете написать следующее.
[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.
Ответ 3
Этот ответ действителен для WebAPI 2.0 и выше
Чтение через ответ Whyleee, я заметил, что подход основан на парапотенциально хрупких предположениях:
- Подход пытается воссоздать выбор действия и предполагает внутренние детали реализации в Web API.
- Предполагается, что используется селектор контроллера по умолчанию, когда имеется известная точка публичной расширяемости, которая позволяет ее заменить.
Альтернативный подход заключается в использовании легкого функционального теста.
Шагами в этом подходе являются:
- Инициализировать тестовый объект HttpConfiguration с помощью метода WebApiConfig.Register, отображая способ инициализации приложения в реальном мире.
- Добавьте настраиваемый фильтр проверки подлинности к объекту тестовой конфигурации, который захватывает информацию о действиях на этом уровне. Это можно ввести или сделать непосредственно в коде продукта с помощью переключателя.
2.1. Фильтр аутентификации будет закоротить любые фильтры, а также код действия, поэтому нет никаких проблем с фактическим кодом, выполняемым в самом методе действий.
- Используйте сервер in-memory (HttpServer) и выполните запрос. Это легкий подход, используя канал в памяти, поэтому он не попадет в сеть.
- Сравните полученную информацию о действии с ожидаемой информацией.
[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"));
}
}
Этот помощник выполняет конвейер и проверяет данные,
фильтр аутентификации, другие свойства также могут быть захвачены OR
может быть реализован клиентский фильтр, который выполняет валидацию непосредственно для каждого теста, передавая лямбда в фильтр при инициализации.
public class ActionSelectorValidator
{
public static bool IsActionSelected(
HttpMethod method,
string uri,
HttpConfiguration config,
Type controller,
string actionName)
{
config.Filters.Add(new SelectedActionFilter());
HttpServer server = new HttpServer(config);
HttpClient client = new HttpClient(server);
HttpRequestMessage 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);
Assert.IsNull(context.Result);
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);
}
}
Ответ 4
Спасибо 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);
}
Я также немного улучшил его, чтобы позволить тестирование параметров при необходимости (это массив параметров, чтобы вы могли добавить все, что вам нравится, и оно просто проверяет, что они присутствуют). Это также было адаптировано для 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; }
}
}
Ответ 5
Я взял решение Кейта Джексона и изменил его на:
a) работать с asp.net web api 2 - маршрутизация атрибутов , а, как маршрутизация старой школы
и
b) проверить не только имена параметров маршрута, но и их значения
например. для следующих маршрутов
[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" }
});
}
Изменения в модификациях Кита Джексона в решении 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; }
}
Ответ 6
Все другие ответы мне не удалось из-за некоторых деталей, которые я не мог понять.
Вот полный пример использования GetRouteData()
: https://github.com/JayBazuzi/ASP.NET-WebApi-GetRouteData-example, созданный следующим образом:
- В VS 2013, Новый Проект → Веб, Веб-приложение ASP.NET
- Выберите WebAPI. Проверьте "Добавить модульные тесты".
-
Добавьте следующий unit test:
[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);
}
Ответ 7
Чтобы получить данные маршрута из коллекций маршрутов, в этом случае вам необходимо предоставить полный 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.