The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

ASP.NET MVC 單元測試系列 (2):可測試性 (Testability)

為什麼人家說 ASP.NET MVC 的可測試性很高?這是真的嗎?為什麼實際在寫 ASP.NET MVC 單元測試的時候卻綁手綁腳的,很多時候連怎麼開始寫測試程式都摸不著頭緒。由於 Web 環境下能開發的功能很多,程式碼的多樣性也很大,有時後複雜度也很高,寫出能動的程式已經不容易了,所以以往總是用隨心所欲的方式開發,不過現在不能這樣寫了,為了讓開發過程中都能不斷的撰寫單元測試,有時後你必須妥協,寫程式的時候要時時刻刻想到你寫的這行程式碼需要被測試,所以你要套用一些 設計樣式 (Design Pattern) 來撰寫程式,好讓你未來撰寫測試的日子更加好過。

先來看看一個「不容易測試」的例子,我先建立一個資料庫,並且利用 LINQ to SQL 取得資料:

然後我在 AccountController 控制器中新增一個 Action 程式碼如下,由於該 Controller 中可能會有多個 Action 都會需要用到 DataContext 物件,所以很自然的可能會將該物件提升到類別層級,以省去重複的程式碼:

接著我再寫一個測試方法測試這個 Action,不過這個 Action 無法執行成功,因為我們在 Profile 這個 Action 裡用到了一個類別層級的私有變數 db, 而這個 MyDataContext 型別的變數在測試類別中根本無法進行操作,也無法傳入連線字串:

你只能被迫在測試專案中使用完全一樣的 SQL 連線參數連接資料庫才能進行測試,我們可以將原 Web 專案下 web.config 中 <connectionString> 下的參數原封不動移動到 MvcApplication2.Tests 測試專案的 app.config 檔案的 <connectionString> 區段裡,就可以正常執行了:

不過,你相信這個測試方法嗎?由於資料庫來自外部資源,你只能在資料庫有連線的狀況下進行測試,你並不能保證你每次執行單元測試都會成功,當網路斷線或資料庫維護時,你的測試程式就會引發例外,而這個例外並不是這個被測試者的錯!這裡說的「被測試者」是指 AccountController 控制器下的 Profile 動作方法,由於我們的測試程式碼是 單元測試 ( Unit Testing ),所以我們所測試的目標是這個單元(Unit)的程式邏輯而已,並非 此單元 ( 意指 Profile 這個方法 ) 以外的任何部分,當然也包括資料庫,因為資料庫存取的部分並不隸屬於我們所應測試的範圍內。: 避免外部資源存取的問題我們在本篇文章還不會解決,等學到 Mock 技巧之後這問題自然會迎刃而解!

對於這個 Profile 動作方法,我們將 不容易測試的罪狀 列表如下:

  • 與 MyDataContext 緊緊相依,一個 Action 之中有過多關注點 (程式邏輯 v.s 資料存取)
    : 一般來說程式邏輯資料存取是一個很明顯的判斷依據,看到這兩個部分的程式碼混在一起時就可以試著打散,用鬆散耦合的方式處理。
  • db 物件屬於類別層級的私有變數,無法透過測試方法進行操作
    : 由於各位尚未學到 Mocking 的技巧,在次就先不多做著墨。
  • 雖然我們目前並不測試資料是否存不存在,但資料庫一掛掉單元測試也無法進行,這對單元測試程式來說是個很明顯的警訊!

這時你應該會開始思考,要怎樣改寫才能讓我程式變的「更容易被測試」呢?其實要讓你的程式變的更容易被測試是有方法的,以共通的原則來說就是:「 讓你的程式碼變的更加鬆散耦合(Loosely coupled),讓類別與類別之間的關連性降低,降低到可以個別獨立存在,如此一來便可在彼此互不影響之下完成個別的單元測試,而這些類別又能組合成一個有用的應用程式。( 這概念原本就是物件導向程式開發的基礎,不過由於概念十分抽象,唯有親自動手寫過物件導向程式的人才能夠理解箇中奧妙 )

基於上述原則來開發程式雖然對可測試性有幫助,但事實上還不夠,在實務上我們還是會利用許多現成的程式框架來協助我們的程式變的更容易測試,像是利用 Mocking 容器 (mocking containers) ( 如 moq, NMock, TypeMock, JustMock, … ) 、或 依賴注入框架 (dependency injection) ( 如 autofac, Unity, Ninjet, Castle Windsor, StructureMap, … ) 等等都是非常常見工具框架。

以 MVC 設計樣式來說,就已經將程式切分成三大塊 Model、View、Controller,而 ASP.NET MVC 更是打從一開始就以提升可測試性為目標,像是 Controller 本身就是一個抽象類別,所有的 Controller 都必須繼承他並實做自己的 Action 動作方法,他不再有像之前 ASP.NET Web Form 的時候有那種 ASPX 與 Code Behind 緊緊相連的狀況,Controller 幾乎可以視為一個單純的 .NET 類別(Class)來操作,甚至於不需要有真實的 HttpContext 環境都能夠執行,而這就是 ASP.NET MVC 的可測試性比 ASP.NET Web Form 高出許的主因。

ASP.NET MVC 的另一個重點在於「關注點分離」,這也是體現程式碼 鬆散耦合 (Loosely coupled) 的絕佳表現,它把 ASP.NET MVC 的許多部分都設計成 可抽換的 (Pluggable) 零件,讓 ASP.NET MVC 的每個部分都可以獨立存在,並且可以獨立到可以針對每一個部分撰寫單元測試,而且還可以不用擔心測試目標方法與其他部分程式會互相影響 (當然這並非一定,有原則就有例外)。

非廣告:若想瞭解 ASP.NET MVC 的運作原理,建議可購買 ASP.NET MVC 2 開發實戰 回去參考。

在 ASP.NET 的框架裡,其實定義了許多靜態類別來處理許多 Web 環境下的工作,像是 HttpContext 或 HttpRequest 等等這些常用的物件基本上跳離 Web 環境就無法執行,這對於可測試性來說是一大傷害,你可以假設單元測試專案其實只是一個 Console Application 而已,為了要測試 ASP.NET 的程式需要預先設定或模擬出多少已知與未知的變數才能讓測試程式跑下去?因此從 .NET Framework 3.5 SP1 開始,新增了一個 System.Web.Abstraction.dll 組件 ( 在 .NET 4.0 已經被合併至 System.Web.dll 組件 ),這個組件包含了如下圖幾個非常特殊的類別,而且這些類別都被 ASP.NET MVC 大量的使用,以降低 ASP.NET MVC 跟 System.Web 命名空間裡的那些不容易測試的類別之間的耦合關係,透過一層抽象化的包裝之後,ASP.NET MVC 將會變的非常容易透過 Mocking 技術通過單元測試。如下圖這幾個名稱為 xxxxBase 的類別全部都是抽象類別,都是為了方便你做 Mocking 之用,而 xxxxWrapper 則是方便在 ASP.NET MVC 之中取用原本 ASP.NET 所提供那些功能。

下圖是利用 NDepend 工具分析出 ASP.NET MVC 與其相關組件的關連,節點越大的項目代表程式碼越多,彼此與彼此間的線條粗細代表組件之間的關連,雖然 System.Web.Abstraction 組件的內容不多,不過可以參數該組件與 System.Web 組件的關係非常密切:(點圖可放大顯示)

最後,我們來改寫原本不容易測試的程式,改的讓他更容易測試些。為了遵循物件導向程式設計的基本原則 ( 鬆散耦合 ) 以及關注點分離原則,我們將所有與資料存取有關的程式碼全部都移到獨立的類別去,類別名稱我們取為 MemberRepository 並新增此類別到 Models 目錄下:

其 MemberRepository.cs 的內容僅包括資料庫存取的部分如下,不但把資料庫存取的程式碼抽離成獨立專案,甚至於可以在使用的時候傳入不同的連線參數連接不同資料庫 (例如測試資料庫開發資料庫):

using System.Linq;

namespace MvcApplication2.Models
{
  public class MemberRepository
  {
    MyDataContext db;

    public MemberRepository()
    {
      db = new MyDataContext();
    }

    public MemberRepository(string connstr)
    {
      db = new MyDataContext(connstr);
    }

    public Member GetMemberByAccount(string account)
    {
      var q = from p in db.Member where p.Account == account select p;

      return q.FirstOrDefault();
    }
  }
}

然後,我們改寫 AccountController 控制器類別中的程式碼,將原本加入的程式碼全部移除,並修改成以下程式碼,你可以發現我們在類別層級雖然共用了一個私有變數,但是我們在 AccountController 新增兩個建構子,第一個預設的建構子用來幫我們建立起 MemberRepository 類別實體,第二個建構子則傳入 MemberRepository 物件,這個建構子在之後我們撰寫單元測試時非常重要的一個部分,這部分下段再做說明。最後我們的 Profile 動作方法則直接呼叫 MemberRepository 物件 _r 的 GetMemberByAccount 方法取得會員資料,並傳給 ViewData.Model 強型別物件。

再來我們來修改測試方法,將原本的測試方法移除,並改成透過測試方法來產生 MemberRepository 物件,並傳入 AccountController 進行測試,這在裡我們還可以指定單元測試專案專用的連線參數:

一個小小的變化,讓一個不容易測試的程式碼變的更容易測試了,其實我們改動的幅度並沒有很大,僅僅將資料庫存取的部分切割出來,變成一個獨立的 MemberRepository 類別而已。

從上述的程式碼演變,你有沒有看出要如何寫出易於測試的程式碼了呢?讓我們重點複習一下:

  • 讓你的程式碼變的更加鬆散耦合(Loosely coupled),本篇文章的範例過於簡單,還不夠鬆散耦合!
  • 善用關注點分離的特性,讓可以獨立存在的程式碼獨立出去(有目的性的,寫程式一定要有感覺),不但程式碼更容易閱讀與理解,更能夠便於測試!

可測試性的提升不只這樣,今天講的原則只有一個簡單的目的,就是讓初學者知道往「可測試性」這條路的方向要怎麼走,由於我們還沒用到 Mocking 技巧,下回我們再來看看還有沒有什麼更方便的測試技巧或測試工具可用,敬請期待!

單元測試的路途還很長的呢,歡迎各位留言探討問題,如有謬誤之處敬請指教,謝謝。

程式碼下載

相關連結