天天看點

JS&CSS檔案請求合并及壓縮處理研究(四)

本篇将會嘗試對之前的代碼進行相關的單元測試,驗證路徑合并規則的覆寫率及正确性。

熟悉 ASP.NET MVC 開發的朋友應該知道,微軟在MVC架構下內建了一款名為 Microsoft.VisualStudio.QualityTools.UnitTestFramework 的單元測試架構。這樣我們就不再需要引用第三方諸如NUnit等測試架構了(順便少受點Java同學的白眼:D)。而 Microsoft.VisualStudio.QualityTools.UnitTestFramework 測試架構的用法,和 NUnit 其實并沒有什麼大的差別。

對于ASP.NET MVC 應用程式來說,Controller作為連接配接View與Model的橋梁,很多時候我們都有必要對其穩定性和正确性建立針對的單元測試。這個過程在MVC中可以很容易的完成。下面我們就實際示範一下。

定位到Mcmurphy.Tests項目,添加引用:

(1),Mcmurphy.Web。我們的Controller并沒有單獨建立項目,而是存放于Mcmurphy.Web項目的Controllers下。是以對Controller的測試需要添加其引用。

(2),Microsoft.VisualStudio.QualityTools.UnitTestFramework 這是上面提到的微軟在MVC中內建的單元測試架構。

接下來我們在Mcmurphy.Tests項目中,建立 HomeControllerTest.cs檔案,添加以下代碼:

using System.Web.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MvcResourceHandle.Controllers;
namespace Mcmurphy.Tests
{
    [TestClass]
    public class HomeControllerTest
    {
        [TestMethod]
        public void Index()
        {
            var controller = new HomeController();
            var result = controller.Index() as ViewResult;
            Assert.AreEqual("welcome to chengdu", result.ViewBag.Message);
        }
    }
}      

然後我們修改 Mcmurphy.Web 項目Controllers目錄下的HomeController.cs檔案。調整一下 Index Action:

public ActionResult Index()
{
       ViewBag.Message = "welcome to chengdu";
       return View();
}      

接下來我們:

JS&CSS檔案請求合并及壓縮處理研究(四)

然後VS會彈出一個測試結果的對話框:

JS&CSS檔案請求合并及壓縮處理研究(四)

沒錯,就是這麼簡單。換作NUnit,我們隻需要将 TestClass => TestFixture,TestMethod => Test。關于NUnit在此不再贅述。相關資訊可以百度:TestDriven.NET。從名字就可以看出來,這又是從Java移植過來的哈。

這裡我們用到了 Microsoft.VisualStudio.QualityTools.UnitTestFramework 最常用的兩個屬性,TestClassAttribute 和 TestMethodAttribute。當然還有一些比較有用的屬性,比如 IgnoreAttribute,TimeoutAttribute,TestInitializeAttribute,AssemblyInitializeAttribute等。有興趣的朋友可以了解下。

關于單元測試,還有一個比較重要的概念,就是Assert(斷言)。顧名思義,就是對某一個結果進行事先的預測和判定。下面列出一些常見的單元測試斷言:

Assert.AreEqual()            //測試指定的值是否相等,如果相等,則測試通過
Assert.IsTrue()              //測試指定的條件是否為true,如果為true,則測試通過
Assert.IsFalse()             //測試指定的條件是否為false,如果為false,則測試通過
Assert.IsNull()              //測試指定的對象是否為空,如果為空,則測試通過
Assert.IsNotNull()           //測試指定的對象是否非空,如果不為空,則測試通過
Assert.IsInstanceOfType()     //測試指定的對象是否為某一個類型的執行個體,如果是,則測試通過      

Okay,對MVC的單元測試有了相應的知識儲備之後,接下來我們開始“資源檔案路徑合并規則”的單元測試。

由于我們的AppendResFile、RemoveResFile、RenderResFile等方法擴充自 HtmlHelper ,而HtmlHelper對象又是HttpContext相關的。這裡又牽涉到另外一個問題,即HttpContext是很難進行模拟的(Mock)。為了提高單元測試的可行性,微軟随ASP.NET MVC釋出了一個“抽象包”,專門用于對 HttpContext 及其相關元件進行抽象。這裡我們會用到這個抽象包裡面的 HttpContextBase 和 HttpRequestBase。(對應早先版本的 IHttpContext和 IHttpRequest)。

先一睹HttpContextBase的源碼(部分截圖):

JS&CSS檔案請求合并及壓縮處理研究(四)

可以看到雖然 HttpContextBase 是一個抽象類,但其實裡面的每個方法都有一個預設的實作(throw new NotImplementedException())。這樣我們在測試中模拟 HttpContext對象時,隻需要繼承HttpContextBase實作自己關注的成員即可。

定位到Mcmurphy.Tests項目,建立 CombineTest.cs 類,添加以下代碼:

/// <summary>
        /// HttpContext模拟類
        /// </summary>
        public class MockHttpContext : HttpContextBase
        {
            //覆寫 HttpRequest,便于模拟其它請求資訊
            public override HttpRequestBase Request
            {
                get
                {
                    return MockRequest;
                }
            }

            public HttpRequestBase MockRequest { get; set; }

            IDictionary dict = new Dictionary<string, object>();
            //因為我們将資源檔案暫存于 HttpContext.Items 中,是以需要覆寫Items
            public override IDictionary Items
            {
                get { return dict; }
            }
        }

        /// <summary>
        /// HttpRequest模拟類
        /// </summary>
        public class MockHttpRequest : HttpRequestBase
        {
            //覆定Form。可以在其中模拟請求資料。
            public override NameValueCollection Form
            {
                get
                {
                    return MockForm;
                }
            }
            public NameValueCollection MockForm { get; set; }
        }          

對于最終需要模拟的 HtmlHelper,我們看一下它的兩個構造函數:

public HtmlHelper(System.Web.Mvc.ViewContext viewContext, System.Web.Mvc.IViewDataContainer viewDataContainer)

public HtmlHelper(System.Web.Mvc.ViewContext viewContext, System.Web.Mvc.IViewDataContainer viewDataContainer, System.Web.Routing.RouteCollection routeCollection)      

這裡我們不需要構造System.Web.Routing.RouteCollection參數。是以選擇第一個構造函數。

是以,我們需要建立 System.Web.Mvc.ViewContext 和 System.Web.Mvc.IViewDataContainer,以滿足HtmlHelper對象的建立。

對于 System.Web.Mvc.IViewDataContainer 接口,直接執行個體化 System.Web.Mvc.ViewPage 對象,ViewPage 實作了 IViewDataContainer 接口。而執行個體化ViewPage的前提,則是建立 ViewContext 對象。是以我們可以編寫以下代碼:

/// <summary>
        /// 擷取HtmlHelper執行個體
        /// </summary>
        /// <returns></returns>
        private HtmlHelper GetHtmlHelper()
        {
            var page = new ViewPage
                           {
                               ViewContext = new ViewContext(
                                   new ControllerContext(),
                                   new MyView(""), //自定義視圖
                                   new ViewDataDictionary(),
                                   new TempDataDictionary(),
                                   new StringWriter())
                           };

            var mockHttpContext = new MockHttpContext();
            var mockHttpRequest = new MockHttpRequest();
            mockHttpContext.MockRequest = mockHttpRequest;
            page.ViewContext.HttpContext = mockHttpContext;
            var htmlHelper = new HtmlHelper(page.ViewContext, page);
            return htmlHelper;
        }      

通過上述方法,我們就可以擷取到模拟的 HtmlHelper 對象。但在 ViewContext 的構造函數中,我們傳入了 new MyView("") 的參數,也即是自定義 View。這又是個什麼東東?

檢視ViewContext的構造函數,我們得知這是一個IView接口類型。IView是MVC中定義視圖所需方法的一個接口,其實它也就定義了一個方法 : Render。MSDN的解釋為:使用指定的編寫器對象來呈現指定的視圖上下文。這句話比較繞口。這麼說吧,我們常用RazorViewEngine内部就是使用RazorView向頁面渲染資料的,而RazorView就是實作了IView接口。SO,如果我們要編寫自己的視圖引擎,IView的實作是重中之重。下面,我們嘗試完成一個簡單的 MyView,代碼如下:

     /// <summary>
        /// 自定義的視圖
        /// 視圖需要繼承 IView 接口
        /// </summary>
        public class MyView : IView
        {
            // 視圖檔案的實體路徑
            private readonly string _viewPhysicalPath;

            public MyView(string viewPhysicalPath)
            {
                _viewPhysicalPath = viewPhysicalPath;
            }

            /// <summary>
            /// 實作 IView 接口的 Render() 方法
            /// </summary>
            public void Render(ViewContext viewContext, TextWriter writer)
            {
                // 擷取視圖檔案的原始内容  
                string rawContents = File.ReadAllText(_viewPhysicalPath);

                // 根據自定義的規則解析原始内容  
                string parsedContents = Parse(rawContents, viewContext.ViewData);

                // 呈現出解析後的内容
                writer.Write(parsedContents);
            }

            public string Parse(string contents, ViewDataDictionary viewData)
            {
                // 對 {##} 之間的内容作解析
                return Regex.Replace
                (
                    contents,
                    @"\{#(.+)#\}",

                    // 委托類型 public delegate string MatchEvaluator(Match match)
                    p => GetMatch(p, viewData)
                );
            }

            protected virtual string GetMatch(Match m, ViewDataDictionary viewData)
            {
                if (m.Success)
                {
                    // 擷取比對後的結果,即 ViewData 中的 key 值,并根據這個 key 值傳回 ViewData 中對應的 value
                    string key = m.Result("$1");
                    if (viewData.ContainsKey(key))
                    {
                        return viewData[key].ToString();
                    }
                }
                return string.Empty;
            }
        }      

上面的MyView僅僅對頁面中的“占位符”用ViewData中的值進行了簡單的替換。如果我們打算獨立的使用這個MyView對頁面輸出進行渲染,則可以像下面這樣操作:

public ActionResult Index()
        {
            MyView myView = new MyView();
            ViewData["userName"] = "mcmurphy";
            ViewResult result = new ViewResult();
            result.View = myView;
            return result;
        }      

關于自定義視圖引擎的更多資訊,可以參考:

http://www.codeproject.com/Articles/294297/Creating-your-own-MVC-View-Engine-into-MVC-Applica

Okay,切換回文章的Master分支。有了上面的準備工作。下面就可以對之前的路徑合并規則進行測試。比如:  

[TestMethod]
        public void test1()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/A");
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/B");

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderA/A,B]&compress");

            Assert.AreEqual(expectedStr, result);
        }      

其中 FilterRenderResult 方法是對合并後的路徑進行一個簡單過濾:

/// <summary>
        /// 過濾合并後的路徑
        /// </summary>
        /// <param name="renderString"></param>
        /// <returns></returns>
        private static string FilterRenderResult(MvcHtmlString renderString)
        {
            var matchs = Regex.Matches(renderString.ToString(), "(?<=<script[^>]*src=['\"]?)[^'\"> ]*");
            return matchs[1].ToString();
        }      

關于單元測試,其實最主要也最耗時的工作就是測試用例的編寫。下面鄙人就貼出完整的CombineTest單元測試類代碼,對常用的合并規則進行了測試覆寫。

JS&amp;CSS檔案請求合并及壓縮處理研究(四)
JS&amp;CSS檔案請求合并及壓縮處理研究(四)
using System;
using System.Collections;
using System.Collections.Specialized;
using System.IO;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Mvc;
using Mcmurphy.Extension;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Mcmurphy.Component.Enumeration;

namespace Mcmurphy.Tests
{
    [TestClass]
    public class CombineTest
    {
        #region 測試準備

        /// <summary>
        /// HttpContext模拟類
        /// </summary>
        public class MockHttpContext : HttpContextBase
        {
            public override HttpRequestBase Request
            {
                get
                {
                    return MockRequest;
                }
            }

            public HttpRequestBase MockRequest { get; set; }

            IDictionary dict = new Dictionary<string, object>();

            public override IDictionary Items
            {
                get { return dict; }
            }
        }

        /// <summary>
        /// HttpRequest模拟類
        /// </summary>
        public class MockHttpRequest : HttpRequestBase
        {
            public override NameValueCollection Form
            {
                get
                {
                    return MockForm;
                }
            }
            public NameValueCollection MockForm { get; set; }
        }

        /// <summary>
        /// 自定義的視圖
        /// 視圖需要繼承 IView 接口
        /// </summary>
        public class MyView : IView
        {
            // 視圖檔案的實體路徑
            private readonly string _viewPhysicalPath;

            public MyView(string viewPhysicalPath)
            {
                _viewPhysicalPath = viewPhysicalPath;
            }

            /// <summary>
            /// 實作 IView 接口的 Render() 方法
            /// </summary>
            public void Render(ViewContext viewContext, TextWriter writer)
            {
                // 擷取視圖檔案的原始内容  
                string rawContents = File.ReadAllText(_viewPhysicalPath);

                // 根據自定義的規則解析原始内容  
                string parsedContents = Parse(rawContents, viewContext.ViewData);

                // 呈現出解析後的内容
                writer.Write(parsedContents);
            }

            public string Parse(string contents, ViewDataDictionary viewData)
            {
                // 對 {##} 之間的内容作解析
                return Regex.Replace
                (
                    contents,
                    @"\{#(.+)#\}",

                    // 委托類型 public delegate string MatchEvaluator(Match match)
                    p => GetMatch(p, viewData)
                );
            }

            protected virtual string GetMatch(Match m, ViewDataDictionary viewData)
            {
                if (m.Success)
                {
                    // 擷取比對後的結果,即 ViewData 中的 key 值,并根據這個 key 值傳回 ViewData 中對應的 value
                    string key = m.Result("$1");
                    if (viewData.ContainsKey(key))
                    {
                        return viewData[key].ToString();
                    }
                }
                return string.Empty;
            }
        }

        #endregion

        #region 輔助方法

        /// <summary>
        /// 擷取HtmlHelper執行個體
        /// </summary>
        /// <returns></returns>
        private HtmlHelper GetHtmlHelper()
        {
            var page = new ViewPage
                           {
                               ViewContext = new ViewContext(
                                   new ControllerContext(),
                                   new MyView(""), 
                                   new ViewDataDictionary(),
                                   new TempDataDictionary(),
                                   new StringWriter())
                           };

            var mockHttpContext = new MockHttpContext();
            var mockHttpRequest = new MockHttpRequest();
            mockHttpContext.MockRequest = mockHttpRequest;
            page.ViewContext.HttpContext = mockHttpContext;
            var htmlHelper = new HtmlHelper(page.ViewContext, page);
            return htmlHelper;
        }

        /// <summary>
        /// 過濾渲染渲染結果字元串。
        /// 主要是去掉傳回結果中的 compress
        /// </summary>
        /// <param name="renderString"></param>
        /// <returns></returns>
        private static string FilterRenderResult(MvcHtmlString renderString)
        {
            var matchs = Regex.Matches(renderString.ToString(), "(?<=<script[^>]*src=['\"]?)[^'\"> ]*");
            return matchs[1].ToString();
        }

        #endregion

        #region 測試方法

        /// <summary>
        /// 驗證同檔案夾合并
        /// </summary>
        [TestMethod]
        public void SameFolderText()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/A");
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/B");

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderA/A,B]&compress");

            Assert.AreEqual(expectedStr, result);
        }

        /// <summary>
        /// 驗證同檔案夾合并
        /// </summary>
        [TestMethod]
        public void SameFolderText1()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "[folderA/A,B][folderA/C,D]", "");

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderA/A,B,C,D]&compress");

            Assert.AreEqual(expectedStr, result);
        }

        /// <summary>
        /// 驗證不同分組分開渲染
        /// </summary>
        [TestMethod]
        public void GroupTest()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/A","groupA");
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/B","groupB");

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderA/B]&compress");

            Assert.AreEqual(expectedStr, result);
        }

        /// <summary>
        /// 驗證不同檔案夾合并
        /// </summary>
        [TestMethod]
        public void DiffFolderTest1()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/A");
            htmlHelper.AppendResFile(ResourceType.Script, "folderB/A");

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderA/A][folderB/A]&compress");

            Assert.AreEqual(expectedStr, result);
        }

        /// <summary>
        /// 驗證不同檔案夾合并
        /// </summary>
        [TestMethod]
        public void DiffFolderTest2()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "[folderA/A][folderB/A]");

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderA/A][folderB/A]&compress");

            Assert.AreEqual(expectedStr, result);
        }

        /// <summary>
        /// 驗證優先級
        /// </summary>
        [TestMethod]
        public void PriorityTest()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "folderA/A", "");
            htmlHelper.AppendResFile(ResourceType.Script, "folderB/A", "", PriorityType.High);

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderB/A][folderA/A]&compress");

            Assert.AreEqual(expectedStr, result);
        }

        /// <summary>
        /// 綜合測試,
        /// 優先級不同的同檔案夾不會合并
        /// </summary>
        [TestMethod]
        public void CompTest1()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "[folderA/A][folderB/A][folderC/A]", "");
            htmlHelper.AppendResFile(ResourceType.Script, "folderB/B", "", PriorityType.High);

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderB/B][folderA/A][folderB/A][folderC/A]&compress");

            Assert.AreEqual(expectedStr, result);
        }

        /// <summary>
        /// 綜合測試,
        /// 優先級本同的同檔案夾合并
        /// </summary>
        [TestMethod]
        public void CompTest2()
        {
            var htmlHelper = GetHtmlHelper();
            htmlHelper.AppendResFile(ResourceType.Script, "[folderA/A][folderB/A][folderA/B]", "");
            htmlHelper.AppendResFile(ResourceType.Script, "folderB/B");

            var renderString = htmlHelper.RenderResFile(ResourceType.Script);
            string result = FilterRenderResult(renderString);

            var expectedStr = String.Format("Resource/script?href=[folderA/A,B][folderB/A,B]&compress");

            Assert.AreEqual(expectedStr, result);
        }
        #endregion
    }
}      

View Code