CS 462

Unit Testing Routes


Introduction

It is important to unit test all components of your MVC application. This includes testing that the routes you have set up are in fact being used as you intended. That is, the route you have created (e.g. in RouteConfig.cs) correctly causes certain URL's to route to the controller and action method that you intended. These are incoming routes. You also should test outgoing routes, since these are used every time you write a @Html.ActionLink() in a Razor view.

We'll describe two methods of testing routes.

URL Routing: Chapter 15 in Pro ASP.NET MVC 5 by Adam Freeman

If you haven't already, create a new project within your Visual Studio solution. This project is just for Unit testing. As shown in class, use NuGet to install NUnit, NUnit3TestAdapter and Moq. We will use NUnit rather than the built-in MSTest. Remove the using statements for MS Test and the respective package statements in packages.config. Also, add a reference to your main project.

The book covers this very well so I'll leave it for you to read. The only difficulty involves Mocking the routing features of the MVC framework. The book doesn't use NUnit so you'll have to convert a few things to NUnit syntax. Here's a sample in NUnit syntax:

using System;
using Moq;
using NUnit.Framework;
using System.Reflection;
using System.Web;
using System.Web.Routing;
using Standups;        // The main project name

namespace Tests
{
    [TestFixture]
    public class TestRoutes
    {
        // TODO: Move this functionality to its own base class, separate from each test class
        private HttpContextBase CreateHttpContext(string targetUrl = null, string httpMethod = "GET")
        {
            // straight from book
        }

        private void TestRouteMatch(string url, string controller, string action,
                                    object routeProperties = null, string httpMethod = "GET")
        {
            // straight from book
        }

        private bool TestIncomingRouteResult(RouteData routeResult, string controller, 
                                             string action, object propertySet = null)
        {
            // straight from book
        }

        private void TestRouteFail(string url)
        {
            // straight brom book
        }

        [SetUp]
        public void SetUp()
        {
            // do any one time setup here
        }

        [OneTimeSetUp]
        public void RunThisBeforeEveryTestMethod()
        {
            // or setup prior to every test method
        }

        [TearDown]
        public void TearDown()
        {
            // clean up so future tests have a clean environment to run in
        }

        [Test]
        public void DefaultURL_ShouldMapTo_Home_Index()
        {
            TestRouteMatch("~/", "Home", "Index");
        }
        
        [Test]
        public void DefaultURLWithHome_ShouldMapTo_Home_Index()
        {
            TestRouteMatch("~/Home", "Home", "Index");
        }

        [Test, Description("About this application page")]
        public void AboutOrHomeAbout_ShouldMapTo_Home_About()
        {
            TestRouteMatch("~/About", "Home", "About");
            TestRouteMatch("~/Home/About", "Home", "About");
        }

        [Test]
        public void ContactOrHomeContact_ShouldMapTo_Home_Contact()
        {
            TestRouteMatch("~/Contact", "Home", "Contact");
            TestRouteMatch("~/Home/Contact", "Home", "Contact");
        }

        [Test]
        public void MyAccount_ShouldMapTo_MyAccount_Index()
        {
            TestRouteMatch("~/MyAccount", "MyAccount", "Index");
        }

        [Test]
        public void MyAccountSelectGroup_ShouldMapTo_MyAccount_SelectGroup_GET()
        {
            TestRouteMatch("~/MyAccount/SelectGroup", "MyAccount", "SelectGroup");
        }
        
        [Test]
        public void MyAccountSelectGroup_ShouldMapTo_MyAccount_SelectGroup_POST()
        {
            TestRouteMatch("~/MyAccount/SelectGroup/2", "MyAccount", "SelectGroup", new { id = "2" }, "POST");
        }
    }
}

This works fine for public facing routes and may work for routes that require authentication. It does not work if you're using Areas though. For that I can confirm that another package does work, even for an Admin area.

After digging deeper into the code above I have some observations:

  1. The books code won't handle query strings
  2. It also does not check to see if you have a controller and action method at the destination to be the target of this route!
  3. It only checks that the route you intend is correctly present in the route table and that it would go to the given controller and action method if it were present
  4. Both of these problems are taken care of in the testing library presented below

Unit Testing Routes with MvcRouteTester

A very nice package that allows you to test routes using an expressive syntax is MvcRouteTester. This one takes a bit more to set up so only use this method if you're using Areas to separate your Controllers and Views into nicely organized folders and namespaces (it's really nice!).

Begin by installing (with NuGet) the following into your Test project. Rather than list them individually I'll just show you the resulting packages.config. You don't have to separately install every one as some of them install their own dependencies. Also, add a reference to your main project.

<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="Castle.Core" version="4.0.0" targetFramework="net452" />
  <package id="Microsoft.AspNet.Identity.Core" version="2.0.0" targetFramework="net452" />
  <package id="Microsoft.AspNet.Mvc" version="5.2.3" targetFramework="net452" />
  <package id="Microsoft.AspNet.Razor" version="3.2.3" targetFramework="net452" />
  <package id="Microsoft.AspNet.WebApi.Client" version="5.2.3" targetFramework="net452" />
  <package id="Microsoft.AspNet.WebApi.Core" version="5.2.3" targetFramework="net452" />
  <package id="Microsoft.AspNet.WebPages" version="3.2.3" targetFramework="net452" />
  <package id="Microsoft.Web.Infrastructure" version="1.0.0.0" targetFramework="net452" />
  <package id="Moq" version="4.7.8" targetFramework="net452" />
  <package id="MvcRouteTester.Mvc5.2" version="2.0.1" targetFramework="net452" />
  <package id="Newtonsoft.Json" version="7.0.1" targetFramework="net452" />
  <package id="NUnit" version="3.6.1" targetFramework="net452" />
  <package id="NUnit3TestAdapter" version="3.7.0" targetFramework="net452" />
</packages> 
            

Next up, we need some classes to set things up. First is a class to integrate NUnit into MvcRouteTester:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NUnit.Framework;
using MvcRouteTester.Assertions;

namespace Tests
{
    // Class for using MVCRouteTester within NUnit
    public class NunitAssertEngine : IAssertEngine
    {
        [System.Diagnostics.DebuggerNonUserCode]
        public void Fail(string message)
        {
            Assert.Fail(message);
        }

        [System.Diagnostics.DebuggerNonUserCode]
        public void StringsEqualIgnoringCase(string s1, string s2, string message)
        {
            if (string.IsNullOrEmpty(s1) && string.IsNullOrEmpty(s2))
            {
                return;
            }
            StringAssert.AreEqualIgnoringCase(s1, s2, message);
        }
    }
}

Now a couple of base classes. You can extend these to implement tests for either routes in areas or regular routes. I haven't yet figured out how to mix the two of them.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Web.Mvc;
using System.Web.Routing;
using NUnit.Framework;
using MvcRouteTester;

namespace Tests
{
    // See: https://github.com/anthonysteele/mvcroutetester/issues/20

    // Class to apply mvcroutetester to area routing. Extend this class to test routes
    // in an area
    public abstract class AreaRouteFactsBase<T> where T : AreaRegistration, new()
    {
        public RouteCollection Routes { get; private set; }

        public AreaRouteFactsBase()
        {
            // Arrange
            Routes = new RouteCollection();
            T area = new T();
            AreaRegistrationContext context = new AreaRegistrationContext(area.AreaName, Routes);
            area.RegisterArea(context);
        }
    }

    // Class to make writing test classes easier.  Extend this to test routes that 
    // aren't in an area
    public class TestRoutesBase
    {
        public RouteCollection Routes;

        //public TestRoutesBase()
        [SetUp]
        public void SetUp()
        {
            Routes = RouteTable.Routes;
            Standups.RouteConfig.RegisterRoutes(Routes);

            RouteAssert.UseAssertEngine(new NunitAssertEngine());
        }

        //public void Dispose()
        [TearDown]
        public void TearDown()
        {
            RouteTable.Routes.Clear();
        }
    }
}

Now for some actual unit test classes. These are from my example project for Stand-Up meeting reports, called Standups. These are analogous to those tested above using the book's code. Notice that these don't need the ~ for the base url.

using NUnit.Framework;
using System;
using MvcRouteTester.Assertions;
using MvcRouteTester;
using System.Web.Routing;
using Standups;
using Standups.Controllers;
using System.Web.Mvc;

namespace Tests
{
    [TestFixture]
    public class TestPublicRoutes : TestRoutesBase
    {
        [SetUp]
        public void SetupRouteTests()
        {}

        [Test]
        public void DefaultOverall_ShouldMapToHome_Index()
        {
            Routes.ShouldMap("/").To<HomeController>(x => x.Index());
        }

        [Test]
        public void DefaultHome_ShouldMapToHome_Index()
        {
            Routes.ShouldMap("/Home").To<HomeController>(x => x.Index());
        }

        [Test]
        public void DefaultHomeIndex_ShouldMapToHome_Index()
        {
            Routes.ShouldMap("/Home/Index").To<HomeController>(x => x.Index());
        }

        [Test]
        public void Contact_ShouldMapToHome_Contact()
        {
            Routes.ShouldMap("/Contact").To<HomeController>(x => x.Contact());
        }

        [Test]
        public void ContactPlusAnything_ShouldMapToHome_Contact()
        {
            Routes.ShouldMap("/Contact/something/else").To<HomeController>(x => x.Contact());
            Routes.ShouldMap("/contact/aalk8_literally/any/length/url?q=45").To<HomeController>(x => x.Contact());
        }

        [Test]
        public void About_ShouldMapToHome_About()
        {
            Routes.ShouldMap("/About").To<HomeController>(x => x.About());
        }

        [Test]
        public void AboutPlusAnything_ShouldMapToHome_About()
        {
            Routes.ShouldMap("/About/something/else").To<HomeController>(x => x.About());
            Routes.ShouldMap("/about/aalk8_literally/any/length/url?q=45").To<HomeController>(x => x.About());
        }
        
        // Action method for this one is:
        // public ActionResult IndexOther(int val, string hello)
        [Test]
        public void Test_URL_With_QueryStrings_ShouldMapTo_Parameters()
        {
            Routes.ShouldMap("/Home/IndexOther?val=5&hello=five").To<HomeController>(x => x.IndexOther(5,"five"));
        }
    }
}

For testing routes in an area:

using NUnit.Framework;
using System;
using MvcRouteTester.Assertions;
using MvcRouteTester;
using System.Web.Routing;
using Standups;
using Standups.Controllers;
using System.Web.Mvc;
using Standups.Areas.Admin.Controllers;

namespace Tests
{
    // Notice the parameterized type is from the Admin area in my main project
    [TestFixture]
    public class TestAdminRoutes : AreaRouteFactsBase<Standups.Areas.Admin.AdminAreaRegistration>
    {
        [SetUp]
        public void SetupRouteTests()
        {
            RouteAssert.UseAssertEngine(new NunitAssertEngine());
        }

        [Test]
        public void DefaultRouteInAdmin_ShouldMapToIndexInAdminArea()
        {
            Routes.ShouldMap("/Admin").To<AdminController>(x => x.Index());
        }

        [Test]
        public void AdminAdmin_ShouldMapToAdminAreaIndex()
        {
            Routes.ShouldMap("/Admin/Admin").To<AdminController>(x => x.Index());
        }

       // Shouldn't have multiple asserts in the same test method.  Doing this to save space
       // (yeah right :-)
        [Test]
        public void AdminAdvisors_CRUD_GET()
        {
            Routes.ShouldMap("/Admin/Advisors").To<AdvisorsController>(x => x.Index());
            Routes.ShouldMap("/Admin/Advisors/Create").To<AdvisorsController>(x => x.Create());
            Routes.ShouldMap("/Admin/Advisors/Details/5").To<AdvisorsController>(x => x.Details(5));
            Routes.ShouldMap("/Admin/Advisors/Edit/5").To<AdvisorsController>(x => x.Edit(5));
            Routes.ShouldMap("/Admin/Advisors/Delete/5").To<AdvisorsController>(x => x.Delete(5));
        }

        //... test POST action methods and other domain entities
    }
}

Take note how this testing is superior to the one presented above. It tests that the route exists in the route table as you intended AND it confirms that a Controller and Action method exist to respond to that request. Ask yourself, would the test compile if the destination action method didn't exist or had the wrong signature?

That should get you going...