Unit-testing routes with Web API

One of the cool features of Web API, is that you can define the routes using attributed routing. I really like this approach, but it can also lead to misspelling which in the end can cause invalid routes.

To avoid that, you should really test that all the routes matches the right controller, the right action and the right http request type. In order to that, you must have a lot of test setup code for just faking the http request. However, with this little extension method, it will be both fun and easy to test.

Let us begin by taking this controller and action:

1
2
3
4
5
6
7
8
9
10
11
12
[RoutePrefix("person")]
public class PersonController : ApiController {

[HttpGet]
[Route("{id:int}", Name = "Person.GetPersonById")]
public IHttpActionResult GetPersonById(int id)
{

var person = new Person { Id = id }; // Should probably be a database query. :-)
return Ok(person);
}

}

In the example above, we want to verify that the http://localhost/api/person/1 route is matching the action GetPersonById(int id) with the argument 1 on PersonController and with a GET request.

It is straight forward to test this, with this extension method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Fact]
public void RouteName_GetPersonById_ShouldRouteToPersonController_GetPersonById()
{

// Arrange
var configuration = new HttpConfiguration();
RouteConfig.RegisterRoutes(configuration); // Your RouteConfig from your Web API project.
configuration.EnsureInitialized();

// Act and assert
configuration.AssertRouteEqualTo<PersonController>("http://localhost/api/person/1",
controller => controller.GetPersonById(1), HttpMethod.Get, new Dictionary<string, object>
{
{ "id", 1 }
}
);
}

Please note, it is important to call the configuration.EnsureInitialized(); extensions to make sure the routes are correctly mapped, before asserting the route. If you do not do that, an exception will be thrown.

Now, lets take a look on how this AssertRouteEqualTo<TController>(...) extension method looks like.

In order to do all this, I came up with the following extension method, with some help from Stack Overflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
public static class WebApiRouteExtensions
{
/// <summary>
/// Assert that the route url is matching the controller and the action with the arguments specified (optional).
/// </summary>
/// <typeparam name="TController"></typeparam>
/// <param name="configuration">The Web API HttpConfiguration.</param>
/// <param name="url">The url which is going to be matched.</param>
/// <param name="action">The action on the controller to be matched.</param>
/// <param name="method">The HttpMethod expect to be on the action.</param>
/// <param name="parameters">Optional parameters to match the action with.</param>
public static void AssertRouteEqualTo<TController>(this HttpConfiguration configuration, string url, Expression<Func<TController, object>> action, HttpMethod method, Dictionary<string, object> parameters = null) where TController : IHttpController
{
var request = new HttpRequestMessage(method, url);
var route = configuration.RouteRequest(request);
var controllerName = typeof(TController).Name;
var actionMethodCall = action.Body as MethodCallExpression;

if (route == null)
{
throw new Exception($"The specified route '{url}' does not exisit in the route collection.");
}

if (route.HttpMethod != method)
{
throw new Exception($"The specified route '{url}' does not match the HttpMethod '{method}' - expected: '{route.HttpMethod}'");
}

if (route.Controller.Name != controllerName)
{
throw new Exception($"The specified route '{url}' does not match the expected controller '{controllerName}'");
}

if (actionMethodCall == null)
{
throw new ArgumentException("Expression must be a MethodCallExpression", nameof(action));
}

if (!String.Equals(route.Action, actionMethodCall.Method.Name, StringComparison.InvariantCultureIgnoreCase))
{
throw new ArgumentException($"The specified route '{url}' does not match the expected action '{action}'");
}

if (parameters != null && parameters.Any())
{
foreach (var param in parameters)
{
if (route.RouteData.Values.All(kvp => kvp.Key != param.Key))
{
throw new ArgumentException($"The specified route '{url}' does not contain the expected parameter '{param}'");
}

if (!route.RouteData.Values[param.Key].Equals(param.Value.ToString()))
{
throw new ArgumentException($"The specified route '{url}' with parameter '{param.Key}' and value '{route.RouteData.Values[param.Key]}' does not equal does not match supplied value of '{param.Value}'");
}
}
}
}

/// <summary>
/// Routes the request.
/// </summary>
/// <param name="config">The config.</param>
/// <param name="request">The request.</param>
/// <returns>Inbformation about the route.</returns>
private static RouteInfo RouteRequest(this HttpConfiguration config, HttpRequestMessage request)
{

var routeData = config.Routes.GetRouteData(request);

// If the request is invalid, return.
if (routeData == null)
{
return null;
}

// Remove unnessercery parameters
routeData.RemoveOptionalRoutingParameters();

var subroutes = routeData.GetSubRoutes();
if (subroutes == null) return null;

routeData = subroutes.First();
var actionDescriptor = ((HttpActionDescriptor[])routeData.Route.DataTokens.First(token => token.Key == "actions").Value).First();
var controllerDescriptor = actionDescriptor.ControllerDescriptor;

return new RouteInfo
{
Controller = controllerDescriptor.ControllerType,
Action = actionDescriptor.ActionName,
HttpMethod = actionDescriptor.SupportedHttpMethods.First(),
RouteData = routeData
};
}

/// <summary>
/// Removes the optional routing parameters.
/// </summary>
/// <param name="routeData">The route data.</param>
private static void RemoveOptionalRoutingParameters(this IHttpRouteData routeData)
{

var optionalParams = routeData.Values
.Where(x => x.Value == RouteParameter.Optional)
.Select(x => x.Key)
.ToList();

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

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

public HttpMethod HttpMethod { get; set; }
}

Let me know if you run into any problems with the extensions method above.

Happy testing!

Comments