how do I test views using razor syntax in mvc3?

2019-05-10 12:36发布

问题:

I am writing code to test an C# MVC3 application. I can test the controllers but how do I test the code in the views? This includes javascript and razor styled code.

Are there any tools available which can mock out views or test views and javascript in C# in them?

回答1:

The following is about testing the rendered output of the view. This textual output can for instance be loaded into a DOM for further analysis with XPath (using XmlReader for XHTML or HtmlAgilityPack for SGML-style HTML). With some nice helper methods this allows easy check for checking specific parts of the view, such as testing //a[@href='#'] or anything else which you want to test. This helps making the unit tests more stable.

One would expect that this is easy when using Razor instead of the "blown-up" WebForms engine, but it turned out to be quite the contrary, due to many inner workings of the Razor view engine and views using parts (HtmlHelper especially) of the HTTP request lifecycle. In fact, properly testing the generated output requires a lot of running code to get reliable and appropriate results, even more so if you use exotic stuff such as portable areas (from the MVCContrib project) and the like in the mix.

The HTML helpers for actions and URLs require that the routing is properly initialized, the route dictionary is properly set up, the controller must also exist, and there are other "gotchas" related to loading data for the view, such as setting up the view data dictionary...

We ended up creating a ViewRenderer class which will actually instantiate an application host on the physical path of your web to be tested (can be statically cached, re-initialization for each single test is impractical due to the initialization lag):

host = (ApplicationHost)System.Web.Hosting.ApplicationHost.CreateApplicationHost(typeof(ApplicationHost), "/", physicalDir.FullName);

The ApplicationHost class in turn inherits from MarshalByRefObject since the host will be loaded in a separate application domain. The host performs all sorts of unholy initialization stuff in order to properly initialize the HttpApplication (the code in global.asax.cs which registers routes etc.) while disabling some aspects (such as authentication and authorization). Be warned, serious hacking ahead. Use at your own risk.

public ApplicationHost() {
    ApplicationMode.UnitTesting = true; // set a flag on a global helper class to indicate what mode we're running in; this flag can be evaluated in the global.asax.cs code to skip code which shall not run when unit testing
    // first we need to tweak the configuration to successfully perform requests and initialization later
    AuthenticationSection authenticationSection = (AuthenticationSection)WebConfigurationManager.GetSection("system.web/authentication");
    ClearReadOnly(authenticationSection);
    authenticationSection.Mode = AuthenticationMode.None;
    AuthorizationSection authorizationSection = (AuthorizationSection)WebConfigurationManager.GetSection("system.web/authorization");
    ClearReadOnly(authorizationSection);
    AuthorizationRuleCollection authorizationRules = authorizationSection.Rules;
    ClearReadOnly(authorizationRules);
    authorizationRules.Clear();
    AuthorizationRule rule = new AuthorizationRule(AuthorizationRuleAction.Allow);
    rule.Users.Add("*");
    authorizationRules.Add(rule);
    // now we execute a bogus request to fully initialize the application
    ApplicationCatcher catcher = new ApplicationCatcher();
    HttpRuntime.ProcessRequest(new SimpleWorkerRequest("/404.axd", "", catcher));
    if (catcher.ApplicationInstance == null) {
        throw new InvalidOperationException("Initialization failed, could not get application type");
    }
    applicationType = catcher.ApplicationInstance.GetType().BaseType;
}

The ClearReadOnly method uses reflection to make the in-memory web configuration mutable:

private static void ClearReadOnly(ConfigurationElement element) {
    for (Type type = element.GetType(); type != null; type = type.BaseType) {
        foreach (FieldInfo field in type.GetFields(BindingFlags.Instance|BindingFlags.NonPublic|BindingFlags.DeclaredOnly).Where(f => typeof(bool).IsAssignableFrom(f.FieldType) && f.Name.EndsWith("ReadOnly", StringComparison.OrdinalIgnoreCase))) {
            field.SetValue(element, false);
        }
    }
}

The ApplicationCatcher is a "null" TextWriter which stores the application instance. I couldn't find another way to initialize the application instance and get it. The core of it is pretty simple.

public override void Close() {
    Flush();
}

public override void Flush() {
    if ((applicationInstance == null) && (HttpContext.Current != null)) {
        applicationInstance = HttpContext.Current.ApplicationInstance;
    }
}

This now enables us to render out almost any (Razor) view as if it were hosted in a real web server, creating almost a full HTTP lifecycle for rendering it:

private static readonly Regex rxControllerParser = new Regex(@"^(?<areans>.+?)\.Controllers\.(?<controller>[^\.]+)Controller$", RegexOptions.CultureInvariant|RegexOptions.IgnorePatternWhitespace|RegexOptions.ExplicitCapture);

public string RenderViewToString<TController, TModel>(string viewName, bool partial, Dictionary<string, object> viewData, TModel model) where TController: ControllerBase {
    if (viewName == null) {
        throw new ArgumentNullException("viewName");
    }
    using (StringWriter sw = new StringWriter()) {
        SimpleWorkerRequest workerRequest = new SimpleWorkerRequest("/", "", sw);
        HttpContextBase httpContext = new HttpContextWrapper(HttpContext.Current = new HttpContext(workerRequest));
        RouteData routeData = new RouteData();
        Match match = rxControllerParser.Match(typeof(TController).FullName);
        if (!match.Success) {
            throw new InvalidOperationException(string.Format("The controller {0} doesn't follow the common name pattern", typeof(TController).FullName));
        }
        string areaName;
        if (TryResolveAreaNameByNamespace<TController>(match.Groups["areans"].Value, out areaName)) {
            routeData.DataTokens.Add("area", areaName);
        }
        routeData.Values.Add("controller", match.Groups["controller"].Value);
        ControllerContext controllerContext = new ControllerContext(httpContext, routeData, (ControllerBase)FormatterServices.GetUninitializedObject(typeof(TController)));
        ViewEngineResult engineResult = partial ? ViewEngines.Engines.FindPartialView(controllerContext, viewName) : ViewEngines.Engines.FindView(controllerContext, viewName, null);
        if (engineResult.View == null) {
            throw new FileNotFoundException(string.Format("The view '{0}' was not found", viewName));
        }
        ViewDataDictionary<TModel> viewDataDictionary = new ViewDataDictionary<TModel>(model);
        if (viewData != null) {
            foreach (KeyValuePair<string, object> pair in viewData) {
                viewDataDictionary.Add(pair.Key, pair.Value);
            }
        }
        ViewContext viewContext = new ViewContext(controllerContext, engineResult.View, viewDataDictionary, new TempDataDictionary(), sw);
        engineResult.View.Render(viewContext, sw);
        return sw.ToString();
    }
}

Maybe this helps you to get to some results. In general many people say that the hassle of testing views is not worth the effort. I'll let you be the judge of that.



回答2:

Check out vantheshark's article, it describes how to mock the ASP.NET MVC View engine using NSubstitute.