To, że istnieją dostępne MVC Route Debuggers to każdy pewnie o tym wie. Zarówno Glimpse jak i RouteDebugger pomogą nam wykryć, który route jest aktualnie dopasowany do naszego View.

Dodatkowo, jak stosujecie UT, to na pewno macie kod sprawdzający czy dany route pasuje do danej akcji kontrolera, np:

public class account_routes_tests : routes_tests_base
{
    private readonly string _url = "http://google.com";

    [Fact]
    public void sign_in_should_match_signin_action()
    {
        "~/sign-in".ShouldMapTo<AccountController>(a => a.SignIn(null));
    }

    [Fact]
    public void profile_should_match_profile_action()
    {
        "~/profile".ShouldMapTo<AccountController>(a => a.Profile());
    }

    [Fact]
    public void sign_out_should_match_signout_action()
    {
        "~/sign-out".ShouldMapTo<AccountController>(a => a.SignOut());
    }

    [Fact]
    public void sign_in_with_return_url_should_match_signin_action_and_return_url()
    {
        var routeData = "~/sign-in".WithMethod(HttpVerbs.Get);
        routeData.Values["returnurl"] = _url;

        routeData.ShouldMapTo<AccountController>(c => c.SignIn(_url));
    }

    [Fact]
    public void sign_in_sso_should_match_signin_sso_action()
    {
        var routeData = "~/sign-in-sso".WithMethod(HttpVerbs.Post);

        routeData.ShouldMapTo<AccountController>(c => c.SignInSso(null));
    }

    [Fact]
    public void sign_in_sso_with_return_url_should_match_signin_sso_action()
    {
        var routeData = "~/sign-in-sso".WithMethod(HttpVerbs.Post);
        routeData.Values["returnurl"] = _url;

        routeData.ShouldMapTo<AccountController>(c => c.SignInSso(_url));
    }
}

I zapewne macie też testy sprawdzające czy akcja w kontrolerze przypisuje wartości do ViewBag czy też do modelu, które są potem wykorzystywane w Html.ActionLink lub Url.Action.

Jednak niezależnie jak bardzo się bronicie przed pewnymi sprawami, to w środowisku zarówno programistycznym jak i live możecie natrafić na niespodziewane błędy jak chociażby dziwny URL, który nie powinien mieć miejsca. Przecież testy wam mówią, iż taki URL nie ma prawa bytu, a jednak.

U nas zdarzyło się to parę razy. Wina leżała po stronie CRM, który według wszystkich założeń powinien wykonywać pewną operację. Jednak przez jeden z updatów, przestał on ją wykonywać, przez co wartości zwracane, które oznaczają unikatowy przyjazny użytkownikowi identyfikator encji były puste.

Zanim do tego doszliśmy spędziliśmy 2 dni debugując aplikację. Sprawdzaliśmy linki itp. itd. ale nikt z nas nie wpadł na pomysł by sprawdzić coś co miało być oczywiste a nie było – unikatowy identyfikator.

Dopiero dzięki małemu rozszerzeniu, udało nam się do tego dojść.

Mianowicie, stworzyliśmy rozszerzenie, które analizuje parametry przekazane do Html.ActionLink i Url.Action pod względem wartości pustych lub nullowych. Nie chcieliśmy wiedzieć, jaki route został wybrany, bo dokładnie wiedzieliśmy, chcieliśmy wiedzieć, dlaczego mimo testów, jest wybierany inny route niż powinien być.

Rozszerzenie działa dość prosto, bierze all co jest podane, wyszukuje pasujący route dla kontrolera i akcji, a następnie sprawdza jakie route values zostały przekazane i próbuje połączyć to z definicją route. Jeżeli trafi na route, który pasuje parametrom, sprawdza czy wszystkie dane są podane i czy wartości nie są null lub whitespace. W zależności od tego albo wyrzuci exception (lub nie w zależności od konfiguracji) mówiący „to nie zadziała”, albo i nie.

Dla przykładu, dla takiej konfiguracji routes:

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

    routes.MapRoute(
        "Default-test",
        "lic/{myid}/{regno}",
        new { controller = "Home", action = "Test" }
    );

    routes.MapRoute(
        "Default-test2",
        "lic/{myid}/{test}",
        new { controller = "Home", action = "Test2", myid = UrlParameter.Optional }
    );

    routes.MapRoute(
        "default", // Route name
        "{controller}/{action}",
        new { controller = "Home", action = "Index" }
    );
}

I takim View:

@* using for WriteRouteErrors *@
@using Spiderman.Common.Extensions

@{
    ViewBag.Title = "Html.ActionLink & Url.Action Samples";
}

<h2>Sample URL</h2>

<ul>
    <li>@Html.ActionLinkDbg("Test", "Test", "Home", new { regno = ViewBag.RegNo, myid = "              " }, null) - @Url.Action("Test", new { regno = ViewBag.RegNo, myid = "              " })</li>
    @*<li>@Url.ActionDbg("Test", new { regno = ViewBag.RegNo, myid = "              " })</li>*@
    <li>@Html.ActionLinkDbg("Test2", "Test2", "Home", new { test = ViewBag.RegNo, myid = ViewBag.Test }, null) - @Url.Action("Test2", "Home", new { test = ViewBag.RegNo, myid = ViewBag.Test })</li>
    @*<li>@Url.Action("Test2", "Home", new { test = ViewBag.RegNo, myid = ViewBag.Test })</li>*@
</ul>

@Html.WriteRouteErrors()

Dostaniemy taki o to wynik:

route_errors_example

Uwaga: Rozszerzenie to nie analizuje brakujących argumentów itp., ono analizuje jedynie wartości przekazane do niego. Nie jest to inteligentne zwierzę, lecz głupie jak but, sprawdzające tylko to co wie.

Jak widać, jedyna zmiana w wykonywanych akcjach to zmiana Html.ActionLink na Html.ActionLinkDbg i Url.Action na Url.ActionDbg.

Ze względu na ograniczenia UrlHelper, jedyna opcja wyświetlenia błędów z nim związanych (ActionDbg) jest za pomocą wyrzucenia exception. Więc odkomentowanie linii w przykładzie, da następujący rezultat:

actiondbg_error

Pełny kod rozszerzenia znajdziecie poniżej – wystarczy zrobić copy-paste i wrzucić go do siebie do projektu. Rozszerzenie powinno dać się normalnie podmienić (replace ActionLink na ActionLinkDbg itp.) bez negatywnego wpływu na cały projekt. Ale nie gwarantuje tego. Dodatkowo, domyślnie rzucanie exception jest wyłączone – patrz przykład wcześniej. By to zmienić należy ustawić true na _throw_on_error.

Mam nadzieję, że komuś się to przyda :)

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Mvc;
using System.Web.Routing;
using Spiderman.Common.Extensions;

namespace Spiderman.Common.Extensions
{
    public static class DebugExtensions
    {
        /// <summary>
        /// If set to <c>true</c>, when used will throw new RouteDbgException. If set to <c>false</c> will add
        /// new ViewBag member RouteErrors that can be used at the end of the page like this:
        /// <code>
        ///     @if(ViewBag.RouteErrors != null) 
        ///     {
        ///         <h2>RouteErrors</h2>
        ///         <ul>
        ///            @Html.Raw(ViewBag.RouteErrors)
        ///         </ul>
        ///     }
        /// </code>
        /// or extension method can be used:
        /// <code>
        ///     @Html.WriteRouteErrors
        /// </code>
        /// </summary>
        private static readonly bool _throw_on_error = false;
        private static readonly string _key = "RouteErrors";

        public static MvcHtmlString WriteRouteErrors(this HtmlHelper htmlHelper)
        {
            var errors = htmlHelper.ViewData[_key] as string;

            if(string.IsNullOrWhiteSpace(errors))
            {
                return new MvcHtmlString(string.Empty);
            }

            var split = errors.Split(new[] { "<li>" }, StringSplitOptions.RemoveEmptyEntries);

            TagBuilder divContainer = new TagBuilder("div");
            TagBuilder h2 = new TagBuilder("h2");
            TagBuilder ul = new TagBuilder("ul");

            foreach(var error in split)
            {
                var tmp = error.Replace("</li>", string.Empty);

                TagBuilder li = new TagBuilder("li");
                li.InnerHtml = tmp;
                ul.InnerHtml += li.ToString(TagRenderMode.Normal);
            }

            h2.InnerHtml = "Route Errors";
            divContainer.InnerHtml = h2.ToString(TagRenderMode.Normal) + ul.ToString(TagRenderMode.Normal);

            return new MvcHtmlString(divContainer.ToString(TagRenderMode.Normal));
        }

        public static bool Debug(this HtmlHelper htmlHelper, string actionName, string controller, RouteValueDictionary routeValues, RequestContext requestContext = null)
        {
            var sb = new StringBuilder();

            var nullValues = routeValues.EmptyValues();

            if(htmlHelper != null)
            {
                requestContext = htmlHelper.ViewContext.RequestContext;
            }

            if(htmlHelper == null && requestContext == null)
            {
                throw new Exception("Html Helper and Request Context cannot be both null.");
            }

            if(string.IsNullOrWhiteSpace(actionName))
            {
                actionName = requestContext.RouteData.Values.GetValue("action", @default: string.Empty).ToString();
            }

            if(string.IsNullOrWhiteSpace(controller))
            {
                controller = requestContext.RouteData.Values.GetValue("controller", @default: string.Empty).ToString();
            }

            // add routes that match request -> controller & action
            var routes = RouteTable.Routes.GetMatchingRoutes(controller, actionName);

            foreach(var value in nullValues)
            {
                var token = string.Format("{{{0}}}", value.Key);

                var routesWithToken = routes.Where(x => x.Url.Contains(token));

                var stringValue = value.Value as string;
                foreach(var route in routesWithToken)
                {
                    var defaultValue = route.Defaults.GetValue(value.Key);
                    var matchingUrl = GetResolvedPath(requestContext, actionName, controller, routeValues);
                    var routeUrl = route.Url;

                    if(defaultValue == null && stringValue == null)
                    {
                        sb.AppendFormat(
                            "{0} - Route url, contains key '{1}' that is null, and no default value is provided for this key.",
                            route.Url, value.Key);

                        sb.AppendFormat(" MVC will use url: '{0}' instead of '{1}'.", matchingUrl, routeUrl);

                        sb.AppendLine();
                        continue;
                    }

                    if(defaultValue == null && string.IsNullOrWhiteSpace(stringValue))
                    {
                        sb.AppendFormat(
                            "{0} - Route url, contains key '{1}' that is a white spece ('{2}'), this URL will probably does not work.",
                            route.Url, value.Key, stringValue);

                        sb.AppendFormat(" MVC will use url: '{0}' instead of '{1}'.", matchingUrl, routeUrl);

                        sb.AppendLine();

                        continue;
                    }

                    if(defaultValue == UrlParameter.Optional)
                    {
                        if(!route.Url.EndsWith(token))
                        {
                            sb.AppendFormat(
                            "{0} - Route url, contains key '{1}' that is null. Default value for it is 'optional' but key is located in the middle or beginning of the URL. The route may work, but the URL '{2}' may be different from what you expect to receive.",
                            route.Url, value.Key, matchingUrl);

                            sb.AppendFormat(" MVC will use url: '{0}' instead of '{1}'.", matchingUrl, routeUrl);

                            sb.AppendLine();
                            continue;
                        }
                    }
                }
            }

            var error = sb.ToString();

            if(!string.IsNullOrEmpty(error))
            {
                if(_throw_on_error || htmlHelper == null)
                {
                    throw new RouteDbgException(error);
                }

                var errors = htmlHelper.ViewData[_key] as string;
                if(string.IsNullOrWhiteSpace(errors))
                {
                    errors = string.Empty;
                }

                errors += "<li>" + error + "</li>";

                htmlHelper.ViewData[_key] = errors;

                return false;
            }

            return true;
        }

        #region local methods for getting values from RouteCollection dict and etc

        private static IList<Route> GetMatchingRoutes(this RouteCollection @this, string controller, string actionName)
        {
            var routes = new List<Route>();
            using(@this.GetReadLock())
            {
                // we do not know the url, so we need to loop thru all routes
                foreach(var routeBase in @this)
                {
                    var route = routeBase as Route;

                    // ignore route [default one]
                    if(route == null || route.RouteHandler is StopRoutingHandler || route.Defaults == null)
                    {
                        continue;
                    }

                    var controllerMatch = route.Url.Contains("{controller}");
                    var actionMatch = route.Url.Contains("{action}");

                    var routeController = route.Defaults.GetValue("controller", string.Empty).ToString();
                    if(string.Compare(controller, routeController, StringComparison.OrdinalIgnoreCase) != 0 && !controllerMatch)
                    {
                        continue;
                    }

                    var routeAction = route.Defaults.GetValue("action", string.Empty).ToString();
                    if(string.Compare(actionName, routeAction, StringComparison.OrdinalIgnoreCase) != 0 && !actionMatch)
                    {
                        continue;
                    }

                    routes.Add(route);
                }
            }

            return routes;
        }

        private static object GetValue(this RouteValueDictionary @this, string key, object @default = null)
        {
            object routeValue;
            if(@this.TryGetValue(key, out routeValue))
            {
                return routeValue;
            }

            return @default;
        }

        private static IDictionary<string, object> EmptyValues(this RouteValueDictionary @this)
        {
            var nullValues = new Dictionary<string, object>();
            foreach(var routeValue in @this)
            {
                var stringvalue = Convert.ToString(routeValue.Value);
                if(string.IsNullOrWhiteSpace(stringvalue))
                {
                    nullValues.Add(routeValue.Key, routeValue.Value);
                }
            }

            return nullValues;
        }

        private static string GetResolvedPath(RequestContext requestContext, string actionName, string controller, RouteValueDictionary routeValues)
        {
            var mergedRouteValues = new RouteValueDictionary();

            foreach(KeyValuePair<string, object> routeElement in GetRouteValues(routeValues))
            {
                mergedRouteValues[routeElement.Key] = routeElement.Value;
            }

            // Merge explicit parameters when not null
            if(actionName != null)
            {
                mergedRouteValues["action"] = actionName;
            }

            if(controller != null)
            {
                mergedRouteValues["controller"] = controller;
            }

            var vpd = RouteTable.Routes.GetVirtualPathForArea(requestContext, null, mergedRouteValues);
            if(vpd == null)
            {
                return string.Empty;
            }

            return vpd.VirtualPath;
        }

        private static RouteValueDictionary GetRouteValues(RouteValueDictionary routeValues)
        {
            return routeValues ?? new RouteValueDictionary();
        }

        #endregion local methods for getting values from RouteCollection dict and etc
    }

    public class RouteDbgException : Exception
    {
        public RouteDbgException() : base() { }
        public RouteDbgException(string message) : base(message) { }
    }
}

#region Html.ActionLinkDbg & Html.ActionDbg

namespace System.Web.Mvc.Html
{
    public static class DebuggableActionLinkExtensions
    {
        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, null /* controllerName */, new RouteValueDictionary(), new RouteValueDictionary());
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, object routeValues)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, null /* controllerName */, new RouteValueDictionary(routeValues), new RouteValueDictionary());
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, object routeValues, object htmlAttributes)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, null /* controllerName */, new RouteValueDictionary(routeValues), HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, RouteValueDictionary routeValues)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, null /* controllerName */, routeValues, new RouteValueDictionary());
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, RouteValueDictionary routeValues, IDictionary<string, object> htmlAttributes)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, null /* controllerName */, routeValues, htmlAttributes);
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, controllerName, new RouteValueDictionary(), new RouteValueDictionary());
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, object routeValues, object htmlAttributes)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, controllerName, new RouteValueDictionary(routeValues), HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, RouteValueDictionary routeValues, IDictionary<string, object> htmlAttributes)
        {
            // calling new as this will make sure that routeValues are not null
            DebugExtensions.Debug(htmlHelper, actionName, controllerName, routeValues ?? new RouteValueDictionary());
            return MvcHtmlString.Create(HtmlHelper.GenerateLink(htmlHelper.ViewContext.RequestContext, htmlHelper.RouteCollection, linkText, null/* routeName */, actionName, controllerName, routeValues, htmlAttributes));
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, string protocol, string hostName, string fragment, object routeValues, object htmlAttributes)
        {
            return ActionLinkDbg(htmlHelper, linkText, actionName, controllerName, protocol, hostName, fragment, new RouteValueDictionary(routeValues), HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }

        public static MvcHtmlString ActionLinkDbg(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, string protocol, string hostName, string fragment, RouteValueDictionary routeValues, IDictionary<string, object> htmlAttributes)
        {
            DebugExtensions.Debug(htmlHelper, actionName, controllerName, routeValues ?? new RouteValueDictionary());
            return MvcHtmlString.Create(HtmlHelper.GenerateLink(htmlHelper.ViewContext.RequestContext, htmlHelper.RouteCollection, linkText, null /* routeName */, actionName, controllerName, protocol, hostName, fragment, routeValues, htmlAttributes));
        }
    }
}

namespace System.Web.Mvc
{
    public static class DebuggableUrlActionExtensions
    {
        public static string ActionDbg(this UrlHelper urlHelper, string actionName)
        {
            return urlHelper.ActionDbg(actionName, null, new RouteValueDictionary(), null, null, true);
        }

        public static string ActionDbg(this UrlHelper urlHelper, string actionName, object routeValues)
        {
            return urlHelper.ActionDbg(actionName, null, routeValues == null ? new RouteValueDictionary() : new RouteValueDictionary(routeValues), null, null, true);
        }

        public static string ActionDbg(this UrlHelper urlHelper, string actionName, RouteValueDictionary routeValues)
        {
            return urlHelper.ActionDbg(actionName, null, routeValues ?? new RouteValueDictionary(), null, null, true);
        }

        public static string ActionDbg(this UrlHelper urlHelper, string actionName, string controllerName)
        {
            return urlHelper.ActionDbg(actionName, controllerName, new RouteValueDictionary(), null, null, true);
        }

        public static string ActionDbg(this UrlHelper urlHelper, string actionName, string controllerName, object routeValues)
        {
            return urlHelper.ActionDbg(actionName, controllerName, routeValues == null ? new RouteValueDictionary() : new RouteValueDictionary(routeValues), null, null, true);
        }

        public static string ActionDbg(this UrlHelper urlHelper, string actionName, string controllerName, RouteValueDictionary routeValues)
        {
            return urlHelper.ActionDbg(actionName, controllerName, routeValues ?? new RouteValueDictionary(), null, null, true);
        }

        public static string ActionDbg(this UrlHelper urlHelper, string actionName, string controllerName, object routeValues, string protocol)
        {
            return urlHelper.ActionDbg(actionName, controllerName, routeValues == null ? new RouteValueDictionary() : new RouteValueDictionary(routeValues), protocol, null, true);
        }

        public static string ActionDbg(this UrlHelper urlHelper, string actionName, string controllerName, RouteValueDictionary routeValues, string protocol, string hostName)
        {
            return urlHelper.ActionDbg(actionName, controllerName, routeValues == null ? new RouteValueDictionary() : new RouteValueDictionary(routeValues), protocol, hostName, true);
        }

        private static string ActionDbg(this UrlHelper urlHelper, string actionName, string controllerName, RouteValueDictionary routeValues, string protocol, string hostName, bool local)
        {
            DebugExtensions.Debug(null, actionName, controllerName, routeValues ?? new RouteValueDictionary(), urlHelper.RequestContext);

            return urlHelper.Action(actionName, controllerName, routeValues ?? new RouteValueDictionary(), protocol, hostName);
        }
    }
}

#endregion Html.ActionLinkDbg & Html.ActionDbg