Testing Routes In ASP.NET MVC

asp.net, code, asp.net mvc 0 comments suggest edit

The ASP.NET Routing engine used by ASP.NET MVC plays a very important role. Routes map incoming requests for URLs to a Controller and Action. They also are used to construct an URL to a Controller/Action. In this way, they provide a two-way mapping between URLs and controller actions.

When building routes, it may be useful to write unit tests for the routes to ensure that you’ve set up the proper mappings you intend.

ScottGu touched a bit on unit testing routes in part 2 of his series on MVC in which he covers URL Routing. In this post, we’ll go into a little more depth with testing routes.

To keep it interesting, let me show you what the final unit test looks like for testing a route. That way, if you don’t care about the details, you can skip all this discussion and just download the code.

[TestMethod]
public void RouteHasDefaultActionWhenUrlWithoutAction()
{
  RouteCollection routes = new RouteCollection();
  GlobalApplication.RegisterRoutes(routes);

  TestHelper.AssertRoute(routes, "~/product"
    , new { controller = "product", action = "Index" });
}

The first part of the test simply populates a collection with routes from your Global application class defined in Global.asax.cs. The second part is a call to another helper method that attempts to map a route to the specified virtual URL and compares the route data to a dictionary of name/value pairs. The dictionary is passed in as an anonymous type using a technique that my coworker Eilon Lipton wrote about on his blog.

Let’s take a quick look at the RegisterRoutes method so you can see which routes I am testing.

public static void RegisterRoutes(RouteCollection routes) {
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    routes.MapRoute("blog-route", "blog/{year}/{month}/{day}",
        new { controller = "Blog", action = "Index", id = "" },
        new { year = @"\d{4}", month = @"\d{2}", day = @"\d{2}" }
    );

    routes.MapRoute(
        "Default",
        "{controller}/{action}/{id}",
        new { controller = "Home", action = "Index", id = "" }
    );
}

Looks like your standard set of routes, no? I threw one in there that looks like one you might use with a blog engine.

Next, I’ll show you how I would write a test the long way using MoQ.

[TestMethod]
public void CanMapNormalControllerActionRoute() {
    RouteCollection routes = new RouteCollection();
    GlobalApplication.RegisterRoutes(routes);

    var httpContextMock = new Mock<HttpContextBase>();
    httpContextMock.Setup(c => c.Request.AppRelativeCurrentExecutionFilePath)
        .Returns("~/product/list");

    RouteData routeData = routes.GetRouteData(httpContextMock.Object);
    Assert.IsNotNull(routeData, "Should have found the route");
    Assert.AreEqual("product", routeData.Values["Controller"]
        , "Expected a different controller");
    Assert.AreEqual("list", routeData.Values["action"]
        , "Expected a different action");
}

Ok, that isn’t all that bad. It may seem like a lot of code, but it’s pretty straightforward, assuming you understand the general pattern for using a mock framework.

However, we can shorten a lot of this code by using an extension method I wrote in a previous post. I actually wrote an overload that makes it easier to mock a request for a specific URL.

That gets us further, but we can do so much more. Here is the code I wrote for my AssertRoute method.

public static void AssertRoute(RouteCollection routes, string url, 
    object expectations) 
{
    var httpContextMock = new Mock<HttpContextBase>();
    httpContextMock.Setup(c => c.Request.AppRelativeCurrentExecutionFilePath)
        .Returns(url);

    RouteData routeData = routes.GetRouteData(httpContextMock.Object);
    Assert.IsNotNull(routeData, "Should have found the route");

    foreach (PropertyValue property in GetProperties(expectations)) {
        Assert.IsTrue(string.Equals(property.Value.ToString(), 
            routeData.Values[property.Name].ToString(), 
            StringComparison.OrdinalIgnoreCase)
            , string.Format("Expected '{0}', not '{1}' for '{2}'.", 
            property.Value, routeData.Values[property.Name], property.Name));
    }
}

This code makes use of the GetProperties method I lifted from Eilon’s blog post, Using C# 3.0 Anonymous types as Dictionaries.

The expectations passed to this method are the name/value pairs you expect to see in the RouteData.Values dictionary.

I hope you find this useful. The code (along with other unit test examples) are in solution ready for download.

UPDATE: I updated the blog post and solution (7/27 1:38 PM PST) to account for all the changes to routing made since I wrote this post originally.

UPDATE AGAIN: Updated the post to use MoQ 3.0 and cleaned up the code a slight bit.

UPDATE: 03/11/2012 - Fixed the broken download link. Rewrote the tests to use xUnit.NET.

Tags: ASP.NET MVC , TDD , Routing

Found a typo or error? Suggest an edit! If accepted, your contribution is listed automatically here.

Comments

avatar

29 responses

  1. Avatar for Adam Tybor
    Adam Tybor December 16th, 2007

    Nice post Phil, looks very familiar :)

  2. Avatar for Haacked
    Haacked December 17th, 2007

    Someone wrote me an email and pointed out I haven't addressed URLs with query string parameters. I'll take a look at this soon.

  3. Avatar for Haacked
    Haacked December 17th, 2007

    Actually, just found out that query string parameters are not part of routing. It's the Controller action invocation that matches query string parameters to the action method's parameters.
    So the part that is the route is everything up to the query string question mark. Make sense?

  4. Avatar for Christopher Steen
    Christopher Steen December 17th, 2007

    Sharepoint SLEEPLESS ROADSHOW – The Ultimate Office Dev Weekend [Via: Public Sector DPE Team ] WPF ...

  5. Avatar for Haacked
    Haacked December 18th, 2007

    Updated my samples to better handle querystrings. Thanks David!

  6. Avatar for DotNetKicks.com
    DotNetKicks.com December 18th, 2007

    You've been kicked (a good thing) - Trackback from DotNetKicks.com

  7. Avatar for Community Blogs
    Community Blogs December 18th, 2007

    Finally some time for .net. Since Scott and Phil started writing about is I wanted to read and try out

  8. Avatar for Vijay Santhanam
    Vijay Santhanam January 1st, 2008

    I found this test approach mightily useful for testing routes and I'll keep this on my tool-belt for my next MVC project.

  9. Avatar for My World
    My World January 3rd, 2008

    After a great start to the new year, I've taken the first three days back at work as PD days to catch

  10. Avatar for Lance Fisher
    Lance Fisher January 6th, 2008

    Hi Phil, thanks for this I've been able to start testing routes like this, and I really like it.
    I'm wondering why in your SetMockedRequestUrl() method you disallow using domains in the urls. You throw an error if the url does not start with "~/". Is this a limitation of the routing engine? I am trying to set up a site that has routing similar Amazon S3. That is, both ~/mysite/blog and mysite.com/blog will route to the same place. The routing should allow for any number of mysite's to be created. For development, I've added several entries to my hosts file to route a couple different domains to my app.
    Thanks for any tips.

  11. Avatar for Community Blogs
    Community Blogs February 3rd, 2008

    I did a couple user group presentations this month on the new ASP.NET MVC framework. This post is a follow

  12. Avatar for Travis
    Travis July 16th, 2008

    I hope someone reads this.. but where are these interfaces defined? I cannot find them anywhere (well my project cant find them referenced anywhere):
    IHttpContext
    IHttpRequest
    IHttpResponse
    Specifically when used here in MvcMockHelpers.cs:
    IHttpContext context = mocks.DynamicMock<ihttpcontext>();
    IHttpRequest request = mocks.DynamicMock<ihttprequest>();
    IHttpResponse response = mocks.DynamicMock<ihttpresponse>();
    I am using .NET 3.5, Asp.Net Preview 4.

  13. Avatar for Art
    Art January 18th, 2010

    Hi Phil,
    The TddDemo.zip appears to be corrupted. I cannot open it using anu of my compression utilities (Windows Explorer, WinZip, 7-Zip). Any chance you have a copy you could refresh?
    Thanks!

  14. Avatar for Paul Wallace
    Paul Wallace January 18th, 2010

    Same here, The TddDemo.zip appears to be corrupted.

  15. Avatar for BjartN
    BjartN February 10th, 2010

    File still corrupt..

  16. Avatar for Sayed Ibrahim Hashimi
    Sayed Ibrahim Hashimi March 13th, 2010

    Looks like the .zip file is still invalid. Just downloaded it, and bang!

  17. Avatar for Scott McNeany
    Scott McNeany June 2nd, 2010

    This is very similar to the testing feature in Pro Asp.Net MVC book, but I'm having some trouble testing ALL of my routes since some are defined in Areas, not in Global.asax.cs.
    I run the AreaRegistration.RegisterAllAreas() method in the Application_Start, not in the RegisterRoutes() method in my application. If I move the call inside RegisterRoutes() or call it from my test method, I can an error stating:
    Test method purchase.web.test.RoutingTests.TestQuoteNoProductGroup threw exception: System.Web.HttpException: The type initializer for 'System.Web.Compilation.CompilationLock' threw an exception. ---> System.TypeInitializationException: The type initializer for 'System.Web.Compilation.CompilationLock' threw an exception. ---> System.NullReferenceException: Object reference not set to an instance of an object..
    Any thoughts on this error and how I can properly test the routes defined in the other areas?

  18. Avatar for Oleg D.
    Oleg D. June 8th, 2010

    In case somebody needs it, here is how GetProperties() can be implemented:
    private static Dictionary<string, object> GetProperties(object values)
    {
    var result = new Dictionary<string, object>();
    if (values != null)
    {
    foreach (PropertyDescriptor descriptor in TypeDescriptor.GetProperties(values))
    {
    object obj2 = descriptor.GetValue(values);
    result.Add(descriptor.Name, obj2);
    }
    }
    return result;
    }

  19. Avatar for Donn Felker
    Donn Felker December 30th, 2010

    Hey Phil,
    The download link on this page is broken. It goes to: http://haacked.com/code/TddDemo.zip which is 404'd.
    Thanks
    D

  20. Avatar for Michael
    Michael January 24th, 2012

    Thanks for the tip. This worked well. The only problem I ran into was I use HttpMethodConstraint. To make this work with unit testings, I had to mock the Request.HttpMethod with this Moq line
    httpContextMock.Setup(c => c.Request.HttpMethod).Returns("POST");

  21. Avatar for Bob
    Bob February 24th, 2012

    Dude sort out your dead links ffs, re: the download!

  22. Avatar for haacked
    haacked March 11th, 2012

    Bob, the link is fixed.

  23. Avatar for Dan Schlossberg
    Dan Schlossberg June 20th, 2012

    Hey Phil,
    I'm impressed that you still keep up a 5 year old blog post, Thx!
    Not sure what RTM of mvc4 routes will look like but I started a project on beta and am now on RC. Plus I'm using WebApi and areas, so I had some challenges extending your example to my situation which registers a web api in an area.
    Here is what worked for me. (The "arrange" portion of test)


    var importAreaContext = new AreaRegistrationContext("Import", RouteTable.Routes);
    var importAreaReg = new ImportAreaRegistration();
    importAreaReg.RegisterArea(importAreaContext);

    MvcApplication.RegisterRoutes(RouteTable.Routes);


  24. Avatar for Tim Bourguignon
    Tim Bourguignon July 19th, 2012

    I've been using Phil's method for a while until I felt the urge to actually check if a controller or an action really exists (for instance when another developper renames an action... hum...). In that case, one of the "quite very generic" routes was still happy about the URL although the action did not exist anymore and my test was passing although it should not have.
    I switched to MvcContrib to perform my testing and which works with objects instead of strings (and thus triggers a compiler error if a controller or action does not exist). See the examples here.

  25. Avatar for Justin Holzer
    Justin Holzer January 6th, 2013

    While I like the idea of being able to unit test routes, wouldn't the type of unit test described in this post be extremely brittle? The examples appear to have a dependency on the implementation of the RouteCollection class.

    For instance, this code is assuming that the GetRouteData method of RouteCollection is calling Request.AppRelativeCurrentExecutionFilePath in HttpContextBase:

    var httpContextMock = new Mock<httpcontextbase>();
    httpContextMock.Setup(c => c.Request.AppRelativeCurrentExecutionFilePath)
    .Returns(url);

    RouteData routeData = routes.GetRouteData(httpContextMock.Object);Granted, the implementation of the MVC framework is not something that will be changing all that often. Even so, isn't this crossing unit test boundaries? Wouldn't this be better served with an integration test or is this just one of those cases where folks feel it's OK to bend the rules a bit because of the added value (being able to quickly validate your routes)?

  26. Avatar for haacked
    haacked January 7th, 2013

    There is a bit of white-box testing going on here for sure. While you might argue that this is really an integration test because of that, I felt like it's still fine to call it a unit test because it's fast, it doesn't require loading in any of the ASP.NET runtime, the specific implementation behavior I rely on is (as you point out) probably _NEVER_ going to change, and it provides value to have as part of your unit test suite.

    A lot of projects I know don't bother with the automated Integration tests (egad!). If you do have integration tests, then feel free to move these into that suite. :)

  27. Avatar for movgp0
    movgp0 June 7th, 2013

    I have the same problem. did not figure it out yet.

  28. Avatar for andrewgunn
    andrewgunn June 11th, 2013

    Great post.

    Might be worth adding an assertion on the actual route data count against the expected. Just a thought!

  29. Avatar for HojjatK
    HojjatK September 9th, 2013

    Awesome, this fixed my unit testing issue:)
    I was registering area routes after MVCApplication.RegisterRoutes and Area's route was not working,

    I figured out the order of registration is important and I don't know why!