37

I have a very simple test in a test project in a solution using ASP MVC V5 and attribute routing. Attribute routing and the MapMvcAttributeRoutes method are part of ASP MVC 5.

[Test]
public void HasRoutesInTable()
{
    var routes = new RouteCollection();
    routes.MapMvcAttributeRoutes();
    Assert.That(routes.Count, Is.GreaterThan(0));
}

This results in:

System.InvalidOperationException : 
This method cannot be called during the applications pre-start initialization phase.

Most of the answers to this error message involve configuring membership providers in the web.config file. This project has neither membership providers or a web.config file so the error seems be be occurring for some other reason. How do I move the code out of this "pre-start" state so that the tests can run?

The equivalent code for attributes on ApiController works fine after HttpConfiguration.EnsureInitialized() is called.

Anthony
  • 5,176
  • 6
  • 65
  • 87

4 Answers4

19

I recently upgraded my project to ASP.NET MVC 5 and experienced the exact same issue. When using dotPeek to investigate it, I discovered that there is an internal MapMvcAttributeRoutes extension method that has a IEnumerable<Type> as a parameter which expects a list of controller types. I created a new extension method that uses reflection and allows me to test my attribute-based routes:

public static class RouteCollectionExtensions
{
    public static void MapMvcAttributeRoutesForTesting(this RouteCollection routes)
    {
        var controllers = (from t in typeof(HomeController).Assembly.GetExportedTypes()
                            where
                                t != null &&
                                t.IsPublic &&
                                t.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) &&
                                !t.IsAbstract &&
                                typeof(IController).IsAssignableFrom(t)
                            select t).ToList();

        var mapMvcAttributeRoutesMethod = typeof(RouteCollectionAttributeRoutingExtensions)
            .GetMethod(
                "MapMvcAttributeRoutes",
                BindingFlags.NonPublic | BindingFlags.Static,
                null,
                new Type[] { typeof(RouteCollection), typeof(IEnumerable<Type>) },
                null);

        mapMvcAttributeRoutesMethod.Invoke(null, new object[] { routes, controllers });
    }
}

And here is how I use it:

public class HomeControllerRouteTests
{
    [Fact]
    public void RequestTo_Root_ShouldMapTo_HomeIndex()
    {
        // Arrange
        var routes = new RouteCollection();

        // Act - registers traditional routes and the new attribute-defined routes
        RouteConfig.RegisterRoutes(routes);
        routes.MapMvcAttributeRoutesForTesting();

        // Assert - uses MvcRouteTester to test specific routes
        routes.ShouldMap("~/").To<HomeController>(x => x.Index());
    }
}

One problem now is that inside RouteConfig.RegisterRoutes(route) I cannot call routes.MapMvcAttributeRoutes() so I moved that call to my Global.asax file instead.

Another concern is that this solution is potentially fragile since the above method in RouteCollectionAttributeRoutingExtensions is internal and could be removed at any time. A proactive approach would be to check to see if the mapMvcAttributeRoutesMethod variable is null and provide an appropriate error/exceptionmessage if it is.

NOTE: This only works with ASP.NET MVC 5.0. There were significant changes to attribute routing in ASP.NET MVC 5.1 and the mapMvcAttributeRoutesMethod method was moved to an internal class.

Steven
  • 241
  • 1
  • 4
  • 3
    This code has been adapted and incorporated into MvcRouteTester, here in the WebRouteTestMapper class: http://github.com/AnthonySteele/MvcRouteTester/blob/mvc5/src/MvcRouteTester/WebRouteTestMapper.cs – Anthony Nov 28 '13 at 15:25
  • I note the comment about this solution being fragile. With that said, this is not working for me on MVC 5.1.2. `mapMvcAttributeRoutesMethod` is always null. I can't find the method when manually inspecting the assembly either. Is this extension method supposed to live in `RouteCollectionAttributeRoutingExtensions` in `System.Web.Mvc`? – MEMark May 03 '14 at 10:21
  • @MEMark: Unfortunately this only works for MVC 5.0; the was moved to a different class in 5.1. A SO question dealing with a 5.1 solution can be found [here](http://stackoverflow.com/questions/22416561/how-to-find-the-right-route-in-a-routecollectionroute/22666318). – Steven May 04 '14 at 00:22
11

In ASP.NET MVC 5.1 this functionality was moved into its own class called AttributeRoutingMapper.

(This is why one shouldn't rely on code hacking around in internal classes)

But this is the workaround for 5.1 (and up?):

public static void MapMvcAttributeRoutes(this RouteCollection routeCollection, Assembly controllerAssembly)
{
    var controllerTypes = (from type in controllerAssembly.GetExportedTypes()
                            where
                                type != null && type.IsPublic
                                && type.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase)
                                && !type.IsAbstract && typeof(IController).IsAssignableFrom(type)
                            select type).ToList();

    var attributeRoutingAssembly = typeof(RouteCollectionAttributeRoutingExtensions).Assembly;
    var attributeRoutingMapperType =
        attributeRoutingAssembly.GetType("System.Web.Mvc.Routing.AttributeRoutingMapper");

    var mapAttributeRoutesMethod = attributeRoutingMapperType.GetMethod(
        "MapAttributeRoutes",
        BindingFlags.Public | BindingFlags.Static,
        null,
        new[] { typeof(RouteCollection), typeof(IEnumerable<Type>) },
        null);

    mapAttributeRoutesMethod.Invoke(null, new object[] { routeCollection, controllerTypes });
}
Seb Nilsson
  • 26,200
  • 30
  • 103
  • 130
  • 1
    Yes, and it's guaranteed to change in the next version, out soon, as routing is getting a welcome rewrite in vNext. this is why "code hacking around in internal classes" is far inferior to the front door. It is unknown if MS will be sensible enough to actually build a front door next time. They have not until now, leaving this as the only option. – Anthony Jun 28 '14 at 15:15
  • The drawback to this is that I couldn't get this to recognize areas. So a path "/admin/service/accounts" where admin is an area, was mapped to the default route so: admin = controller, service = action – a11smiles Mar 03 '15 at 18:52
5

Well, it's really ugly and I'm not sure if it'll be worth the test complexity, but here's how you can do it without modifying your RouteConfig.Register code:

[TestClass]
public class MyTestClass
{
    [TestMethod]
    public void MyTestMethod()
    {
        // Move all files needed for this test into a subdirectory named bin.
        Directory.CreateDirectory("bin");

        foreach (var file in Directory.EnumerateFiles("."))
        {
            File.Copy(file, "bin\\" + file, overwrite: true);
        }

        // Create a new ASP.NET host for this directory (with all the binaries under the bin subdirectory); get a Remoting proxy to that app domain.
        RouteProxy proxy = (RouteProxy)ApplicationHost.CreateApplicationHost(typeof(RouteProxy), "/", Environment.CurrentDirectory);

        // Call into the other app domain to run route registration and get back the route count.
        int count = proxy.RegisterRoutesAndGetCount();

        Assert.IsTrue(count > 0);
    }

    private class RouteProxy : MarshalByRefObject
    {
        public int RegisterRoutesAndGetCount()
        {
            RouteCollection routes = new RouteCollection();

            RouteConfig.RegisterRoutes(routes); // or just call routes.MapMvcAttributeRoutes() if that's what you want, though I'm not sure why you'd re-test the framework code.

            return routes.Count;
        }
    }
}

Mapping attribute routes needs to find all the controllers you're using to get their attributes, which requires accessing the build manager, which only apparently works in app domains created for ASP.NET.

dmatson
  • 6,035
  • 1
  • 23
  • 23
  • Thanks, that at least is an answer. Looks complex and intricate (and fragile) to extend this to a complete testing library though (which is what I am working on). Hopefully there's another way, or else from a testability point of view there's a poor design decision made here. Oddly, it's only web routes that are affected. ApiRoutes work fine – Anthony Nov 22 '13 at 09:37
  • Yeah, unfortunately I don't know of any alternative here. Mapping attribute routes needs to get the list of controllers (so that it can find all their attribute routes). Ideally there would be a way to replace the service that provides the controllers, but we didn't do that in this version. I filed a bug to track this problem - https://aspnetwebstack.codeplex.com/workitem/1445 – dmatson Nov 22 '13 at 18:12
  • Great option. Unfortunately, I would have to hard code much of the tests to use an independent proxy as many of the elements (RouteData, for example, to test routes) are not marked as serializable. :( – a11smiles Mar 03 '15 at 18:50
-5

What are you testing here? Looks like you are testing a 3rd party extension method. You shouldn't be using your unit tests to test 3rd party code.

ozczecho
  • 8,649
  • 8
  • 36
  • 42
  • 2
    I am testing my testing library: https://github.com/AnthonySteele/MvcRouteTester/tree/mvc5 in order to make it work for testing ASP MVC 5 websites. When you say "3rd party extension method", do you mean "MapMvcAttributeRoutes()" ? - this is not 3rd party, it is part of the ASP MVC v5 framework. – Anthony Nov 18 '13 at 09:15
  • 1
    No where in your question did you say you were writing a testing lib. I was under impression you were building a mvc web app - hence testing extension methods supplied by Mvc framework (ie 3rd party) shouldnt be done in your solution. – ozczecho Nov 28 '13 at 04:03
  • 2
    The attributes on controllers are what's really being tested. They're not shown here. MapMvcAttributeRoutes is the way that these attributes are turned into routing table data. It's assumed to work correctly, so is not under test. I gave a simplified example that captured the problem and does basically do a test on a web app. I thought that the Asp.Net framework counted as one of the first 2 parties. – Anthony Nov 29 '13 at 10:25