使用IdleTest进行TDD单元测试驱动开发演练(3) 之 ASP.NET MVC
一、【前言】
(1)本文将用到IOC框架Unity,可参照《Unity V3 初步使用 —— 为我的.NET项目从简单三层架构转到IOC做准备》
(2)本文的解决方案是基于前述《使用IdleTest进行TDD单元测试驱动开发演练(1)》、《使用IdleTest进行TDD单元测试驱动开发演练(2)》继续编 写的,但是已经将解决方案、项目名称等等改名为了“IdleTest.EFAndMVCDemo”。
(3)本文将不再一步一步的记录,只写出重要的步骤并贴出一些关键代码,完整代码请参照 IdleTest 中的IdleTest.EFAndMVCDemo.MvcUI项目和IdleTest.EFAndMVCDemo.MvcUITest。
(4)本文关注点是针对ASP.NET MVC中的单元测试,都是较为简单的ASP.NET MVC,很多代码并不适合实际开发,仅供参考。
(5)程序运行仍会有报错,原因是我没有添加相应的View,但是这不是本文关心的,故而项目代码的完善待日后再说了。
(6)虽然本人早在ASP.NET MVC 1.0时代就使用它来开发项目,但却对现在较新的版本了解不多,因而难免有错漏,望各大虾多多批评指正。
(7)虽然说TDD要测试先行,但我觉得这并不适合所有应用程序的开发,例如ASP.NET MVC,我这里就先创建一个ASP.NET MVC项目“IdleTest.EFAndMVCDemo.MvcUI”,并整理项目的结构,添加一个UserController的控制器,然后才创建单元测试项目“IdleTest.EFAndMVCDemo.MvcUITest”,这两个项目也是我提供的源码链接中本文的关注点,最后去完善实现代码。
二、为测试准备相应代码
1. 首先更新了IdleTest相关类,添加了断言方法“ThrowException”,这对无返回值的函数进行单元测试还是蛮有用的,主要就是断言执行该函数是否正确的抛出了异常与否。该方法通过“Assert.Fail”来实现了自定义的断言,如有需要可参考代码如下
public virtual void ThrowException(Action action, bool hasThrow = true, string message = null) { Exception exception = null; try { action(); } catch (Exception ex) { exception = ex; } if ((exception == null) == hasThrow) { Assert.Fail(message); } }
2. 两个项目的相关引用程序集以及Fakes程序集如下图所示
3. 在项目“IdleTest.EFAndMVCDemo.MvcUI”编写相应代码便于支持IOC,前面的文中说了,要想达到测试单元,摆脱依赖,IOC是最好的解耦方式,当然这个也要适度使用。
public virtual void ThrowException(Action action, bool hasThrow = true, string message = null) { Exception exception = null; try { action(); } catch (Exception ex) { exception = ex; } if ((exception == null) == hasThrow) { Assert.Fail(message); } }
public class MvcApplication : System.Web.HttpApplication { protected void Application_Start() { AreaRegistration.RegisterAllAreas(); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); IocContainer.Register(); } }
在Global.asax的Application_Start方法中加了一行代码“IocContainer.Register();”,将所有需要注入的类型全局注册到IOC容器,避免每次请求都要注册而影响性能,这也是按照微软提供的模板中的方式来做。
4. 在项目“IdleTest.EFAndMVCDemo.MvcUITest”编写如下代码,便于支持单元测试。
(1)UITestConfig类用于保存测试用到的一些数据,简言之就是把硬编码写在一起,方便维护,假如在后期登陆页面的URL变化后只需修改此类中的值便可以继续运行单元测试。
class UITestConfig { public static string LoginViewName = "Login"; public static string DefaultUserUrl = "/Home/Index"; public static string LoginUrl = "/User/Login"; public static string ExistsUserName = "user1"; public static string ExistsPassword = "123"; public static string NotExistsUserName = "user12345"; public static string NotExistsPassword = "12311111"; }
(2)ControllerAssert.cs文件中的类“ControllerAssert”提供了对Controller中的ActionResult类型进行断言的两个常用操作方法。其中AssertViewResult方法对返回ViewResult的Action进行测试;AssertRedirectResult则是针对页面重定向相关的Action,其归根结底就是对Action导航到的URL进行断言。
public class ControllerAssert { /// <summary> /// 断言ViewResult /// </summary> /// <param name="view">需要断言的ActionResult对象</param> /// <param name="expectedModel">预期的View数据模型,null则不对View的Model断言</param> /// <param name="expectedViewName">预期的View名称,为空则不对View的名称断言</param> public static void AssertViewResult(ActionResult view, string expectedViewName, object expectedModel = null) { AssertCommon.IsInstance(typeof(ViewResult), view); var viewResult = view as ViewResult; if (!string.IsNullOrEmpty(expectedViewName)) { AssertCommon.AreEqual(expectedViewName, viewResult.ViewName); } if (expectedModel != null) { AssertCommon.IsNull(false, viewResult.Model); AssertCommon.AreEqual(expectedModel.ToString(), viewResult.Model.ToString()); } } /// <summary> /// 断言RedirectResult或与重定向相关的Action /// </summary> /// <param name="view">需要断言的ActionResult对象</param> /// <param name="expectedUrl">预期的重定向URL,可为绝对地址或相对地址</param> public static void AssertRedirectResult(ActionResult view, string expectedUrl) { if (view is ViewResult) { var result = view as ViewResult; int viewIndex = expectedUrl.IndexOf(result.ViewName, StringComparison.CurrentCultureIgnoreCase); int expectedIndex = expectedUrl.LastIndexOf("/") + 1; AssertCommon.AreEqual(expectedIndex, viewIndex); } else if (view is RedirectResult) { var result = view as RedirectResult; AssertCommon.AreEqual(expectedUrl, result.Url); } else if (view is RedirectToRouteResult) { var result = view as RedirectToRouteResult; string actualUrl = string.Format( "/{0}/{1}", result.RouteValues["controller"], result.RouteValues["action"]); AssertCommon.IsBoolean(true, expectedUrl.IndexOf(actualUrl, StringComparison.CurrentCultureIgnoreCase) >= 0); } else { AssertCommon.AssertInstance.Fail( string.Format("返回的View类型错误【{0}】", view)); } } }
(3)ControllerAssert.cs文件中的类“ControllerAssertInstance”继承“AssertInstance”类并override AssertEqual方法,自定义了针对“ContentResult”类型的断言方式,使得AssertCommon中AssertEqual方法均调用该方法(当然前提是先调用“AssertCommon.ResetAssertInsance(new ControllerAssertInstance());”,可参见AdultRoleAttributeTest中的使用)。
public class ControllerAssertInstance : AssertInstance { public override void AreEqual<T>(T expected, T actual, bool areEqual = true, Func<T, T, bool> compareFunc = null, string message = null) { if (expected is ContentResult) { var expectedResult = expected as ContentResult; var actualResult = actual as ContentResult; AreEqual(expectedResult.Content, actualResult.Content, areEqual); } else { base.AreEqual<T>(expected, actual, areEqual); } } }
三、针对Controller的测试
1. UserController编写了两个构造函数,代码如下,不得不承认这样做更多是为了方便单元测试,感觉有点违背了“不应因单元测试而去修改原代码”的初衷,但是我又没想到其他方式,如您有好的或坏的建议,均盼指点。
private IUserService userService; public UserController() : this(IocContainer.Instance<IUserService>()) { } public UserController(IUserService userService) { this.userService = userService; }
2. 紧接着编写相应的测试代码,年底了,由于我精力与时间有限,故在此只做了登陆的测试,关于MVC的其他测试思想差不多都大同小异(当然使用ext之类的前端可能不太相同,这不在本文探讨范围)。
[TestClass] public class UserControllerTest { private UserController controller; private string beforeURL = "/User/About"; [TestInitialize] public void InitTest() { StubIUserService userService = new StubIUserService(); //模拟用户输入了正确的用户名和密码 userService.LoginUserModel = p => p.LoginName == UITestConfig.ExistsUserName && p.Password == UITestConfig.ExistsPassword; controller = new UserController(userService); } #region Login [TestMethod] public void LoginTest_进入登陆页面不出异常() { //确保Action在参数为空时不会出异常 AssertCommon.ThrowException<string>(TestCommon.GetEmptyStrings(), false, p => controller.Login(p)); } [TestMethod] public void LoginTest_进入正确的登陆页面地址() { LoginGetTestHelper(controller.Login(beforeURL)); LoginGetTestHelper(controller.Login(null)); } private void LoginGetTestHelper(ActionResult view) { ControllerAssert.AssertViewResult(view, UITestConfig.LoginViewName); } [TestMethod] public void LoginTest_登陆提交不出异常() { //确保Action在参数为空时不会出异常 AssertCommon.ThrowException<string>(TestCommon.GetEmptyStrings(), false, p => controller.Login(p, null, null)); AssertCommon.ThrowException<string>(TestCommon.GetEmptyStrings(), false, p => controller.Login(null, p, null)); AssertCommon.ThrowException<string>(TestCommon.GetEmptyStrings(), false, p => controller.Login(null, null, p)); } [TestMethod] public void LoginPostTest_登陆提交用户名或密码错误_回到登陆页面() { //用户名或密码错误均不能登录,返回的view均为“Login” LoginGetTestHelper(controller.Login(UITestConfig.NotExistsUserName, UITestConfig.NotExistsPassword, null)); LoginGetTestHelper(controller.Login(UITestConfig.ExistsUserName, UITestConfig.NotExistsPassword, null)); LoginGetTestHelper(controller.Login(UITestConfig.NotExistsUserName, UITestConfig.ExistsPassword, null)); } [TestMethod] public void LoginPostTest_登陆提交用户名或密码为空_回到登陆页面() { //用户名或密码为空均不能登录,返回的view均为“Login” LoginGetTestHelper(controller.Login(UITestConfig.ExistsUserName, null, null)); LoginGetTestHelper(controller.Login(null, UITestConfig.ExistsPassword, null)); } [TestMethod] public void LoginPostTest_登陆提交用户名或密码正确_进入指定页面() { LoginSuccessTest(controller.Login( UITestConfig.ExistsUserName, UITestConfig.ExistsPassword, beforeURL), beforeURL); LoginSuccessTest(controller.Login( UITestConfig.ExistsUserName, UITestConfig.ExistsPassword, null)); } private void LoginSuccessTest(ActionResult view, string expectedUrl = null) { if (string.IsNullOrEmpty(expectedUrl)) expectedUrl = UITestConfig.DefaultUserUrl; ControllerAssert.AssertRedirectResult(view, expectedUrl); } #endregion [TestMethod] public void RegisterGetTest() { } [TestMethod] public void RegisterPostTest() { } }
3. 通过与测试运行相结合去修改UserController,最终的代码如下
public class UserController : Controller { private IUserService userService; public UserController() : this(IocContainer.Instance<IUserService>()) { } public UserController(IUserService userService) { this.userService = userService; } public ActionResult Register() { return View("Register"); } [HttpPost] public ActionResult Register(UserModel model) { return View("Register"); } public ActionResult Login(string returnUrl) { return View("Login"); } [HttpPost] public ActionResult Login(string loginName, string password, string returnUrl) { var failedView = View("Login"); if (string.IsNullOrEmpty(loginName) || string.IsNullOrEmpty(password)) { return failedView; } if (userService.Login(new UserModel { LoginName = loginName, Password = password })) { if (string.IsNullOrEmpty(returnUrl)) { return RedirectToAction("Index", "Home"); } return Redirect(returnUrl); } return failedView; } }
4. 测试通过后,检查覆盖率,如下图所示
四、针对Filter的测试
1. 有关MVC中Filter的好处我这里就不费口舌了,下面我假设这么一个需求,需要对一些页面的访问进行控制,即未成年人不能进入。于是编写以下Filter,这里我将先去实现这个类,然后再进行单元测试。
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public sealed class AdultRoleAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext filterContext) { IIdentity identity = filterContext.HttpContext.User.Identity; var loginResult = new RedirectResult("/User/Login"); if (string.IsNullOrEmpty(identity.Name) || !identity.IsAuthenticated) { filterContext.Result = loginResult; return; } UserModel model = IocContainer.Instance<IUserService>().GetModel(identity.Name); if (model == null) { filterContext.Result = loginResult; } else if (model.Age < 18) { filterContext.Result = GetNotAdultView(); } } public ActionResult GetNotAdultView() { ContentResult result = new ContentResult(); result.Content = "本页面内容需满18岁才能观看,请您长大后再来访问!"; return result; } }
2. 紧接着编写单元测试类AdultRoleAttributeTest,这里编写单元测试有两个难点。第一,AdultRoleAttribute类override OnActionExecuting方法时有一个类型为ActionExecutingContext的参数,我需要通过这个参数获取当前登录用户(“filterContext.HttpContext.User.Identity”),所以要模拟这个依赖有点难度,因为它的成员调用得很深(参见GetHttpContext方法);第二,通过用户名去获取用户的年龄需要依赖于Service层,但这显然不符合单元测试的做法,并且该类难以注入模拟类型(我不想由于单元测试随便去修改原有代码),所以我还得要伪装IocContainer的Instance方法(参见ShimGetUserModel方法)。
[TestClass] public class AdultRoleAttributeTest { [TestMethod] public void FilterTest_用户未登陆跳转到登陆页面() { AdultRoleAttribute attr = new AdultRoleAttribute(); StubActionExecutingContext context = new StubActionExecutingContext(); //用户名为空断言应跳转到登陆页面 context.HttpContextGet = () => StubHttpContext(string.Empty, true); context.Result = new StubActionResult(); attr.OnActionExecuting(context); ControllerAssert.AssertRedirectResult(context.Result, UITestConfig.LoginUrl); //用户名不为空,但该用户未验证,断言应跳转到登陆页面 context.HttpContextGet = () => StubHttpContext("zhangsan", false); context.Result = new StubActionResult(); attr.OnActionExecuting(context); ControllerAssert.AssertRedirectResult(context.Result, UITestConfig.LoginUrl); } [TestMethod] public void FilterTest_用户已登陆但该用户已被删除跳转到登陆页面() { AdultRoleAttribute attr = new AdultRoleAttribute(); StubActionExecutingContext context = new StubActionExecutingContext(); //用户名不为空,该用户已验证,但是获取不到用户信息,仍不能访问 context.HttpContextGet = () => StubHttpContext("zhangsan", true); context.Result = new StubActionResult(); using (ShimsContext.Create()) { ShimGetUserModel(null); attr.OnActionExecuting(context); ControllerAssert.AssertRedirectResult(context.Result, UITestConfig.LoginUrl); } } [TestMethod] public void FilterTest_未成年不能进入() { AdultRoleAttribute attr = new AdultRoleAttribute(); StubActionExecutingContext context = new StubActionExecutingContext(); AssertCommon.ResetAssertInsance(new ControllerAssertInstance()); using (ShimsContext.Create()) { //用户已验证,但年龄小于18,则断言返回相应的提示页面或内容 AssertCommon.AreEqual(attr.GetNotAdultView(), GetFilterContextByAge(new StubActionExecutingContext(), 17).Result); } } [TestMethod] public void FilterTest_年龄大于或等于18可访问() { ValidAgeTest(18); ValidAgeTest(38); } public void ValidAgeTest(int age) { AdultRoleAttribute attr = new AdultRoleAttribute(); StubActionExecutingContext context = new StubActionExecutingContext(); using (ShimsContext.Create()) { //用户已验证年龄大于等于18,断言进入Filter前后的Result应未变 string viewName = "view"; string masterName = "master"; var expectedView = new StubViewResult(); expectedView.ViewName = viewName; expectedView.MasterName = masterName; context.Result = expectedView; var actualView = GetFilterContextByAge(context, age).Result as ViewResult; AssertCommon.AreEqual(viewName, actualView.ViewName); AssertCommon.AreEqual(masterName, actualView.MasterName); } } public ActionExecutingContext GetFilterContextByAge(StubActionExecutingContext context, int age) { AdultRoleAttribute attr = new AdultRoleAttribute(); ShimGetUserModel(new UserModel { Age = age }); context.HttpContextGet = () => StubHttpContext("zhangsan", true); attr.OnActionExecuting(context); return context; } public void ShimGetUserModel(UserModel model) { ShimIocContainer.InstanceOf1<IUserService>(() => { var userService = new StubIUserService(); userService.GetModelString = p => model; return userService; }); } public HttpContextBase StubHttpContext(string userName, bool isAuthenticated) { var context = new StubHttpContextBase(); context.UserGet = () => { var principal = new StubIPrincipal(); principal.IdentityGet = () => { var id = new StubIIdentity(); id.IsAuthenticatedGet = () => isAuthenticated; id.NameGet = () => userName; return id; }; return principal; }; return context; } }
3. 运行覆盖率分析,如下图所示
五、总结
1. 由于UI是与End Users关联最大的,也是项目其他人员极其关心的,因而我仍将单元测试命名为业务或需求人员能看得懂的命名并将各个方法细分到一个或一种用例,与业务或需求人员确定需求(当然有时候这个需要以文档为据,但我这里也是相对的说法,千万别照搬),当需求变更,首先更改的是单元测试,然后再去编写实现代码。还是那句话前期工作量巨大,但是质量保证真的是杠杠的,且在后期修改代码时大大降低风险。
2. 这里的单元测试只是针对UI,并可通过对接口的模拟摆脱了对服务层和仓储层的依赖,然后使用构造函数注入方式实现了DI,而遵循里氏替换原则编写了AssertInstance的子类ControllerAssertInstance,不然(不遵循里氏替换原则继承AssertInstance)将很容易导致IdleTest不能正常工作。也就是说在做TDD时,遵循SOLID的程度与编写单元测试的容易度成正比关系。
3. 如您对ASP.NET MVC 的 TDD感兴趣,可参照MSDN有比较官方的例子(我只找到了VS2010的例子,那时还没有Fakes要自己编写模拟代码,如您找到了VS2012/2013的例子请告诉我一声,不尽感激)。
4. 我这里只是个人学习以及使用单元测试过程中的一些方式、心得等等,肯定存在不足之处,请各位大虾多多指教,同时作为一个菜鸟,也期待能和对设计模式、单元测试、敏捷开发感兴趣的猿/媛友们多多交流共同进步。
5. 完整代码
【废话一段】这算是我2013最后一篇博文了吧,不管认识的不认识的,码农或非码农的,单身的成对的或者搞小三小四的,均祝大家新年快乐!存款多多,股票节节攀升,贵金属重演两年前的大跃进,保险打水漂!家人健康,小孩越来越懂事,老婆越来越漂亮,老公越来越能干!!
给了大家这么多祝福,也希望大家在年后有啥缺人的情况喊我一声。