使用IdleTest進行TDD單元測試驅動開發演練(3) 之 ASP.NET MVC

1、【前言】html

(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」,這兩個項目也是我提供的源碼連接中本文的關注點,最後去完善實現代碼。前端


2、爲測試準備相應代碼web

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容器,避免每次請求都要註冊而影響性能,這也是按照微軟提供的模板中的方式來作。ide

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進行斷言。post

    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

 


3、針對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. 測試經過後,檢查覆蓋率,以下圖所示


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. 運行覆蓋率分析,以下圖所示


5、總結

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

相關文章
相關標籤/搜索