Last active
December 31, 2015 00:29
-
-
Save feanz/7908043 to your computer and use it in GitHub Desktop.
Route Testing Extensions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <summary> | |
/// Used to simplify testing routes and restful testing routes | |
/// <example> | |
/// This tests that incoming PUT on resource is handled by the update method of the Banner controller | |
/// "~/banner/1" | |
/// .WithMethod(HttpVerbs.Put) | |
/// .ShouldMapTo<BannerController>(action => action.Update(1)); | |
/// </example> | |
/// </summary> | |
public static class RouteTestingExtensions | |
{ | |
/// <summary> | |
/// Will return the name of the action specified in the ActionNameAttribute for a method if it has an | |
/// ActionNameAttribute. | |
/// Will return the name of the method otherwise. | |
/// </summary> | |
/// <param name="method"></param> | |
/// <returns></returns> | |
public static string ActionName(this MethodInfo method) | |
{ | |
return method.IsDecoratedWith<ActionNameAttribute>() ? method.GetAttribute<ActionNameAttribute>().Name : method.Name; | |
} | |
/// <summary> | |
/// Will return true the first attribute of type TAttribute on the attributeTarget. | |
/// </summary> | |
/// <typeparam name="TAttribute"></typeparam> | |
/// <param name="attributeTarget"></param> | |
/// <returns></returns> | |
public static TAttribute GetAttribute<TAttribute>(this ICustomAttributeProvider attributeTarget) where TAttribute : Attribute | |
{ | |
return (TAttribute) attributeTarget.GetCustomAttributes(typeof (TAttribute), false)[0]; | |
} | |
#region MVC | |
/// <summary> | |
/// Gets a value from the <see cref="RouteValueDictionary" /> by key. Does a | |
/// case-insensitive search on the keys. | |
/// </summary> | |
/// <param name="routeValues"></param> | |
/// <param name="key"></param> | |
/// <returns></returns> | |
public static object GetValue(this RouteValueDictionary routeValues, string key) | |
{ | |
return (from routeValueKey in routeValues.Keys | |
where string.Equals(routeValueKey, key, StringComparison.InvariantCultureIgnoreCase) | |
select routeValues[routeValueKey] == null ? null : routeValues[routeValueKey].ToString()).FirstOrDefault(); | |
} | |
/// <summary> | |
/// Will return true if the attributeTarget is decorated with an attribute of type TAttribute. | |
/// Will return false if not. | |
/// </summary> | |
/// <typeparam name="TAttribute"></typeparam> | |
/// <param name="attributeTarget"></param> | |
/// <returns></returns> | |
public static bool IsDecoratedWith<TAttribute>(this ICustomAttributeProvider attributeTarget) where TAttribute : Attribute | |
{ | |
return attributeTarget.GetCustomAttributes(typeof (TAttribute), false).Length > 0; | |
} | |
/// <summary> | |
/// Find the route for a URL and an Http Method | |
/// because you have a method contraint on the route | |
/// </summary> | |
/// <param name="url"></param> | |
/// <param name="httpMethod"></param> | |
/// <returns></returns> | |
public static RouteData Route(string url, string httpMethod) | |
{ | |
var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build(); | |
return RouteTable.Routes.GetRouteData(context); | |
} | |
/// <summary> | |
/// Returns the corresponding route for the URL. Returns null if no route was found. | |
/// </summary> | |
/// <param name="url">The app relative url to test.</param> | |
/// <returns>A matching <see cref="RouteData" />, or null.</returns> | |
public static RouteData Route(this string url) | |
{ | |
var context = new MockHttpContextFixture().WithRelativePath(url).Build(); | |
return RouteTable.Routes.GetRouteData(context); | |
} | |
/// <summary> | |
/// Returns the corresponding route for the URL. Returns null if no route was found. | |
/// </summary> | |
/// <param name="url">The URL.</param> | |
/// <param name="httpMethod">The HTTP method.</param> | |
/// <param name="formMethod">The form method.</param> | |
/// <returns></returns> | |
public static RouteData Route(this string url, HttpVerbs httpMethod, HttpVerbs formMethod) | |
{ | |
var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build(); | |
var route = RouteTable.Routes.GetRouteData(context); | |
return route; | |
} | |
/// <summary> | |
/// Returns the corresponding route for the URL. Returns null if no route was found. | |
/// </summary> | |
/// <param name="url">The URL.</param> | |
/// <param name="httpMethod">The HTTP method.</param> | |
/// <returns></returns> | |
public static RouteData Route(this string url, HttpVerbs httpMethod) | |
{ | |
var context = new MockHttpContextFixture().WithRelativePath(url).WithHttpMethod(httpMethod).Build(); | |
var route = RouteTable.Routes.GetRouteData(context); | |
return route; | |
} | |
/// <summary> | |
/// Verifies the <see cref="RouteData">routeData</see> will instruct the routing engine to ignore the route. | |
/// </summary> | |
/// <param name="relativeUrl"></param> | |
/// <returns></returns> | |
public static RouteData ShouldBeIgnored(this string relativeUrl) | |
{ | |
var routeData = relativeUrl.Route(); | |
if (routeData.RouteHandler.GetType() != typeof (PageRouteHandler)) | |
throw new RouteTestingException("Expected StopRoutingHandler, but wasn't"); | |
return routeData; | |
} | |
/// <summary> | |
/// Asserts that the route matches the expression specified. Checks controller, action, and any method arguments | |
/// into the action as route values. | |
/// </summary> | |
/// <typeparam name="TController">The controller.</typeparam> | |
/// <param name="routeData">The routeData to check</param> | |
/// <param name="action">The action to call on TController.</param> | |
public static RouteData ShouldMapTo<TController>(this RouteData routeData, Expression<Func<TController, ActionResult>> action) | |
where TController : Controller | |
{ | |
if (RouteTable.Routes.Count == 0) | |
throw new ArgumentException("No routes found in route table make sure you register routes before testing route matching"); | |
if (routeData == null) | |
throw new ArgumentException("The URL did not match any route"); | |
//check controller | |
routeData.ShouldMapTo<TController>(); | |
//check action | |
var methodCall = CheckActionMaps(routeData, action); | |
CheckArgumentsMap(routeData, methodCall); | |
return routeData; | |
} | |
/// <summary> | |
/// Converts the URL to matching RouteData and verifies that it will match a route with the values specified by the | |
/// expression. | |
/// </summary> | |
/// <typeparam name="TController">The type of controller</typeparam> | |
/// <param name="relativeUrl">The ~/ based url</param> | |
/// <param name="action">The expression that defines what action gets called (and with which parameters)</param> | |
/// <returns></returns> | |
public static RouteData ShouldMapTo<TController>(this string relativeUrl, Expression<Func<TController, ActionResult>> action) where TController : Controller | |
{ | |
if (!relativeUrl.StartsWith("~")) | |
throw new InvalidOperationException("Url should be relative and start with a ~"); | |
return relativeUrl.Route().ShouldMapTo(action); | |
} | |
/// <summary> | |
/// Verifies the <see cref="RouteData">routeData</see> maps to the controller type specified. | |
/// </summary> | |
/// <typeparam name="TController"></typeparam> | |
/// <param name="routeData"></param> | |
/// <returns></returns> | |
public static RouteData ShouldMapTo<TController>(this RouteData routeData) where TController : Controller | |
{ | |
//strip out the word 'Controller' from the type | |
var expected = typeof (TController).Name; | |
if (expected.EndsWith("Controller")) | |
{ | |
expected = expected.Substring(0, expected.LastIndexOf("Controller", StringComparison.Ordinal)); | |
} | |
//get the key (case insensitive) | |
var actual = routeData.Values.GetValue("controller").ToString(); | |
if (actual != expected) | |
throw new RouteTestingException(string.Format("Controller names do not match expecting:{0} actual:{1}", expected, actual)); | |
return routeData; | |
} | |
/// <summary> | |
/// Converts the URL to matching RouteData and verifies that it will match a route for a Web Form page. | |
/// </summary> | |
/// <param name="relativeUrl">The ~/ based URL</param> | |
/// <param name="pathToWebForm">The ~/ based path to the web form</param> | |
/// <returns></returns> | |
public static RouteData ShouldMapToPage(this string relativeUrl, string pathToWebForm) | |
{ | |
return relativeUrl.Route().ShouldMapToPage(pathToWebForm); | |
} | |
/// <summary> | |
/// Verifies the <see cref="RouteData">routeData</see> maps to a web form page. | |
/// </summary> | |
public static RouteData ShouldMapToPage(this RouteData route, string pathToWebForm) | |
{ | |
if (route.RouteHandler.GetType() != typeof (PageRouteHandler)) | |
throw new RouteTestingException("The route does not map to a Web Form page."); | |
var handler = (PageRouteHandler) route.RouteHandler; | |
if (handler.VirtualPath != pathToWebForm) | |
throw new RouteTestingException("The route does not map to the correct Web Form page."); | |
return route; | |
} | |
/// <summary> | |
/// A way to start the fluent interface and and which method to use | |
/// since you have a method constraint in the route. | |
/// </summary> | |
/// <param name="relativeUrl"></param> | |
/// <param name="httpMethod"></param> | |
/// <returns></returns> | |
public static RouteData WithMethod(this string relativeUrl, string httpMethod) | |
{ | |
return Route(relativeUrl, httpMethod); | |
} | |
/// <summary> | |
/// A way to start the fluent interface and and which method to use | |
/// since you have a method constraint in the route. | |
/// </summary> | |
/// <param name="relativeUrl"></param> | |
/// <param name="verb"></param> | |
/// <returns></returns> | |
public static RouteData WithMethod(this string relativeUrl, HttpVerbs verb) | |
{ | |
return WithMethod(relativeUrl, verb.ToString("g")); | |
} | |
/// <summary> | |
/// Asserts that the route matches the expression specified based on the incoming HttpMethod and FormMethod for Simply | |
/// Restful routing. Checks controller, action, and any method arguments | |
/// into the action as route values. | |
/// </summary> | |
/// <param name="relativeUrl">The relative URL.</param> | |
/// <param name="httpMethod">The HTTP method.</param> | |
/// <param name="formMethod">The form method.</param> | |
/// <returns></returns> | |
public static RouteData WithMethod(this string relativeUrl, HttpVerbs httpMethod, HttpVerbs formMethod) | |
{ | |
return relativeUrl.Route(httpMethod, formMethod); | |
} | |
private static MethodCallExpression CheckActionMaps<TController>(RouteData routeData, Expression<Func<TController, ActionResult>> action) where TController : Controller | |
{ | |
var methodCall = (MethodCallExpression) action.Body; | |
var actualAction = routeData.Values.GetValue("action").ToString(); | |
var expectedAction = methodCall.Method.ActionName(); | |
if (!string.Equals(expectedAction, actualAction, StringComparison.InvariantCultureIgnoreCase)) | |
throw new RouteTestingException(string.Format("Action did not match expecting:{0} actual:{1}", expectedAction, actualAction)); | |
return methodCall; | |
} | |
private static void CheckArgumentsMap(RouteData routeData, MethodCallExpression methodCall) | |
{ | |
for (var i = 0; i < methodCall.Arguments.Count; i++) | |
{ | |
var param = methodCall.Method.GetParameters()[i]; | |
//treat strings as value types | |
var isReferenceType = !param.ParameterType.IsValueType && param.ParameterType != typeof (string); | |
var isNullable = isReferenceType || | |
(param.ParameterType.UnderlyingSystemType.IsGenericType && param.ParameterType.UnderlyingSystemType.GetGenericTypeDefinition() == typeof (Nullable<>)); | |
var controllerParameterName = param.Name; | |
var routeDataContainsValueForParameterName = routeData.Values.ContainsKey(controllerParameterName); | |
var actualValue = routeData.Values.GetValue(controllerParameterName); | |
object expectedValue = null; | |
var expressionToEvaluate = methodCall.Arguments[i]; | |
// If the parameter is nullable and the expression is a Convert UnaryExpression, | |
// we actually want to test against the value of the expression's operand. | |
if (expressionToEvaluate.NodeType == ExpressionType.Convert | |
&& expressionToEvaluate is UnaryExpression) | |
{ | |
expressionToEvaluate = ((UnaryExpression) expressionToEvaluate).Operand; | |
} | |
switch (expressionToEvaluate.NodeType) | |
{ | |
case ExpressionType.Constant: | |
expectedValue = ((ConstantExpression) expressionToEvaluate).Value; | |
break; | |
case ExpressionType.New: | |
case ExpressionType.MemberAccess: | |
expectedValue = Expression.Lambda(expressionToEvaluate).Compile().DynamicInvoke(); | |
break; | |
case ExpressionType.MemberInit: | |
throw new InvalidOperationException("This method does not support inline implementation of method arguments"); | |
} | |
// The parameter is nullable so an expected value of '' is equivalent to null; | |
if (isNullable && actualValue == null && (string) expectedValue == string.Empty | |
|| (string) actualValue == string.Empty && expectedValue == null) | |
{ | |
continue; | |
} | |
// HACK: this is only sufficient while System.Web.Mvc.UrlParameter has only a single value. | |
if (actualValue == UrlParameter.Optional || | |
(actualValue != null && actualValue.ToString().Equals("System.Web.Mvc.UrlParameter"))) | |
{ | |
actualValue = null; | |
} | |
//if its a option param use default value | |
if (param.IsOptional) | |
{ | |
actualValue = param.DefaultValue; | |
} | |
if (expectedValue is DateTime) | |
{ | |
actualValue = Convert.ToDateTime(actualValue); | |
} | |
else if (isReferenceType) | |
{ | |
CheckComplexTypeMap(routeData, expectedValue); | |
} | |
else | |
{ | |
//compare as strings | |
expectedValue = (expectedValue != null) ? expectedValue.ToString() : string.Empty; | |
actualValue = (actualValue != null) ? actualValue.ToString() : string.Empty; | |
} | |
var errorMsgFmt = "Value for parameter '{0}' did not match: expected '{1}' but was '{2}'"; | |
if (routeDataContainsValueForParameterName) | |
{ | |
errorMsgFmt += "."; | |
} | |
else | |
{ | |
errorMsgFmt += "; no value found in the route context action parameter named '{0}' - does your matching route contain a token called '{0}'?"; | |
} | |
if (!string.Equals((string) expectedValue, (string) actualValue, StringComparison.InvariantCultureIgnoreCase)) | |
throw new RouteTestingException(string.Format(errorMsgFmt, controllerParameterName, expectedValue, actualValue)); | |
} | |
} | |
private static void CheckComplexTypeMap(RouteData routeData, object expectedValue) | |
{ | |
//if its a reference try and extract its public properties and match against route data | |
//Don't match FormCollections | |
if (!(expectedValue is FormCollection)) | |
{ | |
var props = expectedValue | |
.GetType() | |
.GetProperties() | |
.Select(x => new {x.Name, Value = x.GetValue(expectedValue, null), IsEnum = (x.PropertyType.IsEnum), Type = x.PropertyType}) | |
.ToList(); | |
if (props.Any()) | |
{ | |
foreach (var prop in props) | |
{ | |
if (prop.Value != null) | |
{ | |
var value = routeData.Values.GetValue(prop.Name); | |
if (value != null) | |
{ | |
if (prop.IsEnum) | |
{ | |
//Check both value pass to enum then compare | |
if (Enum.Parse(prop.Type, prop.Value.ToString()).ToString() != Enum.Parse(prop.Type, value.ToString()).ToString()) | |
{ | |
throw new RouteTestingException("Failed to match route parameters to controller action arguments. " + | |
"Tried to match property: {0} on type: {1} which had a value of {2} and the route parameter {3} which had a value of {4} ", prop.Name, prop.Type, prop.Value, prop.Name, value); | |
} | |
} | |
else if (prop.Value.ToString() != value.ToString()) | |
{ | |
throw new RouteTestingException("Failed to match route parameters to controller action arguments. " + | |
"Tried to match property: {0} on type: {1} which had a value of {2} and the route parameter {3} which had a value of {4} ", prop.Name, prop.Type, prop.Value, prop.Name, value); | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
#endregion | |
#region WEB API | |
/// <summary> | |
/// Returns the corresponding http route for the URL. Returns null if no route was found. | |
/// </summary> | |
/// <param name="url">The app relative url to test.</param> | |
/// <param name="config"></param> | |
/// <returns>A matching <see cref="RouteData" />, or null.</returns> | |
public static HttpRouteTester HttpRoute(this string url, HttpConfiguration config) | |
{ | |
return HttpRoute(url, config, HttpMethod.Get); | |
} | |
/// <summary> | |
/// Returns the corresponding http route for the URL. Returns null if no route was found. | |
/// </summary> | |
/// <param name="url">The app relative url to test.</param> | |
/// <param name="config"></param> | |
/// <param name="httpMethod">HttpMethod</param> | |
/// <returns>A matching <see cref="RouteData" />, or null.</returns> | |
public static HttpRouteTester HttpRoute(this string url, HttpConfiguration config, HttpMethod httpMethod) | |
{ | |
var httpRequestMessage = new HttpRequestMessage(httpMethod, url); | |
return new HttpRouteTester(config, httpRequestMessage); | |
} | |
/// <summary> | |
/// Converts the URL to matching web api route and verifies that it will match a route with the values specified by the | |
/// expression. | |
/// </summary> | |
/// <typeparam name="TController">The type of web api controller</typeparam> | |
/// <param name="url">The ~/ based url</param> | |
/// <param name="config"></param> | |
/// <param name="action">The expression that defines what action gets called (and with which parameters)</param> | |
public static HttpRouteTester ShouldMapToApi<TController>(this string url, HttpConfiguration config, Expression<Func<TController, HttpResponseMessage>> action) where TController : ApiController | |
{ | |
url = ConvertRelativeUrlToAbsolute(url); | |
return url.HttpRoute(config).ShouldMapToApi(action); | |
} | |
private static string ConvertRelativeUrlToAbsolute(string url) | |
{ | |
return url.StartsWith("~") ? url.Replace("~", "http://www.site.com") : url; | |
} | |
/// <summary> | |
/// Verifies the <see cref="RouteData">routeData</see> maps to the controller type specified. | |
/// </summary> | |
/// <typeparam name="TController"></typeparam> | |
/// <param name="routeTester"></param> | |
/// <returns></returns> | |
public static HttpRouteTester ShouldMapToApi<TController>(this HttpRouteTester routeTester) where TController : ApiController | |
{ | |
var expecting = typeof (TController); | |
if (expecting != routeTester.GetControllerType()) | |
throw new RouteTestingException(string.Format("Controller types do not match expecting:{0} actual:{1}", expecting, routeTester.GetControllerType())); | |
return routeTester; | |
} | |
/// <summary> | |
/// Asserts that the route matches the expression specified. Checks controller, action, and any method arguments | |
/// into the action as route values. | |
/// </summary> | |
/// <typeparam name="TController">The controller.</typeparam> | |
/// <param name="routeTester">The routeData to check</param> | |
/// <param name="action">The action to call on TController.</param> | |
public static HttpRouteTester ShouldMapToApi<TController>(this HttpRouteTester routeTester, Expression<Func<TController, HttpResponseMessage>> action) | |
where TController : ApiController | |
{ | |
if (routeTester == null) | |
throw new ArgumentException("The URL did not match any route"); | |
routeTester.ShouldMapToApi<TController>(); | |
CheckActionMaps(routeTester, action); | |
return routeTester; | |
} | |
private static void CheckActionMaps<TController>(HttpRouteTester routeTester, Expression<Func<TController, HttpResponseMessage>> action) where TController : ApiController | |
{ | |
var methodCall = (MethodCallExpression) action.Body; | |
var actualAction = routeTester.GetActionName(); | |
var expectedAction = methodCall.Method.ActionName(); | |
if (expectedAction != actualAction) | |
throw new RouteTestingException(string.Format("Action did not match expecting:{0} actual:{1}", expectedAction, actualAction)); | |
} | |
/// <summary> | |
/// A way to start the fluent interface and and which method to use | |
/// since you have a method constraint in the route. | |
/// </summary> | |
/// <param name="url"></param> | |
/// <param name="config"></param> | |
/// <param name="httpMethod"></param> | |
/// <returns></returns> | |
public static HttpRouteTester WithMethod(this string url, HttpConfiguration config, HttpMethod httpMethod) | |
{ | |
return HttpRoute(url, config, httpMethod); | |
} | |
#endregion | |
} | |
public class RouteTestingException : Exception | |
{ | |
public RouteTestingException(string message) | |
: base(message) | |
{ | |
} | |
public RouteTestingException(string format, params object[] args) | |
: base(string.Format(format, args)) | |
{ | |
} | |
} | |
public class HttpRouteTester | |
{ | |
private readonly HttpControllerContext _controllerContext; | |
private readonly IHttpControllerSelector _controllerSelector; | |
private readonly HttpRequestMessage _request; | |
public HttpRouteTester(HttpConfiguration config, HttpRequestMessage request) | |
{ | |
if (config.Routes.Count == 0) | |
throw new ArgumentException("No routes found in route table make sure you register routes before testing route matching"); | |
_request = request; | |
RouteData = config.Routes.GetRouteData(request); | |
if (RouteData == null) | |
throw new ArgumentException("No route data found for this route"); | |
request.Properties[HttpPropertyKeys.HttpRouteDataKey] = RouteData; | |
_controllerSelector = new DefaultHttpControllerSelector(config); | |
_controllerContext = new HttpControllerContext(config, RouteData, request); | |
} | |
public IHttpRouteData RouteData { get; private set; } | |
public string GetActionName() | |
{ | |
if (_controllerContext.ControllerDescriptor == null) | |
GetControllerType(); | |
var actionSelector = new ApiControllerActionSelector(); | |
var descriptor = actionSelector.SelectAction(_controllerContext); | |
return descriptor.ActionName; | |
} | |
public Type GetControllerType() | |
{ | |
var descriptor = _controllerSelector.SelectController(_request); | |
_controllerContext.ControllerDescriptor = descriptor; | |
return descriptor.ControllerType; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment