MvcSitemaps Vs T4MVC and test for all controllers

One of the tasks have performed lately in our massive web-application is restructuring menu. And for the menu to work correctly we had to make sure that every page is somewhere on the menu.

For Menu generation we use MvcSitemapProvider. And for strongly-typed references to our controllers/actions we generate static classes via T4MVC. Task of making sure that every controller action (out of ~600) has a SiteMapAttribute is very tedious. And with development of new features, this can easily be forgotten, leading to bugs in our menu. So we decided to write a test. This turned out to be yet another massive reflection exercise and it took a while to get it correctly. So I’d like to share this with you.

Theory of the test are simple – find all controllers, on every controller find all the methods and check that every method has a custom attribute of type
MvcSiteMapNodeAttribute. But in practice this was more complex because we used T4MVC.

The way T4MVC works is making your controllers as partial classes and adds second part of the controller somewhere. And in the second partial class it adds a load of methods. The actual controller looks like this:

namespace MyAPp.Web.Areas.Core.Controllers
{
    [Authorize(Roles = MembershipRoles.Administrator)]
    public partial class AdminMenuController : CoreController
    {
        [MvcSiteMapNode(Title = "Admin", ParentKey = SiteMapKeys.Home.Root, Key = SiteMapKeys.Home.AdminTop, Order = 999)]
        public virtual EmptyResult Menu()
        {
            return new EmptyResult();
        }
    }
}

and these are part of generated code produced by T4MVC:

    [GeneratedCode("T4MVC", "2.0"), DebuggerNonUserCode]
    protected RedirectToRouteResult RedirectToActionPermanent(ActionResult result)
    {
        var callInfo = result.GetT4MVCResult();
        return RedirectToRoutePermanent(callInfo.RouteValueDictionary);
    }

    [NonAction]
    [GeneratedCode("T4MVC", "2.0"), DebuggerNonUserCode]
    public virtual System.Web.Mvc.JsonResult LargeJson()
    {
        return new T4MVC_System_Web_Mvc_JsonResult(Area, Name, ActionNames.LargeJson);
    }
    [NonAction]
    [GeneratedCode("T4MVC", "2.0"), DebuggerNonUserCode]
    public virtual System.Web.Mvc.ActionResult RedirectToPrevious()
    {
        return new T4MVC_System_Web_Mvc_ActionResult(Area, Name, ActionNames.RedirectToPrevious);
    }
    [NonAction]
    [GeneratedCode("T4MVC", "2.0"), DebuggerNonUserCode]
    public virtual System.Web.Mvc.ActionResult BackToList()
    {
        return new T4MVC_System_Web_Mvc_ActionResult(Area, Name, ActionNames.BackToList);
    }

See these methods? they are not even defined on the controller I’m looking at at the moment. These are all parts of base abstract controller that is a parent for all our controllers.

And when you run your reflection, all these methods pop up as actions. And for our test this is just a noise. Must be filtered out. Notice how generated code is marked with [GeneratedCode] attribute. This is what we are going to use to filter these methods out of our test.

After a long messing about with reflection I came up with this test. I hope this is mostly self-explanatory and comments also help with the reasoning.

    // controller exclusion list
    private static readonly List<Type> ExcludedControllers = new List<Type>()
                                                        {
                                                            typeof(HelpController),
                                                            typeof(HomePageController),
                                                        };

    // ActionRestyle types that should be excluded from the test
    // If action returns only partial view result, it should not have MvcSiteMapAttribute
    private static readonly List<Type> ExcludedReturnTypes = new List<Type>()
                                                        {
                                                            typeof(PartialViewResult),
                                                            typeof(JsonResult),
                                                            typeof(FileResult),
                                                        };


    [Fact] // using xUnit
    public void ControllerActions_Always_HaveBreadcrumbAttribute()
    {
        var errors = new List<string>();

        // excude controllers that should not be testsed
        var controllerTypes = GetControllerTypes().Where(ct => !ExcludedControllers.Contains(ct));

        foreach (var controllerType in controllerTypes)
        {
            // get all public action in the controller type
            var offendingActions = controllerType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
                // filter out all NonActions
                .Where(m => !m.IsDefined(typeof(NonActionAttribute)))
                // filter out all T4MVC generated code
                .Where(m => !m.IsDefined(typeof(GeneratedCodeAttribute)))
                // T4MVC adds some methods that don't return ActionResult - kick them ot as well
                .Where(m => typeof(ActionResult).IsAssignableFrom(m.ReturnType))
                // if action is Post-only, we don't want to apply Sitemap attribute
                .Where(m => !m.IsDefined(typeof(HttpPostAttribute)))
                // and now show us all the actions that don't have SiteMap attributes - that's what we want!
                .Where(m => !m.IsDefined(typeof(MvcSiteMapNodeAttribute)))
                // excluding types of actions that return partial views or FileResults - see filter list above
                .Where(m => !ExcludedReturnTypes.Contains(m.ReturnType))
                .ToArray();

            // add all the offending actions into list of errors
            errors.AddRange(offendingActions.Select(action => String.Format("{0}.{1}", controllerType.Name, action.Name)));
        }

        // Assert
        if (errors.Any())   // if anything in errors  - print out the names
        {
            Console.WriteLine("Total number of controller actions without SiteMapAttribute: {0}", errors.Count);
            var finalMessage = String.Join(Environment.NewLine, errors);
            Console.WriteLine(finalMessage);
        }
        Assert.Empty(errors); // fail the test if there are any errors.
    }


    // return all types of controller in your application
    public static IEnumerable<Type> GetControllerTypes()
    {
        // MvcApplication type is defined in your Global.asax.cs
        return Assembly.GetAssembly(typeof(MvcApplication))
            .GetTypes()
            .Where(t => !t.IsAbstract && t.IsSubclassOf(typeof(Controller)))
            .Where(t => t.Namespace != null && !t.Name.Contains("T4MVC"))
            .ToList();
    }
}