Ce qui suit concerne le test de la sortie rendue de la vue. Cette sortie textuelle peut, par exemple, être chargée dans un DOM pour une analyse plus poussée avec XPath (en utilisant la commande XmlReader
pour XHTML ou HtmlAgilityPack
pour le HTML de style SGML). Grâce à de belles méthodes d'aide, cela permet de vérifier facilement des parties spécifiques de la vue, comme par exemple les tests suivants //a[@href='#']
ou toute autre chose que vous voulez tester. Cela permet de rendre les tests unitaires plus stables.
On pourrait s'attendre à ce que cela soit facile lorsqu'on utilise Razor au lieu du moteur WebForms "gonflé", mais il s'est avéré que c'était tout le contraire, en raison de nombreux rouages internes du moteur de vue Razor et des vues utilisant des parties ( HtmlHelper
notamment) du cycle de vie de la requête HTTP. En fait, pour tester correctement le résultat généré, il faut beaucoup de code en cours d'exécution pour obtenir des résultats fiables et appropriés, d'autant plus si vous utilisez des éléments exotiques tels que les zones portables (du projet MVCContrib) et autres dans le mélange.
Les aides HTML pour les actions et les URL exigent que le routage soit correctement initialisé, que le dictionnaire de routage soit correctement configuré, que le contrôleur existe également et qu'il y ait d'autres problèmes liés au chargement des données pour la vue, comme la configuration du dictionnaire de données de la vue...
Nous avons fini par créer un ViewRenderer
qui instanciera réellement un hôte d'application sur le chemin physique de votre site web à tester (peut être mis en cache statiquement, la réinitialisation pour chaque test n'est pas pratique en raison du délai d'initialisation) :
host = (ApplicationHost)System.Web.Hosting.ApplicationHost.CreateApplicationHost(typeof(ApplicationHost), "/", physicalDir.FullName);
Le site ApplicationHost
hérite à son tour de la classe MarshalByRefObject
puisque l'hôte sera chargé dans un domaine d'application distinct. L'hôte effectue toutes sortes d'opérations d'initialisation impies afin d'initialiser correctement le système de fichiers de l HttpApplication
(le code dans global.asax.cs
qui enregistre les routes, etc.) tout en désactivant certains aspects (comme l'authentification et l'autorisation). Soyez avertis, un piratage sérieux en perspective. Utilisez à vos risques et périls.
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;
}
Le site ClearReadOnly
utilise la réflexion pour rendre la configuration web en mémoire 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);
}
}
}
Le site ApplicationCatcher
est un "null" TextWriter
qui stocke l'instance de l'application. Je n'ai pas trouvé d'autre moyen d'initialiser l'instance de l'application et de la récupérer. L'essentiel est assez simple.
public override void Close() {
Flush();
}
public override void Flush() {
if ((applicationInstance == null) && (HttpContext.Current != null)) {
applicationInstance = HttpContext.Current.ApplicationInstance;
}
}
Cela nous permet maintenant d'effectuer le rendu de presque n'importe quelle vue (Razor) comme si elle était hébergée dans un véritable serveur Web, en créant un cycle de vie HTTP presque complet pour le rendu :
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();
}
}
Peut-être cela vous aidera-t-il à obtenir des résultats. En général, beaucoup de gens disent que le fait de tester les vues n'en vaut pas la peine. Je vous laisse en juger.