一、【前言】
(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最後一篇博文了吧,不管認識的不認識的,碼農或非碼農的,單身的成對的或者搞小三小四的,均祝大家新年快樂!存款多多,股票節節攀升,貴金屬重演兩年前的大躍進,保險打水漂!家人健康,小孩越來越懂事,老婆越來越漂亮,老公越來越能幹!!
給了大家這麼多祝福,也希望大家在年後有啥缺人的情況喊我一聲。