Comment mettre en place des tests unitaires avec asp.net mvc4

image

Ce post fait suite à une critique constructive dont j’ai fait l’objet via le projet opensource ERPStore concernant du code de test que j’avais écrit, la source se trouve ici :

http://hadihariri.com/2012/04/07/test-setups-and-design-smells/

Effectivement je reconnais que la version affichée sur cet exemple (V2) n’était pas bonne, pas sur le plan de l’héritage que je revendique et qui respecte le pattern DRY,  mais parce que l’injection de dépendance était écrite “en dur” dans une méthode d’initialisation qui pouvait ne pas correspondre à la réalité du site qu’elle était censée tester.

Quand on utilise de l’injection de dépendance avec asp.net mvc il est difficile de reproduire au plus proche le comportement du serveur, mais il est possible de reproduire le comportement d’une requête, pour cela j’utilise le framework Moq http://code.google.com/p/moq/ et NUnit http://www.nunit.org/  et “toujours” une classe de base abstraite TestBase

Cette classe possède une méthode initialize, il faudra appeler cette méthode dans toutes les classes de test qui hériteront de TestBase via l’attribut [SetUp] de NUnit.
 
  public virtual void Initialize()
{
var httpContext = GetHttpContext();

if (m_Application == null)
{
m_Application = new ERPStoreApplication();
m_Application.Initialize(httpContext);

}

var resolver = System.Web.Mvc.DependencyResolver.Current as IOC.UnityDependencyResolver;
m_Container = resolver.Container;

this.Logger = m_Container.Resolve<ERPStore.Logging.ILogger>();
}

 

Elle récupere ou crée un contexte Http via la Methode GetHttpContext()
  public HttpContextBase GetHttpContext()
{
if (m_MockHttpContext == null)
{
m_MockHttpContext = CreateMockHttpContext();
}
return m_MockHttpContext.Object;
}

Bien entendu nous n’avons pas lors des tests un serveur IIS avec une requete http en bonne et due forme, il va donc falloir en creer une de toute pièce , pour ce faire Microsoft a fourni les éléments nécessaires via l’assembly System.Web.Abstraction, il s’agit de wrappers (classes abstraites) utilisées par le serveur lors du traitement d’une requête, à savoir HttpContext (son wrapper HttpContextBase), Request et son wrapper RequestBase, Response, ect…, tout l’art est de pouvoir “bouchonner” ces wrappers pour récupérer un context http utilisable par les controllers. C’est la qu’intervient le framework Moq il va nous aider à creer des instances concrètes des differents wrappers.

  private Mock<HttpContextBase> CreateMockHttpContext()
{
var context = new Mock<HttpContextBase>();
var cookies = new HttpCookieCollection();

// Response
var response = new Mock<HttpResponseBase>();
var cachePolicy = new Mock<HttpCachePolicyBase>();
response.SetupProperty(r => r.StatusCode, 200);
response.Setup(r => r.Cache).Returns(cachePolicy.Object);
response.Setup(r => r.ApplyAppPathModifier(It.IsAny<string>())).Returns<string>(r => r);
response.Setup(r => r.Cookies).Returns(cookies);
context.Setup(ctx => ctx.Response).Returns(response.Object);

// Request
var request = new Mock<HttpRequestBase>();
var visitorId = Guid.NewGuid().ToString();
var principal = new ERPStore.Models.UserPrincipal(visitorId);
request.Setup(r => r.AnonymousID).Returns(principal.VisitorId);
request.Setup(r => r.Cookies).Returns(cookies);
request.Setup(r => r.Url).Returns(new Uri("http://www.test.com"));
request.Setup(r => r.Headers).Returns(new System.Collections.Specialized.NameValueCollection());
request.Setup(r => r.RequestContext).Returns(new System.Web.Routing.RequestContext(context.Object, new System.Web.Routing.RouteData()));
request.SetupGet(x => x.PhysicalApplicationPath).Returns("/");
request.Setup(r => r.UserHostAddress).Returns("127.0.0.1");
request.Object.Cookies.Add(new HttpCookie("erpstorevid")
{
Value = principal.VisitorId,
});
context.Setup(ctx => ctx.Request).Returns(request.Object);

// Sessions
var session = new Mock<HttpSessionStateBase>();
context.Setup(ctx => ctx.Session).Returns(session.Object);

// Server
var server = new Mock<HttpServerUtilityBase>();
server.Setup(s => s.MapPath(It.IsAny<string>())).Returns<string>(r => {
if (r.Equals("/bin", StringComparison.InvariantCultureIgnoreCase))
{
r = string.Empty;
}
var path = typeof(ERPStore.ERPStoreApplication).Assembly.Location;
var fileName = System.IO.Path.GetFileName(path);
path = path.Replace(fileName, string.Empty);
r = r.Trim('/').Trim('\\');
path = System.IO.Path.Combine(path, r);
return path;
});
context.Setup(ctx => ctx.Server).Returns(server.Object);

// Principal
context.Setup(ctx => ctx.User).Returns(principal);

// Items
context.Setup(ctx => ctx.Items).Returns(new Dictionary<string, object>());

return context;
}

Le principe est de faire un retour du type demandé pour chaque propriété et méthode, a l’issue un Context Http est retourné pret à l’emploi.


La critique portait au niveau de l’initialisation, ce qui a changé maintenant l’appel de classe ERPStoreApplication est fait au moment du initialize (simulation d’une requete http) qui est en quelque sorte le bootstapper d’ERPStore c’est lui qui a la responsabilité de configurer le container d’inversion de dépendance (Unity), que ce soit pour les tests ou en production il est appelé exactement de la même manière maintenant. Je pouvais aller plus loin encore en créant une instance de Global.asax et en appelant la méthode BeginRequest()


Maintenant pour tester un controller il suffit d’écrire sa propre classe qui doit heriter de TestBase, par exemple le login du site :


 

[TestFixture]
public class AccountControllerTests : TestBase
{
    [SetUp]
    public override void Initialize()
    {
        base.Initialize();
    }


    [TearDown]
    public void TearDown()
    {
        base.TeardownHttpContext();
    }



    [Test]
    public void Login()
    {
        var m_Controller = CreateController<ERPStore.Controllers.AccountController>();
        var result = m_Controller.Login() as System.Web.Mvc.ViewResult;
        Assert.AreEqual(result.ViewName, string.Empty);
    }


    [Test]
    public void Login_Form()
    {
        var accountController = CreateController<ERPStore.Controllers.AccountController>();


        var accountService = System.Web.Mvc.DependencyResolver.Current.GetService<ERPStore.Services.IAccountService>();
        var user = new ERPStore.Models.User();
        user.Login = "userName1";
        user.Id = 12345;
        accountService.SaveUser(user);


        accountService.SetPassword(user, "password1");


        var loginResult = accountController.Login("userName1", "password1", false, null) as System.Web.Mvc.RedirectToRouteResult;


        Assert.AreEqual(loginResult.RouteName, Routing.ERPStoreRoutes.ACCOUNT);
    }


}


La classe de base fournit le controller a tester grâce à la methode CreateController<…>()

  protected T CreateController<T>()
where T : System.Web.Mvc.Controller
{
var httpContext = GetHttpContext();
var result = CreateController<T>(httpContext);
return result;
}

protected T CreateController<T>(System.Web.HttpContextBase httpContext)
where T : System.Web.Mvc.Controller
{
var controller = m_Container.Resolve<T>();
controller.SetupControllerContext(httpContext);

return controller;
}
Grace a cette classe de base il est possible d’obtenir une couverture de 100% sur les controllers, il est meme possible de tester également les routes.
  [Test]
public void Cart_Href()
{
var ctrl = CreateController<ERPStore.Cart.Controllers.CartController>();

var url = ctrl.Url.CartHref();

Assert.AreEqual(url, "/panier");
}
avec sa methode d’extension
  public static string CartHref(this UrlHelper helper)
{
return helper.RouteERPStoreUrl(Cart.Routes.CART);
}
et son enregistrement
   routes.MapERPStoreRoute(
Routes.CART
, "panier"
, new { controller = "Cart", action = "Index", id = string.Empty }
, namespaces
);
 
Bons tests à tous

Aucun commentaire: