一、【前言】

(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 = ""; public static string NotExistsUserName = "user12345"; public static string NotExistsPassword = "";
}

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("/") + ;
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) >= );
}
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 < )
{
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(), ).Result);
}
} [TestMethod]
public void FilterTest_年龄大于或等于18可访问()
{
ValidAgeTest();
ValidAgeTest();
} 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最后一篇博文了吧,不管认识的不认识的,码农或非码农的,单身的成对的或者搞小三小四的,均祝大家新年快乐!存款多多,股票节节攀升,贵金属重演两年前的大跃进,保险打水漂!家人健康,小孩越来越懂事,老婆越来越漂亮,老公越来越能干!!
给了大家这么多祝福,也希望大家在年后有啥缺人的情况喊我一声。

使用IdleTest进行TDD单元测试驱动开发演练(3) 之 ASP.NET MVC的更多相关文章

  1. 使用IdleTest进行TDD单元测试驱动开发演练(2)

    [前言] 1. 有关上篇请参见<使用IdleTest进行TDD单元测试驱动开发演练(1)>,有关本篇用到Entity Framework Code First请参见<使用NuGet助 ...

  2. 使用IdleTest进行TDD单元测试驱动开发演练(1)

    [前言] 开发工具:Visual Studio 2012 测试库:Visual Studio 2012自带的MSTest DI框架:Unity 数据持久层:Entity Framework 前端UI: ...

  3. TDD单元测试驱动

    使用IdleTest进行TDD单元测试驱动开发演练(2)   [前言] 1. 有关上篇请参见<使用IdleTest进行TDD单元测试驱动开发演练(1)>,有关本篇用到Entity Fram ...

  4. TDD测试驱动开发

    TDD测试驱动开发 一.概念 TDD故名思意就是用测试的方法驱动开发,简单说就是先写测试代码,再写开发代码.传统的方式是先写代码,再测试,它的开发方式与之正好相反. TDD是极限编程的一个最重要的设计 ...

  5. 我看TDD测试驱动开发

    今天在实验室给大家介绍了一下TDD和Docker,大家对TDD都比较感兴趣,包括老板,也问了一些问题. 还是从头来说TDD吧,TDD作为敏捷开发领域的领头军,充满魅力,同时也充满争议.一切从三大军规说 ...

  6. 我得新博客上线了采用Vue+Layui的结合开发,后台采用asp.net mvc

    地址:www.zswblog.xyz 写完这个博客项目我真的很开心! 希望博客园的大佬们能去看看,如果可以希望帮我在Layui的年度案例点一个赞,谢谢! 地址:https://fly.layui.co ...

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

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

  8. 行为驱动开发BDD概要

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

  9. 单元测试 – ASP.NET MVC 4 系列

           在开发可测试软件的过程中,单元测试已成为确保软件质量的一个不可或缺部分.测试驱动开发(Test-Driven Development,TDD)是编写单元测试的一种方法,采用该方法的开发人 ...

随机推荐

  1. 项目游戏开发日记 No.0x00000

    14软二杨近星(2014551622) ---恢复内容开始--- 2016-03-17 从开始迈进软件工程专业, 已经快两年了, 记得当初选择软件的理由是, 我要学去做东西, 我享受开发过程. 两年来 ...

  2. 转: 解决Github访问超慢问题

    转自:http://zengrong.net/post/2092.htm 解决Github访问超慢问题 Github is so slowly. 这段时间访问 github 都非常慢,google了一 ...

  3. Xamarin.Android之ContentProvider

    一.前言 掌握了如何使用SQLiteOpenHelper之后,我们就可以进行下一步的学习.本章我们将会学习如何使用ContentProvider来将数据库方面的操作封装起来,同时它还可以供其他应用访问 ...

  4. 【腾讯Bugly干货分享】Android性能优化典范——第6季

    本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/580d91208d80e49771f0a07c 导语 这里是Android性能优 ...

  5. Chrome 控制台不完全指南

    Chrome的开发者工具已经强大到没朋友的地步了,特别是其功能丰富界面友好的console,使用得当可以有如下功效: 更高「逼格」更快「开发调试」更强「进阶级的Frontender」 Bug无处遁形「 ...

  6. TODO:这是一个我的自媒体

    TODO:这是一个我的自媒体 自媒体(外文名:We Media)又称"公民媒体"或"个人媒体",是指私人化.平民化.普泛化.自主化的传播者,以现代化.电子化的手 ...

  7. Docker私有仓库搭建

    # 环境 系统 Linux 3.10.0-123.9.3.el7.x86_64 CentOS 7.0.1406 (Core) Docker 1.12.0, build 8eab29e 1.获取镜像 私 ...

  8. Android笔记——Bundle类的作用

    Bundle类用作携带数据,它类似于Map,用于存放key-value键值对形式的值.相对于Map,它提供了各种常用类型的putXxx()/getXxx()方法,如:putString()/getSt ...

  9. js ajax php分页组件

    github  https://github.com/lihefen/pageList html页面 <!DOCTYPE html><html><head>< ...

  10. Python TODO

    参数类型和用法 所有的双划线方法以及特性 类的构建过程 实例的构建过程 元类MetaClass 装饰器 描述符 迭代器 上下文管理器 生成器 lambda 数据结构,集合 垃圾回收机制 类方法,静态方 ...