一、【前言】

(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);}}

ThrowException

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);}}

IocContainer

    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

在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";}

UITestConfig

(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));}}}

ControllerAssert

(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);}}}

ControllerAssertInstance

三、针对Controller的测试

1. UserController编写了两个构造函数,代码如下,不得不承认这样做更多是为了方便单元测试,感觉有点违背了“不应因单元测试而去修改原代码”的初衷,但是我又没想到其他方式,如您有好的或坏的建议,均盼指点。

        private IUserService userService;public UserController() : this(IocContainer.Instance<IUserService>()){}public UserController(IUserService userService){this.userService = userService;}

UserController构造函数

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(){}}

UserControllerTest

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;}}

UserController

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;}}

AdultRoleAttribute

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;}}

AdultRoleAttributeTest

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最后一篇博文了吧,不管认识的不认识的,码农或非码农的,单身的成对的或者搞小三小四的,均祝大家新年快乐!存款多多,股票节节攀升,贵金属重演两年前的大跃进,保险打水漂!家人健康,小孩越来越懂事,老婆越来越漂亮,老公越来越能干!!
给了大家这么多祝福,也希望大家在年后有啥缺人的情况喊我一声。

转载于:https://www.cnblogs.com/FreeDong/p/3492971.html

使用IdleTest进行TDD单元测试驱动开发演练(3) 之 ASP.NET MVC相关推荐

  1. 解读 TDD 测试驱动开发

    转自:http://www.jianshu.com/p/62f16cd4fef3 本文结构: 什么是 TDD 为什么要 TDD 怎么 TDD FAQ 学习路径 延伸阅读 什么是 TDD TDD 有广义 ...

  2. 实现TDD测试驱动开发

    为什么要用 TDD? TDD 可以让软件开发更快更好. 随着时间的推移,采用 TDD 方式开发新功能会越来越快,修改现有代码的成本可控.相反,传统开发模式开发新功能会越来越慢,修改代码的成本会指数增长 ...

  3. 为什么做UTDD(单元测试驱动开发)

    为什么做UTDD 前言导入 天下熙熙皆为利来,天下攘攘皆为利往.         在开始做一件事情之前,我们首先需要弄清 "它能给我们带来什么?(好处.坏处)"         以 ...

  4. 使用单元测试驱动开发的方式编写flask应用

    flask是个轻量级的python框架,特别适合写一个没有UI的web接口应用,并且提供了很友好的测试框架,参考链接http://flask.pocoo.org/docs/testing/,官方提供的 ...

  5. 行为驱动开发BDD概要

    BDD脱胎于TDD 行为驱动开发(Behavior-Driven Development,简称BDD),是在测试驱动开发(Test-Driven Development,TDD)基础上发展而来的一种软 ...

  6. 测试驱动开发_DevOps之浅谈测试驱动开发

    "测试驱动开发(Test-Driven Development, TDD),以测试作为开发过程的中心,它要求在编写任何产品代码之前,先编写用于定义产品代码行为的测试,而编写的产品代码又要以使 ...

  7. 行为驱动开发BDD和Cucunber简介

    测试驱动开发(TDD) 1.测试驱动开发,即Test-Driven Development(TDD),测试驱动开发是敏捷开发中的一项核心实践和技术,也是一种设计方法论.TDD的原理是在开发功能代码之前 ...

  8. 基于ASP.NET MVC+SQLite开发的一套(Web)图书管理系统【100010294】

    摘要 随着互联网的快速发展,各种线下手工业务都开始转向了互联网线上操作,在21世纪的信息革命时代,信息管理系统成为日常信息记录的主流工具. 本文介绍了以VS 2019(Microsoft Visual ...

  9. 一起谈.NET技术,专访微软MVP衣明志:走进ASP.NET MVC 2框架开发

    日前微软已经发布ASP.NET MVC 2框架RC版,究竟这次RC版本的发布对于WEB开发者带来怎样的改变?以及未来ASP.NET MVC 2正式版还会有哪些改进?带着这样的问题,我们51CTO记者彭 ...

  10. asp.net mvc linux,ASP.NET MVC4开发指南PDF扫描版+源码

    ASP.NET MVC问世已久,几年前或许有人会担心ASP.NET MVC框架是否能用在实务的项目上,也担心用在新项目上是否真的能改善开发效率与质量,但笔者这几年下来,已经累积数十个网站项目改用ASP ...

最新文章

  1. asp.net js函数弹出登录窗口_JS基础 | Cocos Creator 开发环境搭建
  2. 自顶向下彻底理解 Java 中的 Synchronized
  3. 了解mysqlpump工具
  4. 必要商城MySQL开发规范
  5. 洛谷 P2324 [SCOI2005]骑士精神 解题报告
  6. Android 动画效果及Interpolator和AnimationListener的使用
  7. 目录指南中的Python列表文件-listdir VS system(“ ls”)通过示例进行解释
  8. 【2018ACM山东省赛 - E】Sequence(树状数组,思维,优化)
  9. AWS 专家教你使用 Spring Boot 和 DJL ,轻松搭建企业级机器学习微服务!
  10. hashmap怎么取值_HashMap?面试?我是谁?我在哪?我会啥?
  11. mysql语言中有什么运算_SQL知识点,新手感悟
  12. 手机APP/小程序微模卡源码下载,开源开心免费开心
  13. 如何获取ppt的背景图片
  14. 连接局域网打印机显示无法连接服务器,网络打印机拒绝访问无法连接处理方法汇总...
  15. 设置360浏览器默认以极速模式打开
  16. Windows 下视频采集
  17. 智能手机功能设计实现
  18. 探索R包plyr:脱离R中显式循环
  19. 自学生物信息学(思维+超全常用网站)
  20. 博文视点金秋新书大放送(1)

热门文章

  1. sql server,mysql,oracle 获取上一月时间
  2. 每天一道剑指offer-约瑟夫环求解圆圈中剩余的数
  3. 2014大学计算机操作系统,郑州大学软件学院2013-2014《计算机操作系统》试题及答案...
  4. 列车控制matlab仿真,基于matlab的列车纵向碰撞建模仿真研究
  5. html5 textarea 限制字数,如何限制textarea的字符数为225?
  6. java字符串反转及替换_字符串的反转及替换
  7. python series拼接_pandas数据拼接的实现示例
  8. 抽象工厂模式_设计模式3之抽象工厂模式
  9. javascript createelement_如何创建与框架无关的JavaScript插件
  10. 电脑系统哪个好用_火绒杀毒,真有那么好用吗?