Unit-Tests für ASP.NET MVC ActionFilter

7. Februar 2014

Unit-Tests sind wichtig – das sollte mittlerweile bei den meisten Entwicklern angekommen sein. Sie sind aber auch manchmal schwierig zu schreiben – zumal dann, wenn der eigene Code sich in Frameworks integriert oder sie erweitert. Häufig werden an dieser Stelle dann Objekte an den eigenen Code übergeben, die bereits einen recht komplexen Aufbau besitzen und deren Funktionalität auch nur mit viel Aufwand nachzubilden ist.

Gerade im Bereich des ASP.Net MVC gibt es einige Fälle, in denen das Testen der Funktionalität schwierig wird. Einen Einstieg in die Problematik bietet folgender Artikel: Building Testable ASP.NET MVC Applications.

Einen Fall, der mir aufgrund einiger Besonderheiten der beteiligten Framework-Klassen besondere “Herausforderungen” beschert hat, möchte ich im Folgenden schildern.

In einem aktuellen Hobby-Projekt habe ich ein ASP.Net-MVC-Controller-Attribut (leitet also von ActionFilterAttribute ab) geschrieben, welches mir beim Aufruf einer Action prüfen soll, ob eine zusätzliche Angabe mit geschickt wurde. In diesem Fall geht es um einen Authentifizierungsfaktor – das ist aber für die Unit-Test-Problematik nebensächlich.

Der Code der Klasse Arbeitet mit dem ActionExecutingContext, welcher an die virtuelle Methode OnActionExecuting übergeben wird, die von meiner Klasse überschrieben wird und in der über den UrlHelper eine neue Instanz eines RedirectResult zurück geliefert wird.

Wenn es nun ans Unit-Testing geht (ich verwende meist moq, welches auch als NuGet-Package verfügbar ist), wird schnell klar, dass die Abhängigkeiten, die man sich mit dem ActionExecutingContext einfängt gar nicht so einfach zu handhaben sind. So brauche ich zur Abfrage der Session-ID schon einen RequestContext mit HttpContext, der eine Session zur Verfügung stellt. Diese sind zwar genau für den Zweck des Unit-Testing von entsprechenden Basisklassen abgeleitet, besitzen aber voneinander abhängige Properties, für deren korrekte Befüllung man sich mit diesen auch erst mal genauer auseinander setzen muss, als dies für die Entwicklung der zu testenden Methode eigentlich notwendig wäre.

Der Aufbau eines ActionResult erfordert dann schon zusätzlich einen UrlHelper, welcher mit einem RequestContext und einer RouteCollection initialisiert wurde – diese RouteCollection muss wiederum eine Route enthalten, für welche die Properties Defaults, Constraints und DataToken entsprechend korrekt initialisierte Instanzen von RouteValueDictionary besitzen. Um also eine einzige Zeile selbst implementierten Codes zu testen, müssen ggf. mehr als 80 Zeilen Initialisierungsarbeit geleistet werden.

Unglücklicher Weise sagen die Exceptions bei falscher oder unvollständiger Initialisierung der Objekte allerdings nicht immer aus, was nicht korrekt vorbesetzt war, sondern quittieren den Aufruf mit einer NullReferenceException (die in sauber programmiertem Code übrigens NIE auftauchen sollte) irgendwo in den Tiefen des MVC-Frameworks.

Die erste Lösung des Problems lieferten diverse Beiträge auf stackoverflow (z.B.: Unit testing an ActionFilter – correctly setting up the ActionExecutingContext ). Der Einsatz von ReSharper zum Dekompilieren des Codes (der Sourcecode ist übrigens auf CodePlex unter https://aspnetwebstack.codeplex.com/ vollständig verfügbar – da ich aber im Zug an meinen privaten Projekten arbeite und dort häufig keine Internet-Verbindung vorhanden ist, ist ein Dekompilieren mit ReSharper häufig die einfachere Alternative) lieferte dann weitere Hinweise, was wie zu initialisieren sei.

Herausgekommen ist eine Methode, die mir einen kompletten ActionExecutingContext zurück liefert, so dass ich mein ActionFilterAttribute problemlos testen kann. Unit-Tests können somit den Kontext innerhalb einer Zeile Code erzeugen (inkl. Übergabe der URL, die simuliert werden soll):

   1: [TestMethod]

   2: public void DeliversImageForCorrectRequest()

   3: {

   4:     var uriString = "http://localhost/test/?42F...D00";

   5:     var uri = new Uri(uriString, UriKind.Absolute);

   6:     var context = MvcTestBase.CreateRequestContext(uri);

   7:     var target = new YubikeyCheckAttribute(

   8:         new YubikeyConfiguration(),

   9:         new YubicoClientAdapter())

  10:         {

  11:             ImageOnly = true

  12:         }; 

  13:     target.OnActionExecuting(context); var fileResult = context.Result as FileResult;

  14:     Assert.IsNotNull(fileResult); Assert.AreEqual("image/png", fileResult.ContentType);

  15: }

 
 

Der Stand der Methode, mit dem ein erstes Testen eines ActionFilterAttribute möglich ist, ist folgender (zum einfachen Kopieren und Wiederverwenden):

   1: /// <summary>

   2: /// The test base class for MVC tests. This class does provide some basic helpers for

   3: /// testing MVC controllers / filters etc.

   4: /// </summary>

   5: public class MvcTestBase

   6: {

   7:     /// <summary>

   8:     /// Creates a new request context that returns a distinct, 

   9:     /// but always the same, session id.

  10:     /// </summary>

  11:     /// <returns> The <see cref="Mock"/> object for the 

  12:     /// request context. </returns>

  13:     public static ActionExecutingContext CreateRequestContext()

  14:     {

  15:         var uri = new Uri("http://localhost/test", UriKind.Absolute);

  16:         var sessionId = Guid.NewGuid().ToString("N");

  17:         var clientIP = Guid.NewGuid().ToString("N");

  18:         return CreateRequestContext(uri, sessionId, clientIP);

  19:     }

  20:  

  21:     /// <summary>

  22:     /// Creates a new request context that returns a distinct, but 

  23:     /// always the same, session id.

  24:     /// </summary>

  25:     /// <param name="requestUrl">The URL that should be simulated 

  26:     /// to be requested.</param>

  27:     /// <returns> The <see cref="Mock"/> object for the 

  28:     /// request context.  </returns>

  29:     public static ActionExecutingContext CreateRequestContext(Uri requestUrl)

  30:     {

  31:         var sessionId = Guid.NewGuid().ToString("N");

  32:         var clientIP = Guid.NewGuid().ToString("N");

  33:         return CreateRequestContext(requestUrl, sessionId, clientIP);

  34:     }

  35:  

  36:     /// <summary>

  37:     /// Creates a new request context that returns a distinct, 

  38:     /// but always the same, session id.

  39:     /// </summary>

  40:     /// <param name="requestUrl">The URL the request should fake.</param>

  41:     /// <param name="sessionId"> The session Id for the request. </param>

  42:     /// <param name="clientIP"> The client IP for the request. </param>

  43:     /// <returns> The <see cref="Mock"/> object for the request context.  </returns>

  44:     public static ActionExecutingContext CreateRequestContext(Uri requestUrl, string sessionId, string clientIP)

  45:     {

  46:         var form = new NameValueCollection();

  47:         return CreateRequestContext(requestUrl, sessionId, clientIP, form);

  48:     }

  49:  

  50:     /// <summary>

  51:     /// Creates a new request context that returns a distinct, 

  52:     /// but always the same, session id.

  53:     /// </summary>

  54:     /// <param name="requestUrl">The URL the request should fake.</param>

  55:     /// <param name="sessionId"> The session Id for the request. </param>

  56:     /// <param name="clientIP"> The client IP for the request. </param>

  57:     /// <param name="formCollection">The collection of form data items.</param>

  58:     /// <returns> The <see cref="Mock"/> object for the request context.  </returns>

  59:     public static ActionExecutingContext CreateRequestContext(Uri requestUrl, string sessionId, string clientIP, NameValueCollection formCollection)

  60:     {

  61:         return CreateRequestContext(requestUrl, sessionId, clientIP, formCollection, "UserName");

  62:     }

  63:  

  64:     /// <summary>

  65:     /// Creates a new request context that returns a distinct, 

  66:     /// but always the same, session id.

  67:     /// </summary>

  68:     /// <param name="requestUrl">The URL the request should fake.</param>

  69:     /// <param name="sessionId"> The session Id for the request. </param>

  70:     /// <param name="clientIP"> The client IP for the request. </param>

  71:     /// <param name="formCollection">The collection of form data items.</param>

  72:     /// <param name="userName">The name of the user the identity should contain.</param>

  73:     /// <returns> The <see cref="Mock"/> object for the request context.  </returns>

  74:     public static ActionExecutingContext CreateRequestContext(Uri requestUrl, string sessionId, string clientIP, NameValueCollection formCollection, string userName)

  75:     {

  76:         object values = new

  77:         {

  78:             controller = "Home",

  79:             action = "Index",

  80:             id = UrlParameter.Optional

  81:         };

  82:         var requestContext = RequestContext(requestUrl, sessionId, clientIP, formCollection, values, userName);

  83:         var routes = RouteCollection(values);

  84:         var controller = Controller(requestContext, routes);

  85:         return new ActionExecutingContext { RequestContext = requestContext, Controller = controller, };

  86:     }

  87:  

  88:     /// <summary>

  89:     /// Creates an initialized <see cref="HtmlHelper"/> object.

  90:     /// </summary>

  91:     /// <returns>

  92:     /// The <see cref="HtmlHelper"/>.

  93:     /// </returns>

  94:     public static HtmlHelper CreateHtmlHelper()

  95:     {

  96:         var container = new Mock<IViewDataContainer>();

  97:         var formCollection = new NameValueCollection();

  98:         var sessionId = Guid.NewGuid().ToString("N");

  99:         var clientIP = Guid.NewGuid().ToString("N");

 100:         var uri = new Uri("http://test/");

 101:         var viewContext = ViewContext(uri, sessionId, clientIP, formCollection);

 102:         return new HtmlHelper(viewContext, container.Object);

 103:     }

 104:  

 105:     /// <summary>

 106:     /// Generates a request context.

 107:     /// </summary>

 108:     /// <param name="requestUrl"> The request url. </param>

 109:     /// <param name="sessionId"> The session id. </param>

 110:     /// <param name="clientIP"> The client IP. </param>

 111:     /// <param name="formCollection"> The http FORM collection. </param>

 112:     /// <param name="values"> The values for the route data. </param>

 113:     /// <param name="username"> The username. </param>

 114:     /// <returns> The request context"/>. </returns>

 115:     private static RequestContext RequestContext(Uri requestUrl, string sessionId, string clientIP, NameValueCollection formCollection, object values, string username)

 116:     {

 117:         var requestBase = RequestBase(requestUrl, clientIP, formCollection);

 118:         var httpContext = HttpContext(sessionId, requestBase, username);

 119:         return RequestContext(httpContext, values);

 120:     }

 121:  

 122:     /// <summary>

 123:     /// Creates an initialized controller.

 124:     /// </summary>

 125:     /// <param name="requestContext"> The request context for the controller. </param>

 126:     /// <param name="routes"> The routes for the <see cref="UrlHelper"/>. </param>

 127:     /// <returns> The <see cref="Mock"/>. </returns>

 128:     private static Controller Controller(RequestContext requestContext, RouteCollection routes)

 129:     {

 130:         var urlHelper = new UrlHelper(requestContext, routes);

 131:         var controller = new Mock<Controller>();

 132:         controller.Object.Url = urlHelper;

 133:         RouteTable.Routes.Clear();

 134:         foreach (var route in routes)

 135:         { RouteTable.Routes.Add(route); }

 136:         return controller.Object;

 137:     }

 138:  

 139:     /// <summary>

 140:     /// Generates a default route collection.

 141:     /// </summary>

 142:     /// <param name="values"> The default values. </param>

 143:     /// <returns> The <see cref="RouteCollection"/>. </returns>

 144:     private static RouteCollection RouteCollection(object values)

 145:     {

 146:         return new RouteCollection

 147:             {

 148:                 new Route("{controller}/{action}/{id}", new MvcRouteHandler())

 149:                 {      

 150:                     Defaults = new RouteValueDictionary(values),

 151:                     Constraints = new RouteValueDictionary((object)null),

 152:                     DataTokens = new RouteValueDictionary(),

 153:                 }      

 154:             };

 155:     }

 156:  

 157:     /// <summary>

 158:     /// Generates an initialized <see cref="HttpContextBase"/>.

 159:     /// </summary>

 160:     /// <param name="sessionId"> The session id. </param>

 161:     /// <param name="requestBase"> The request base. </param>

 162:     /// <param name="username"> The username for the identity of the current user. </param>

 163:     /// <returns> The <see cref="HttpContextBase"/>. </returns>

 164:     private static HttpContextBase HttpContext(string sessionId, HttpRequestBase requestBase, string username)

 165:     {

 166:         var httpContext = new Moq.Mock<HttpContextBase>();

 167:         httpContext.Setup(x => x.Session).Returns(SessionState(sessionId));

 168:         httpContext.Setup(x => x.Request).Returns(requestBase);

 169:         httpContext.Setup(x => x.Response).Returns(Response());

 170:         httpContext.Setup(x => x.User).Returns(User(username));

 171:         return httpContext.Object;

 172:     }

 173:  

 174:     /// <summary>

 175:     /// Creates an initialized <see cref="IPrincipal"/> for a given user name.

 176:     /// </summary>

 177:     /// <param name="username"> The username. </param>

 178:     /// <returns> The <see cref="IPrincipal"/>. </returns>

 179:     private static IPrincipal User(string username)

 180:     {

 181:         var user = new Mock<IPrincipal>();

 182:         var identity = new Mock<IIdentity>();

 183:         identity.Setup(x => x.Name).Returns(username);

 184:         user.Setup(x => x.Identity).Returns(identity.Object);

 185:         return user.Object;

 186:     }

 187:  

 188:     /// <summary>

 189:     /// Generates a ready to use <see cref="HttpResponseBase"/> object.

 190:     /// </summary>

 191:     /// <returns> The <see cref="Mock"/>. </returns>

 192:     private static HttpResponseBase Response()

 193:     {

 194:         var response = new Mock<HttpResponseBase>();

 195:         response.Setup(r => r.ApplyAppPathModifier(It.IsAny<string>()))

 196:         .Returns((string s) => s);

 197:         return response.Object;

 198:     }

 199:  

 200:     /// <summary>

 201:     /// Generates an initialized <see cref="HttpSessionStateBase"/>.

 202:     /// </summary>

 203:     /// <param name="sessionId"> The session id. </param>

 204:     /// <returns> The <see cref="HttpSessionStateBase"/>. </returns>

 205:     private static HttpSessionStateBase SessionState(string sessionId)

 206:     {

 207:         var sessionState = new Mock<HttpSessionStateBase>();

 208:         sessionState.Setup(x => x.SessionID).Returns(sessionId);

 209:         return sessionState.Object;

 210:     }

 211:  

 212:     /// <summary>

 213:     /// Generates an initialized <see cref="HttpRequestBase"/>.

 214:     /// </summary>

 215:     /// <param name="requestUrl"> The request url. </param>

 216:     /// <param name="clientIP"> The client IP this request should simulate. </param>

 217:     /// <param name="formCollection"> The form value collection. </param>

 218:     /// <returns> The <see cref="HttpRequestBase"/>. </returns>

 219:     private static HttpRequestBase RequestBase(Uri requestUrl, string clientIP, NameValueCollection formCollection)

 220:     {

 221:         var path = requestUrl == null ? string.Empty : requestUrl.AbsolutePath;

 222:         var requestBase = new Mock<HttpRequestBase>();

 223:         requestBase.Setup(x => x.ApplicationPath).Returns(path);

 224:         requestBase.Setup(x => x.AppRelativeCurrentExecutionFilePath).Returns("~" + path);

 225:         requestBase.Setup(x => x.CurrentExecutionFilePath).Returns("/");

 226:         requestBase.Setup(x => x.CurrentExecutionFilePathExtension).Returns(string.Empty);

 227:         requestBase.Setup(x => x.Form).Returns(formCollection);

 228:         requestBase.Setup(x => x.HttpMethod).Returns("GET");

 229:         requestBase.Setup(x => x.Path).Returns("/");

 230:         requestBase.Setup(x => x.RawUrl).Returns(path);

 231:         requestBase.Setup(x => x.RequestType).Returns("GET");

 232:         requestBase.Setup(x => x.Url).Returns(requestUrl);

 233:         requestBase.Setup(x => x.UserHostAddress).Returns(clientIP);

 234:         requestBase.Setup(x => x.UserHostName).Returns(clientIP);

 235:         return requestBase.Object;

 236:     }

 237:  

 238:     /// <summary>

 239:     /// Generates an initialized Request Context.

 240:     /// </summary>

 241:     /// <param name="httpContext"> The http context. </param>

 242:     /// <param name="values"> The route values. </param>

 243:     /// <returns> The Request Context. </returns>

 244:     private static RequestContext RequestContext(HttpContextBase httpContext, object values)

 245:     {

 246:         var requestContext = new Moq.Mock<RequestContext>();

 247:         requestContext.Setup(x => x.HttpContext).Returns(httpContext);

 248:         requestContext.Setup(x => x.RouteData).Returns(RouteData(values));

 249:         return requestContext.Object;

 250:     }

 251:  

 252:     /// <summary>

 253:     /// Generates an initialized <see cref="RouteData"/>.

 254:     /// </summary>

 255:     /// <param name="values"> The values. </param>

 256:     /// <returns> The <see cref="RouteData"/>. </returns>

 257:     private static RouteData RouteData(object values)

 258:     {

 259:         var routeData = new RouteData

 260:         {

 261:             Route = new Route("{controller}/{action}/{id}", new MvcRouteHandler())

 262:             {

 263:                 Defaults = new RouteValueDictionary(values),

 264:                 Constraints = new RouteValueDictionary((object)null),

 265:                 DataTokens = new RouteValueDictionary()

 266:             },

 267:         };

 268:         routeData.Values.Add("controller", "Home");

 269:         routeData.Values.Add("action", "Index");

 270:         return routeData;

 271:     }

 272:  

 273:     /// <summary>

 274:     /// Generates an initialized <see cref="ViewContext"/>.

 275:     /// </summary>

 276:     /// <param name="requestUrl"> The request URL. </param>

 277:     /// <param name="sessionId"> The session id. </param>

 278:     /// <param name="clientIP"> The client IP. </param>

 279:     /// <param name="formCollection"> The form value collection. </param>

 280:     /// <returns> The <see cref="ViewContext"/>. </returns>

 281:     private static ViewContext ViewContext(Uri requestUrl, string sessionId, string clientIP, NameValueCollection formCollection)

 282:     {

 283:         object values = new

 284:         {

 285:             controller = "Home",

 286:             action = "Index",

 287:             id = UrlParameter.Optional

 288:         };

 289:         var viewContext = new Mock<ViewContext>();

 290:         var requestContext = RequestContext(requestUrl, sessionId, clientIP, formCollection, values, "userName");

 291:         var routes = RouteCollection(values);

 292:         var controller = Controller(requestContext, routes);

 293:         viewContext.Setup(x => x.Controller).Returns(controller);

 294:         return viewContext.Object;

 295:     }

 296: }

 

Der aktuelle Stand (der auch die Verwendung des UrlHelper im Test-Objekt ermöglicht) kann jeder Zeit auch meinem CodePlex-Projekt entnommen werden: semauthentication.codeplex.com