We recommend that you unit test your routes to make sure they process incoming URLs as expected, even if you choose not to unit test the rest of your application. URL schemas can get pretty complex in large applications and it is easy to create something which has unexpected results.
In previous chapters, we have avoided creating common helper methods that to be shared between tests so that we can keep each unit test description self-contained. For this chapter, we are taking a different approach – testing the routing schema for an application is most readily done when you can batch up several tests in a single method, and this becomes much easier with some helper methods.
To test routes, we need to mock three classes – HttpRequestBase and HttpContextBase and HttpResponseBase – this last class is required for testing outgoing URLs, which we cover later in this chapter. Together, these classes recreate enough of the MVC infrastructure to support the routing system. Here is the helper method that creates the mock objects, which we added to our unit test project:
private HttpContextBase CreateHttpContext(string targetUrl = null, string httpMethod = "GET") {
// create the mock request
Mock<HttpRequestBase> mockRequest = new Mock<HttpRequestBase>();
mockRequest.Setup(m => m.AppRelativeCurrentExecutionFilePath).Returns(targetUrl);
mockRequest.Setup(m => m.HttpMethod).Returns(httpMethod);
// create the mock response
Mock<HttpResponseBase> mockResponse = new Mock<HttpResponseBase>();
mockResponse.Setup(m => m.ApplyAppPathModifier(
It.IsAny<string>())).Returns<string>(s => s);
// create the mock context, using the request and response
Mock<HttpContextBase> mockContext = new Mock<HttpContextBase>();
mockContext.Setup(m => m.Request).Returns(mockRequest.Object);
mockContext.Setup(m => m.Response).Returns(mockResponse.Object);
// return the mocked context return mockContext.Object;
}
The set up here is relatively simple – we expose the URL we want to test through the AppRelativeCurrentExecutionFilePath property of the HttpRequestBase class, and expose the HttpRequestBase through the Request property of the mock HttpContextBase class. Our next helper method lets us test a route:
private void TestRouteMatch(string url, string controller, string action, object routeProperties = null, string httpMethod = "GET") {
// Arrange
RouteCollection routes = new RouteCollection();
MvcApplication.RegisterRoutes(routes);
// Act - process the route
RouteData result = routes.GetRouteData(CreateHttpContext(url, httpMethod));
// Assert
Assert.IsNotNull(result);
Assert.IsTrue(TestIncomingRouteResult(result, controller, action, routeProperties));
}
The parameters of this method let us specify the URL to test, the expected values for the controller and action segment variables and an object that contains the expected values for any additional variables we have defined – we’ll show you how to create such variables later in the chapter. We also defined a parameter for the HTTP method, which we’ll explain in the Constraining Routes section.
The TestRouteMatch method relies on another method called
TestIncomingRouteResult to compare the result obtained from the routing system with the segment variable values we expect. This method uses .NET reflection so that we can use an anonymous type to express any additional segment variables – don’t worry if this method doesn’t make sense as this is just make testing more convenient and isn’t a requirement for understanding MVC. Here is the TestIncomingRouteResult method:
private bool TestIncomingRouteResult(RouteData routeResult, string controller, string action, object propertySet = null) {
Func<object, object, bool> valCompare = (v1, v2) => {
return StringComparer.InvariantCultureIgnoreCase.Compare(v1, v2) == 0;
};
bool result = valCompare(routeResult.Values["controller"], controller) && valCompare(routeResult.Values["action"], action);
if (propertySet != null) {
PropertyInfo[] propInfo = propertySet.GetType().GetProperties();
foreach (PropertyInfo pi in propInfo) {
if (!(routeResult.Values.ContainsKey(pi.Name) && valCompare(routeResult.Values[pi.Name], pi.GetValue(propertySet, null)))) {
result = false;
break;
} } }
return result;
}
We also need a method to check that a URL doesn’t work – as we’ll see, this can be an important part of defining a URL schema:
private void TestRouteFail(string url) { // Arrange
RouteCollection routes = new RouteCollection();
MvcApplication.RegisterRoutes(routes);
// Act - process the route
RouteData result = routes.GetRouteData(CreateHttpContext(url));
// Assert
Assert.IsTrue(result == null || result.Route == null);
}
The TestRouteMatch and TestRouteFail contain calls to the Assert method, which throws an exception if the assertion fails. Since C# exceptions are propagated up the call stack, we can create simple test methods that can test a set of URLs and get the test behavior we require.
Here is a test method that tests the route we defined in Listing 11-3:
[TestMethod]
public void TestIncomingRoutes() {
// check for the URL that we hope to receive
TestRouteMatch("~/Admin/Index", "Admin", "Index");
// check that the values are being obtained from the segments TestRouteMatch("~/One/Two", "One", "Two");
// ensure that too many of too few segments fails to match TestRouteFail("~/Admin/Index/Segment");
TestRouteFail("~/Admin");
}
This test uses the TestRouteMatch method to check the URL we are expecting and also checks a URL in the same format to make sure that the controller and action values are being obtained properly using the URL segments. We also use the TestRouteFail method to make sure that our application won’t accept URLs that have a different number of segments. When testing, we must prefix the URL with the tilde (~) character – this is how the ASP.NET framework presents the URL to the routing system.
Notice that we didn’t have to define the routes in the test methods – this is because we are loading them directly from the RegisterRoutes method in the Global.asax class.
We can see the effect of the route we have created by starting the application. When the browser requests the default URL (http://localhost:<port>/) the application will return a 404 – Not Found response. This is because we have not created a route for this URL yet – we only support the format {controller}/{action} – to test this kind of URL out, navigate to ~/Home/Index. You can see the result that the application generates in Figure 11-2.
Figure 11-2. Manually testing a URL pattern
Our URL pattern has processed the URL and extracted a value for the controller
variable of Home and for the action variable as Index. The MVC framework maps this request to the Index method of the Home controller, which we created for us when we selected the Internet Application MVC project template.
And so we have created our first route and used it to process an incoming URL. IN the following sections we’ll show you how to create more complex routes and thereby create richer and more flexible URL schemas for your MVC applications.
Defining Default Values
The reason that we got an error when we requested the default URL for the application is that it didn’t match the route we had defined. The default URL is expressed as ~/ to the routing system, and so there are no segments that can be matched to the controller and action variables.
We explained earlier that URL patterns are conservative, in that they will only match URLs with the specified number of segments. We also said that this was the default behavior –
one way to change this is to use default values. A default value is applied when the URL
doesn’t contain a segment that can be matched to the value. Listing 11-4 provides an example of a route that contains a default value.
Listing 11-4. Providing a default value in a Route public static void RegisterRoutes(RouteCollection routes) {
routes.MapRoute("MyRoute", "{controller}/{action}", new { action = "Index" });
}
Default values are supplied as properties in an anonymous type – in the listing we have provided a default value of Index for the action variable. This route will match all two-segment URLs as it did previously – so, for example, if the URL http://mydomain.com/Home/Index is requested, the route will extract Home as the value for the controller and Index as the value for the action. But now that we have provided a default value for the action segment, the route will also match single segment URLs as well – when processing the URL, the routing system will extract the controller value from the sole URL segment, and use the default value for the action variable. In this way, we can request the URL http://mydomain.com/Home and invoke the Index action method on the Home controller.
We can go further and define URLs which don’t contain segment variables at all, relying on just the default values to identify the action and controller. The reason that we got an error when we requested the default URL for the application in the previous section is that it didn’t match the single route we had defined. The default URL is expressed as ~/ to the routing system, and so there are no segments that can be matched to the controller and action variables.
We can map the default URL using default values, as shown in Listing 11-5.
Listing 11-5. Providing Default Values in a Route
public static void RegisterRoutes(RouteCollection routes) { routes.MapRoute("MyRoute", "{controller}/{action}", new { controller = "Home", action = "Index" });
}
By providing defaults values for both the controller and action variables, we have created a route that will match URLs that have zero, one or two segments, as shown in Table 11-3.
Table 11-3. Matching URLs
Number Of Segment s
Example Maps To
0 mydomain.com controller = Home
action = Index
1 mydomain.com/Customer controller = Customer
action = Index
2 mydomain.com/Customer/List controller = Customer
action = List
3 mydomain.com/Customer/List/All No match – too many segments
The fewer segments we receive in the incoming URL, the more we rely on the default values. If we run the application again, the browser will request the default URL once more, but this time our new route will take effect and add our default values for the controller and action, allowing the incoming URL to be mapped to the Index action in the Home controller, as shown by Figure 11-3.
Figure 11-3. Adding a route for the default URL