使用Asp.Net MVC 動態產生View Part.3 - 動態頁面功能Testing

在上一篇文章(使用Asp.Net MVC 動態產生View Part.2 - 使用IViewEngine、IView)中,利用了IViewEngine, IView完成了動態頁面的產生。然而,動態產生頁面的功能是非常需要在每次上版前執行測試,這樣的功能當出現bug時,在第一時間就會被使用者發現,所以必須確保每次上版時,動態產生頁面的功能是正確的。

在本篇文章中,將分別針對單元測試(針對Action)與整合測試兩個部份說明。另外,範例將會使用

在這裡將不會特別說明上述套件如何使用,如果有需要Specflow教學可以到91哥的點部落文章有詳細說明,另一個套件Nsubstitute可至官網有詳細說明。

單元測試

單元測試的部分,這邊只針對Controller的Action進行測試,其他部分將不在此討論說明。

在開始寫測試前,需先將上一篇文章中的範例把Action以外用到HtmlGeneratorHelper的地方改為由外部注入,除Controller以外的類別不與HtmlGeneratorHelper產生耦合。修改的部分為

DynamicViewController.cs

public class DynamicViewController : Controller
{
    public IHtmlGeneratorHelper HtmlGeneratorHelper;

    public DynamicViewController()
    {
        this.HtmlGeneratorHelper = new HtmlGeneratorHelper();
    }

    public ActionResult GetView(string pageName, string id)
    {
        //// remove default view engine
        this.ViewEngineCollection.Clear();

        //// add custom view engine
        this.ViewEngineCollection.Add(new CustomViewEngine(this.HtmlGeneratorHelper));

        var view = string.Format("{0}-{1}", id, pageName);

        return View(view);
    }
}

CustomViewEngine.cs

private IHtmlGeneratorHelper _htmlGenerator;

public CustomViewEngine(IHtmlGeneratorHelper htmlGeneratorHelper)
{
    this._htmlGenerator = htmlGeneratorHelper;
}

/// <summary>
/// Fined view
/// </summary>
/// <param name="controllerContext">controller context</param>
/// <param name="viewName">view name</param>
/// <param name="masterName">layout view</param>
/// <param name="useCache">use chace</param>
/// <returns>ViewEngineResult</returns>
public ViewEngineResult FindView(ControllerContext controllerContext,
        string viewName, string masterName, bool useCache)
{
    var viewObj = SplitViewName(viewName);

    return new ViewEngineResult(new CustomView(viewObj.Item2, viewObj.Item1, this._htmlGenerator), this);
}

/// <summary>
/// Find partial view
/// </summary>
/// <param name="controllerContext">controller context</param>
/// <param name="partialViewName">partial view name</param>
/// <param name="useCache">use cache</param>
/// <returns>ViewEngineResult</returns>
public ViewEngineResult FindPartialView(ControllerContext controllerContext,
       string partialViewName, bool useCache)
{
    var viewObj = SplitViewName(partialViewName);

    return new ViewEngineResult(new CustomView(viewObj.Item2, viewObj.Item1, this._htmlGenerator), this);
}

另外,為了讓單元測試與整合測試能夠用同一組瀏覽頁面時所傳入的參數,需要將HtmlGeneratorHelper.cs也稍微修改一下

public string GetHtml(string pageName, string id)
{
    StringBuilder sb = new StringBuilder();
    var content = string.Empty;

    //// 根據pagename給出不同的content
    content = string.Format("<div>I am {0}</div><div>PageName : {1}</div>", id, pageName);
    sb.AppendLine(content);

    return sb.ToString();
}

測試案例

修改好上一篇文章的範例後,就可以開始寫測試案例。動態頁面的需求中,我們希望能夠透過ID以及PageName來取得使用者編輯產生的動態頁面HTML,所以測試案例會像這樣

動態頁面產生驗證

步驟一 : 先設定使用者編輯好的HTML

步驟二 : 瀏覽頁面時所傳入的參數(ID, PageName)

步驟三 : 呼叫Action

步驟四 : 驗證實際產出的HTML與期望的HTML是否相同

測試程式

寫好測試案例後,就可以開始撰寫測試程式碼,利用Specflow幫我們產出測試程式的骨幹後,就一塊一塊將程式碼寫入對應的區塊。由於是針對Controller的Action進行單元測試,所以必須產生一個假HtmlGeneratorHelper物件,當輸入的參數為測試案例中的ID與PageName時,會傳回指定的HTML。

設定已經編輯好的動態頁面HTML

[Given(@"使用者儲存專屬頁面HTML為")]
public void Given使用者儲存專屬頁面HTML為(Table table)
{
    this._oriHtml = table.Rows[0]["HTML"];
}

設定傳入的參數,另外指定呼叫假HtmlGeneratorHelper物件的GetHtml方法時,如果傳入參數為"唐伯虎"、"9527",則回傳指定的HTML

[Given(@"瀏覽頁面時所傳入的參數")]
public void Given瀏覽頁面時所傳入的參數(Table table)
{
    this._id = table.Rows[0]["ID"];
    this._pageName = table.Rows[0]["PageName"];

    var returnHtml = string.Format(this._oriHtml, this._id, this._pageName);
    this._stubHtmlGeneratorHelper.GetHtml(this._pageName, this._id).Returns(returnHtml);
}

產生測試對象,並將target裡的HtmlGeneratorHelper設定為這邊產生的假物件

[When(@"傳回頁面時")]
public void When傳回頁面時()
{
    var httpContext = GetHttpContext();
    var target = ControllerFactory.CreateController<DynamicViewController>(httpContext);
    var viewResult = target.GetView(this._pageName, this._id) as ViewResult;
    target.HtmlGeneratorHelper = this._stubHtmlGeneratorHelper;

    viewResult.ExecuteResult(target.ControllerContext);

    this._actualViewHeml = httpContext.Response.Output.ToString();

    //// remove \r\n
    this._actualViewHeml = this._actualViewHeml.Replace(System.Environment.NewLine, string.Empty);
}

取得GetView的回傳物件後,將其轉型為ViewResult。可能有人會覺得奇怪,GetView的方法簽章上明明是回傳ActionResult,怎麼可以轉型為ViewResult?這是因為ViewResult繼承了ViewResultBase, ViewResultBase又繼承了ActionResult,所以可以轉型為ViewResult。

那為甚麼需要將回傳的物件轉型為ViewResult呢?因為GetView回傳的物件其實尚未Render出HTML,需要透過呼叫ViewResult的ExcuteResult才會Render出HTML並且存放到HttpContext.Response.Output中。最後在將HttpContext.Response.Output.ToString()即可取得頁面的HTML。

此外,在這邊有兩點需要特別說明

  1. 產生HttpContext的過程有許多屬性需要設定,所以將這些步驟獨立為一個方法

    private HttpContext GetHttpContext()
    {
        var httpRequest = new HttpRequest("", "http://localhost/DynamicView/GetView", "");
        var httpContext = new HttpContext(httpRequest, new HttpResponse(new StringWriter()));
        var userAgent = "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.2.10) " +
             "Gecko/20100914 Firefox/3.6.10";
        var browser = new HttpBrowserCapabilities
        {
            Capabilities = new Hashtable { { string.Empty, userAgent } }
        };
        var factory = new BrowserCapabilitiesFactory();
        factory.ConfigureBrowserCapabilities(new NameValueCollection(), browser);
    
        httpContext.Request.Browser = browser;
    
        return httpContext;
    }
    
  2. 由於測試對象為Controller的Action,在產生controller步驟繁瑣,所以透過另一個類別方法將產生controller的過程封裝起來,只需要呼叫方法時將一個HttpContext傳入。

    public static T CreateController<T>(HttpContext context, RouteData routeData = null)
                    where T : Controller, new()
    {
        T controller = new T();
    
        HttpContextBase wrapper = null;
        wrapper = new HttpContextWrapper(context);
    
        if (routeData == null)
        {
            routeData = new RouteData();
        }
    
        // add the controller routing if not existing
        if (!routeData.Values.ContainsKey("controller") && !routeData.Values.ContainsKey("Controller"))
        {
            routeData.Values.Add("controller", controller.GetType().Name
                                                        .ToLower()
                                                        .Replace("controller", ""));
        }
    
        controller.ControllerContext = new ControllerContext(wrapper, routeData, controller);
        return controller;
    }
    

最後,比對實際產生的HTML與預期的HTML是否一致

[Then(@"該頁面的HTML為")]
public void Then該頁面的HTML為(Table table)
{
    var expectedHtml = table.Rows[0]["HTML"];

    Assert.AreEqual(expectedHtml, this._actualViewHeml);
}

實際執行測試,執行後得到綠燈

Unit test result

整合測試

整合測試中將不會使用任何假物件,完全使用Production code。

測試案例

整合測試的測試案例與單元測試雷同,唯一不一樣的是不需要假設使用者所編輯儲存的HTML

integration test case

因為不在使用假的HtmlGeneratorHelper物件,在Production code裡HtmlGeneratorHelper會根據傳入的id、pagename參數回傳一組字串

<div>I am {id}</div>
<div>PageName : {pagename}</div>

所以在Then的部分的驗證字串是這樣來的。

測試程式

整合測試的程式碼與單元測試大致相同,但是不再需要產生假的HtmlGeneratorHelper物件。實際執行測試,執行後得到綠燈。

integration test success

結論

在上述的說明中,介紹了如何針對產生動態頁面的功能進行單元測試與整合測試,這樣的方法不單單只是可以用在動態頁面,在一般Asp.net MVC中的View也可以進行這樣的測試。但是,有一點必須注意的是

執行View頁面的測試用意是什麼和想要測試什麼

為什麼這樣說呢?在動態頁面這樣的功能中,每次頁面HTML的產生都是由資料庫或是其他儲存方式中取得樣板,在套入ViewModel產生頁面的HTML,整個過程都是我們自己處理,所以這樣的方式需要在每次上版前都經過測試確認沒有問題。然而,Asp.net MVC原本的頁面產生方式,都是靠系統的RazorEngine產生,一般來說並不會修改到這個部分的程式碼,所以測試的必要性就沒有來得那麼重要。但是,並不是說使用Asp.net MVC原本的頁面產生方式就不需要測試,而是應該針對頁面上的某一段HTML、特定的功能或是複雜的頁面進行測試,而不是對整頁的HTML測試。

參考資料

免責聲明:

"文章一定有好壞,文章內容有對有錯,使用前應詳閱公開說明書"