Fluent Route Testing in ASP.NET MVC

Have you written code like this recently?

 

[Test]

public void tedious_route_test()

{

    Global.RegisterRoutes(RouteTable.Routes);

    var httpContext = MockRepository.GenerateStub<HttpContextBase>();

    httpContext.Stub(x => x.Request).Return(MockRepository.GenerateStub<HttpRequestBase>());

    httpContext.Request.Stub(x => x.PathInfo).Return("");

    httpContext.Request.Stub(x => x.AppRelativeCurrentExecutionFilePath).Return("~/foo/bar");

 

    var routeData = RouteTable.Routes.GetRouteData(httpContext);

 

    Assert.That(routeData.Values["controller"], Is.EqualTo("foo"));

    Assert.That(routeData.Values["action"], Is.EqualTo("bar"));

}

This mess of nastiness is what is required to test your routes in ASP.NET MVC.  There are a number of things wrong with this.  First and foremost, we're doing magical-mocking here.  That is - we somehow know exactly the two properties we need to mock for this to work.  It turns out this isn't such a magical process as it is looking at reflector and a lot of trial and error.  The next thing wrong that I see is that our route values (and keys) are case-insensitive, so we'd need to include some more flexibility in our Asserts here.

Who wants to deal with that mess?  I sure don't.  Last night, my pal & co-author Jimmy Bogard banged together a fluent API for testing routes without needing all of this crap.

Here is a test to verify our root route is working properly:

"~/".Route().ShouldMapTo<FooController>();

And a more complex case where we have an action parameter:

"~/foo/bar/widget".Route().ShouldMapTo<FooController>(x => x.Bar("widget"));

These are a lot nicer, as we are dealing with no unnecessary string here, the controller are now strongly-typed (we get intellisense and refactoring support), and we can easily test routes with a single line.

This does have a couple caveats though:  it assumes

  • you're using NUnit
  • you're using RhinoMocks
  • you add your routes to the RouteTable.Routes collection

So how did we do this?  I have to admit, I tried to get this to work on the bus ride to work, but without an internet connection I was dead in the water.  Jimmy helped me grasp the way that Expression<T> works.  His wise words were: "Here's where you just set a breakpoint and fire up the debugger and look."  He's right - the API there is quite opaque.  But once I got it to  work it made a lot of sense.  Expression<T> is awesome!

Here's the method with most of the goods:

public static RouteData ShouldMapTo<TController>(this RouteData routeData, Expression<Func<TController, ActionResult>> action)

    where TController : Controller

{           

    Assert.That(routeData, Is.Not.Null, "The URL did not match any route");

 

    //check controller

    routeData.ShouldMapTo<TController>();

 

    //check action

    var methodCall = (MethodCallExpression) action.Body;

    string actualAction = routeData.Values.GetValue("action").ToString();

    string expectedAction = methodCall.Method.Name;

    actualAction.AssertSameStringAs(expectedAction);

 

    //check parameters

    for (int i = 0; i < methodCall.Arguments.Count; i++)

    {

        string name = methodCall.Method.GetParameters()[i].Name;

        object value = ((ConstantExpression) methodCall.Arguments[i]).Value;

 

        Assert.That(routeData.Values.GetValue(name), Is.EqualTo(value.ToString()));

    }

 

    return routeData;

}

This might be a tad brittle, but in my preliminary testing it worked wonders.  This code is now in MvcContrib's TestHelper project. 

Technorati Tags:
#1 Ze Pedro avatar
Ze Pedro
11.26.2008
4:06 AM

Awesome! Thank you so much for finally allowing me to grasp Expression<T>.

You made my day! Many thanks from Portugal.

Keep up the good work!

Ze


#2 Eric Hexter avatar
Eric Hexter
11.26.2008
8:03 AM

Looks good.... It is nice to have helpers which make the framework easy to test rather than just be testable.


#3 Jimmy Bogard avatar
Jimmy Bogard
11.26.2008
5:49 PM

Small suggestion - instead of "routeString".Route().ShouldMapTo..., maybe Route.For("routeString").ShouldMapTo...? Starting an fluent interface with a primitive expression can look a little funky.


#4 Aaron Jensen avatar
Aaron Jensen
11.30.2008
3:32 AM

This is great, thanks. I think Jimmy's high though (share? ;) ) and you should instead just do "routeString".ShouldMapTo(...).

Of course that's an easy thing for me to add, so I will when I use this. It's just nice to get rid of the noise.


#5 Ryan avatar
Ryan
12.03.2008
8:48 AM

I'm not seeing this extension in MVCContrib.TestHelper. Is it not in the current release build?


#6 benscheirman avatar
benscheirman
12.03.2008
8:49 AM

You'll have to pull down the latest trunk to see it.


#7 Ryan avatar
Ryan
12.03.2008
8:57 AM

Ah.... and the source tree is now on Google Code?


#8 benscheirman avatar
benscheirman
12.03.2008
9:39 AM

Yes, here: code.google.com/.../checkout


#9 WebDevVote avatar
WebDevVote
12.03.2008
10:18 AM

You're been voted!!

Track back from webdevvote.com/.../Fluent_Route_Te


#10 Stuart Thompson avatar
Stuart Thompson
12.03.2008
11:13 AM

Ben,

Thank you. This is a great testing tool for ASP.NET MVC routes and certainly makes unit testing those routes much more compact and elegant; always a good thing!

-- Stu


#11 Fotowho avatar
Fotowho
2.26.2009
6:09 PM

Hello! I installed the RC1 MVC and I MvcApplication Within "Shared Sub RegisterRoutes(ByVal routes As RouteCollection)" and it serves me to this: "http://domain.com/Home/Details/222" But I'm not used to "http://222.domain.com/Details" I tried with the wrench:

Dim url As String = HttpContext.Current.Request.Headers("HOST")

Dim index As String = url.IndexOf(".")

Dim subdomain As String = String.Empty

If index > 0 Then

subdomain = url.Substring(0, index)

End If

If subdomain <> String.Empty Then

routes.MapRoute( _

"Sub", _

"{controller}/{action}/{id}", _

New With {.controller = "Home", .action = "Index", .id = subdomain} _

)

End If

routes.MapRoute( _

"Default", _

"{controller}/{action}/{id}", _

New With {.controller = "Home", .action = "Index", .id = ""} _

)

I am badly designed, could do as well?

Than You.


#12 Elliott O'Hara avatar
Elliott O'Hara
4.20.2009
3:28 PM

Totally agree with Jimmy Bogard. Extending System.String isn't a very good approach (IMO).

I just don't like String.Route(). Means nothing to anything but Routes, so extend Route.


#13 benscheirman avatar
benscheirman
4.20.2009
3:36 PM

@Elliott - the code no longer has the .Route() intermediate step. It is extended directly from string, like so:

"~/foo".ShouldMapTo<FooController>()


#14 Liam McLennan avatar
Liam McLennan
5.05.2009
5:31 AM

I think there might be a problem for overloaded actions. I have two actions called Search and if I put the wrong one in ShouldMapTo I get 'Object reference not set to an instance of an object' at MvcContrib.TestHelper.GeneralTestExtensions.ShouldEqual(Object actual, Object expected, String message).

It should fail it is just that the error message is not great.


#15 benscheirman avatar
benscheirman
5.05.2009
7:06 AM

@Liam: You're right the error message should be better. Want to submit a patch?