ASP.NET MVC Tip #29 – Build a Controller to Debug Your Custom Routes
In this tip, I demonstrate how you can create a special controller that you can use to test your custom routes. I also explain how you can give your routes back their names so you can more effectively unit test your routes.
In this tip, I demonstrate how you can create a custom controller that you can use to debug the custom routes that you add to your ASP.NET MVC applications (see Figure 1). I also explain how you can create unit tests that test your custom routes by name.
Figure 1 – Using the RouteDebugger Controller
I was inspired to undertake this project by Phil Haack’s ASP.NET Routing Debugger:
http://haacked.com/archive/2008/03/13/url-routing-debugger.aspx
However, there are some important differences between the Route Debugger that I describe in this tip and Phil’s Haack’s Route Debugger. First, I wanted to be able to test my custom routes by name. For example, if you create two new routes named MyCustomRoute1 and MyCustomRoute2, I wanted my Route Debugger to be able to tell me which of these two routes were actually called.
Second, I wanted to be able to test my custom routes when performing different types of HTTP operations. For example, I wanted to test which route is called when you perform a GET operation versus a POST operation. And, potentially, I want to be able to test routes with other types of changes in the HttpContext.
Finally, I wanted to implement my Route Debugger as a controller that I could add to any ASP.NET MVC application simply by adding a reference to the RouteDebugger assembly. After you add a reference to the RouteDebugger assembly, you can invoke the RouteDebugger with the following URL:
/RouteDebugger
Give Your Routes Back Their Names
One challenge that you quickly encounter when using URL Routing concerns route names. When you create a new route, you can supply the new route with a name. Unfortunately, however, there is no way to get a route name back again. The Route class itself does not have a Name property. Furthermore, the RouteCollection class does not enable you to retrieve a list of route names.
Because you cannot get route names back from the Route or RouteCollection classes, you cannot easily debug or test routes by name. I want my Route Debugger to be able to tell me which routes are used by name. Furthermore, I want to create unit tests that test whether a particular route with a particular name was used. Therefore, before we do anything else, we need to give our routes their names back.
The NamedRoute class in Listing 1 inherits from the Route class. The NamedRoute class simply adds a new Name property to the base Route class.
Listing 1 – NamedRoute.cs
using System.Web.Routing; namespace RouteDebugger { public class NamedRoute : Route { private string _name; public NamedRoute(string name, string url, IRouteHandler routeHandler):base(url, routeHandler) { _name = name; } public NamedRoute(string name, string url, RouteValueDictionary defaults, RouteValueDictionary constraints, IRouteHandler routeHandler) : base(url, defaults, constraints, routeHandler) { _name = name; } public NamedRoute(string name, string url, RouteValueDictionary defaults, RouteValueDictionary constraints, RouteValueDictionary dataTokens, IRouteHandler routeHandler) : base(url, defaults, constraints, dataTokens, routeHandler) { _name = name; } public string Name { get { return _name; } } } }
You don’t need to make any changes to your existing route tables in your Global.asax file to use the NamedRoute class instead of the Route class. The RouteDebugger project includes a new set of RouteCollection extension methods that sneakily replace the RouteCollection extension methods included with the ASP.NET MVC framework. See Listing 2.
Listing 2 – RouteCollectionExtensions.cs
using System.Web.Routing; using System.Web.Mvc; public static class RouteCollectionExtensions { public static void IgnoreRoute(this RouteCollection routes, string url) { routes.IgnoreRoute(string.Empty, url, null); } public static void IgnoreRoute(this RouteCollection routes, string name, string url) { routes.IgnoreRoute(name, url, null); } public static void IgnoreRoute(this RouteCollection routes, string name, string url, object constraints) { var newRoute = new RouteDebugger.NamedRoute(name, url, new StopRoutingHandler()); routes.Add(name, newRoute); } public static void MapRoute(this RouteCollection routes, string name, string url) { routes.MapRoute(name, url, null, null); } public static void MapRoute(this RouteCollection routes, string name, string url, object defaults) { routes.MapRoute(name, url, defaults, null); } public static void MapRoute(this RouteCollection routes, string name, string url, object defaults, object constraints) { var newRoute = new RouteDebugger.NamedRoute(name, url, new MvcRouteHandler()); newRoute.Defaults = new RouteValueDictionary(defaults); newRoute.Constraints = new RouteValueDictionary(constraints); routes.Add(name, newRoute); } }
The RouteDebugger project (which you can at the end of this tip) includes both the NamedRoute and RouteCollectionExtension classes. If you add a reference to the RouteDebugger assembly to an ASP.NET MVC project, then the routes in the project will be converted to instances of the NamedRoute class automatically.
Create the Route Debugger Controller
Now that we have given our routes their names again, we can create the RouteDebugger controller class. This controller class is contained in Listing 3.
Listing 3 – RouteDebuggerController.cs
using System; using System.Collections.Generic; using System.Text; using System.Web.Mvc; using System.Web.Routing; using MvcFakes; namespace RouteDebugger.Controllers { public class RouteDebuggerController : Controller { public string Index(string url, string httpMethod) { url = MakeAppRelative(url); httpMethod = httpMethod ?? "GET"; var fakeContext = new FakeHttpContext(url, httpMethod); var httpMethodOptions = formatOptions(httpMethod, new string[]{"GET","POST","PUT","DELETE","HEAD"} ); var routeDataText = GetRoutesText(fakeContext); return string.Format(htmlFormat, url, httpMethodOptions, routeDataText); } private string GetRoutesText(FakeHttpContext fakeContext) { var sb = new StringBuilder(); foreach (NamedRoute route in RouteTable.Routes) { var rd = route.GetRouteData(fakeContext); // Get match var isMatch = false; var match = rd == null ? "No Match" : "Match"; // Get values var values = "N/A"; if (rd != null) { isMatch = true; values = formatValues(rd.Values); } // Get defaults var defaults = formatValues(route.Defaults); // Get constraints var constraints = formatValues(route.Constraints); // Get dataTokens var dataTokens = formatValues(route.DataTokens); // Create table row var row = formatRow(isMatch, match, route.Name, route.Url, defaults, constraints, dataTokens, values); sb.Append(row); } return sb.ToString(); } private string formatValues(RouteValueDictionary values) { if (values == null) return "N/A"; var col = new List<String>(); foreach (string key in values.Keys) { object value = values[key] ?? "[null]"; col.Add(key + "=" + value.ToString()); } return String.Join(", ", col.ToArray()); } private string formatOptions(string selected, string[] values) { var sb = new StringBuilder(); foreach (string value in values) { var showSelected = String.Empty; if (value == selected) showSelected = "selected='selected'"; sb.AppendFormat("<option value='{0}' {1}>{0}</option>", value, showSelected); } return sb.ToString(); } private string formatRow(bool hilite, params string[] cells) { var sb = new StringBuilder(); sb.Append(hilite ? "<tr class='hilite'>":"<tr>"); foreach (string cell in cells) sb.AppendFormat("<td>{0}</td>", cell); sb.Append("</tr>"); return sb.ToString(); } private string MakeAppRelative(string url) { if (!url.StartsWith("~")) { if (!url.StartsWith("/")) url = "~/" + url; else url = "~" + url; } return url; } const string htmlFormat = @" <html> <head> <title>Route Debugger</title> <style type='text/css'> table {{ border-collapse:collapse }} td {{ font:10pt Arial; border: solid 1px black; padding:3px; }} .hilite {{background-color:lightgreen}} </style> </head> <body> <form action=''> <label for='url'>URL:</label> <input name='url' size='60' value='{0}' /> <select name='httpMethod'> {1} </select> <input type='submit' value='Debug' /> </form> <table> <caption>Routes</caption> <tr> <th>Matches</th> <th>Name</th> <th>Url</th> <th>Defaults</th> <th>Constraints</th> <th>DataTokens</th> <th>Values</th> </tr> {2} </table> </body> </html> "; } }
There are four special things about the RouteDebugger controller. First, it takes advantage of a class named the FakeHttpContext class from a project called MvcFakes. I’ve used the MvcFakes project in a number of my previous tips to fake such ASP.NET MVC instrincis as the HttpContext, ControllerContext, and ViewContext.
Second, notice that the RouteDebugger controller takes advantage of the NamedRoute class that I described in the previous section. The RouteTable.Routes collection represents a collection of NamedRoutes rather than Routes.
Third, notice that the RouteDebugger enables you to pick an HTTP method when testing a route (see Figure 2). For example, you can debug the routes that are called when performing a GET versus a POST operation.
Figure 2 -- Selecting an HTTP Method
Finally, notice that the RouteDebugger does not depend on an external view. The Index() method simply returns one gigantic string. That way, you don’t need to add a special view to an existing ASP.NET MVC application to use the RouteDebugger controller. An additional benefit is that the Route Debugger is View Engine agnostic. I got this idea from looking at the code for Phil Haack’s RouteDebugger.
After you add a referecne to the RouteDebugger assembly, you can invoke the RouteDebugger by entering the following URL into your browser:
/RouteDebugger
(Of course, this will only work if your MVC application includes the Default route or a custom route that points at the RouteDebugger).
If you enter a URL into the URL input field and hit the Debug button, the RouteDebugger will display a list of all routes configured in the current application. The RouteDebugger indicates the routes that the URL (and HTTP Method) matches. The first route matched will be the route that is actually used.
Unit Testing Routes by Name
Now that we have given our custom routes their names back, we can build unit tests that test routes by name. For example, the unit test class in Listing 4 contains two unit tests.
Listing 4 – RouteTest.cs
using System.Web.Routing; using Microsoft.VisualStudio.TestTools.UnitTesting; using MvcFakes; using RouteDebugger; using Tip29; namespace Tip29Tests.Routes { /// <summary> /// Summary description for RouteTest /// </summary> [TestClass] public class RouteTest { [TestMethod] public void TestInsertRoutePOST() { // Arrange var routes = new RouteCollection(); GlobalApplication.RegisterRoutes(routes); // Act var fakeContext = new FakeHttpContext("~/Movie/Insert", "POST"); var routeData = routes.GetRouteData(fakeContext); // Assert NamedRoute matchedRoute = (NamedRoute)routeData.Route; Assert.AreEqual("Insert", matchedRoute.Name); } [TestMethod] public void TestInsertRouteGET() { // Arrange var routes = new RouteCollection(); GlobalApplication.RegisterRoutes(routes); // Act var fakeContext = new FakeHttpContext("~/Movie/Insert", "GET"); var routeData = routes.GetRouteData(fakeContext); // Assert NamedRoute matchedRoute = (NamedRoute)routeData.Route; Assert.AreNotEqual("Insert", matchedRoute.Name); } } }
The first unit test, named TestInsertRoutePost, tests a custom route named Insert when performing a POST operation. It verifies that the Insert route is the route called when you post to the URL ~/Movie/insert.
The second unit test, named TestInsertRouteGET, verifies that the Insert route is not called when a GET operation is performed with the same URL ~/Movie/Insert. Together, these unit tests verifiy that the Insert route can be called only during a POST operation.
Summary
In this tip, I’ve demonstrated how to create a Route Debugger that you can use to debug the routes in any of your ASP.NET MVC applications. Just add a reference to the RouteDebugger.dll assembly included with the code download. I also explained how to magically replace the anonymous routes in a route table with named routes. Finally, I provided sample unit tests for testing your custom routes by name.