從頭建置一個MSTest單元測試專案

之前工作時就有接觸到單元測試(Unit Test),但僅限於使用別人寫好的單元測試而已。目前工作上剛好有機會能自己寫單元測試,就把整個從建立但撰寫到執行單元測試的過程記錄下來。

使用的單元測試框架為.Net Core內建的MS Test。

再開始寫單元測試前,介意先看一下保哥寫的單元測試系列文,建立一些對於單元測試的觀念=>保哥-單元測試相關系列文章

1.測試的最小單位,必須是可信任的、可重複執行的。
例如: 測試某一個類別(Class)的某一個方法(Method)

2.必須與「整合測試」做非常清楚的切割,兩者個概念完全不同

3.單元測試程式不應該接觸到任何與任何 外部資源 (External Resources) 或 靜態物件 (Statics)。例如: File I/O, 資料庫操作, 網路連線, … 等等,且由於靜態方法會牽扯到狀態,所以不建議在單元測試中存取任何靜態物件。

4.DAL (Data Access Layer) 的程式不建議撰寫「單元測試」

因為 DAL 的程式會與資料庫直接產生關聯,而資料庫中可能還會有 Trigger, Stored Procedure, 表格關聯, … 等,這些東西都會打破「單元測試」的原則,所以建議放入「整合測試」來進行。

5.在「單元測試」類別裡,通常不會用到 Setup Attribute 與 Teardown Attribute,如果為了要簡化各 TestMethod 中許多重複的程式碼,建議使用 Factory method Pattern 來降低個別 TestMethod 之間的耦合關係。

6.開發人員在撰寫程式時,務必要讓所有測試結果都亮起綠燈,沒有藉口。


  • 為什要寫單元測試

1.程式寫完時,可以做最基本的正常流程測試。執行時丟入input,檢核function是否會回傳對應的output,來驗證功能是否能正常執行。

2.未來重構時,可以先透過單元測試的不同測試案例,發現有可能的error。例如,未來重構程式的是另外一個工程師,他可能在重寫function時,少給了一個重要的參數。或是某個參數不能為null,但卻為null。像上述的兩種情境,就可以透過單元測試裡一直新增的測試案例先檢查找出錯誤,讓程式的錯誤率越來越低。


  • 開始建立單元測試專案

1.首先,先在方案右鍵加入=>新增專案,選擇MSTest測試專案

2.選擇檢視=>測試總管,可以看到測試TestClass跟TestMethod清單


  • 開始撰寫Test Method

1.撰寫測試方法,我主要會以Service層裡面的Method為主來撰寫Test Method。首先我們先到要產生測試類別的class,右鍵=>建立單元測試

測試專案選擇我們剛剛建立好的LCM.Tests。沒有手動建立的話,可以直接在測試專案選擇<新測試專案>,並設定命名空間(namespace)

按下確定後,便會由Service的class自動產生TestClass以及其他對應的TestMethod。

接下來我們要把TestMethod的名稱改成一看就能明瞭這支TestMethod主要在驗證什麼東西的名字,單元測試他不是整合測試,主要是看此TestMethod能不能成功被執行完畢,至於執行過程中會遇到的外部情形(例如: File I/O, 資料庫操作, 網路連線, … 等等)則不予測試,故我們的名稱要能讓人知道主要是著重在驗證什麼東西。

以原本的InsertS18Test這個TestMethod來看,他主要是再做兩件事情:

1.把excel內容(DataTable)進行一些商業邏輯檢核,並轉換為後List 

2.進行資料Insert

依據先前所提到的,單元測試不能被外部資源影響。上面的Insert資料因為會透過資料庫,所以就是屬於一個外部資源的行為。所以Insert(BulkInsert)的這個部分,我們就要透過模擬的方式來取代Insert(BulkInsert)這個行為,所以我們這支TestMethod主要就會著重在資料處理商業邏輯部分的驗證,所以我們名字會改為InsertS18_DataContentCheck_Ok告訴使用的人我們主要著重在DataContentCheck的這個部分(更多詳細單元測試命名規則可以參考:與 Roy Osherove 探討單元測試的藝術 (心得筆記))

完成上述步驟後,我們就可以開始撰寫測試內容。


  • 開始撰寫Test Method

我們先以InsertS18Test這個Method為範例,這個Method主要在執行下圖註解這些事

依據先前「單元測試」的注意事項所提到的第3.點(單元測試程式不應該接觸到任何與任何 外部資源 (External Resources) 或 靜態物件 (Statics)…)來說。跟DB進行資料比對,篩選掉已經存在DB的重複資料,很明顯會對資料庫進行相關操作,因為 單元測試是軟體測試的最小單位,如果測試的範圍輕易的就會擴展到其他類別或同類別的其他方法,那就不再是最小單位,也就不是單元測試了!(請參考:ASP.NET MVC 單元測試系列 (3):瞭解 Mock 假物件 ( moq ))

為了不讓單元測試的範圍擴大,我們必須要模擬(Mock)一個DBContext物件出來,才不會跟真的DBContext類別接觸到,因而擴大測試的範圍。為了要Mock DBContext,我們必須要先安裝Moq套件。(Moq詳細使用可以參考:Mock 框架 Moq 的使用 & Moq 在.net Core 單元測試中的使用)

安裝好Moq後,我們就可以開始來mock一個假的DBContext物件了。

Mock<CAEDB01Context> _mockContext = new Mock<CAEDB01Context>();

Mock好之後,沒有意外的話。_mockContext這個物件下的Object屬性裡面應該會有這個DBContext下所有符合Mock條件(介面或是有virtual關鍵字)的成員(目前應該都還是null,因為還沒有Setup內容)的映射了。至於Moq會Mock那些類別成員呢?請參考:ASP.NET MVC 單元測試系列 (3):瞭解 Mock 假物件 ( moq )。 我們先來看看_mockContext.Object長怎樣,結果好像跟剛才說的不太一樣,Object這個屬性好像Mock失敗了,拋回了一個System.ArgumentException的錯誤,應該是哪裡有該有的參數沒有給到了。

先去到我們要Mock的CAEDB01Context這個物件裡面來看看,可以看到建構子有個options的參數要給他

在一般的正常流程,我們會在Program.cs裡面註冊CAEDB01Context時就給options參數。

但在Mock時,不需要真的連線到資料庫,所以我們不需要用到options這個參數來取得連線字串內容,但是在CAEDB01Context的建構子又需要options這個參數,為了解決這個問題,我們增加一個不需要options參數的建構子。

再來看看_mockContext.Object長怎樣,Object這個屬性裡面已經有映射的東西(DBContext裡面的Table物件)了

到這裡算是Mock成功了一半了,接下來就是要塞假資料到table物件裡面了。

			//Mock DBContext
			Mock<CAEDB01Context> _mockContext = new Mock<CAEDB01Context>();
            
            //製作假資料
            var InfoData = new List<test> {
                #region fake data
                new test{ID = 1, PENo = "PE-2100419", OrderNo = "33456781", SOLine = "1.1", PartNo = "13PD02V0AM0411", Quantity = 1, NetPrice = 9999},
                new test{ID = 2, PENo = "PE-2200005", OrderNo = "33456782", SOLine = "1.1", PartNo = "13PD02V0AM0411", Quantity = 1, NetPrice = 9999},
                new test{ID = 3, PENo = "PE-2100419", OrderNo = "33456783", SOLine = "1.1", PartNo = "13PD02V0AM0411", Quantity = 1, NetPrice = 9999},
                #endregion
            }.AsQueryable();

            //宣告一個Mock物件,把假資料的直放進去
            var mockS18 = new Mock<DbSet<test>>();
            mockS18.As<IQueryable<test>>().Setup(m => m.Provider).Returns(InfoData.Provider);
            mockS18.As<IQueryable<test>>().Setup(m => m.Expression).Returns(InfoData.Expression);
            mockS18.As<IQueryable<test>>().Setup(m => m.ElementType).Returns(InfoData.ElementType);
            mockS18.As<IQueryable<test>>().Setup(m => m.GetEnumerator()).Returns(InfoData.GetEnumerator());

            //把Mock物件放到_mockContext
            _mockContext.Setup(c => c.test).Returns(mockS18.Object);

可以看到_mockContext.Object裡面的mock物件已經有我們剛剛塞進去的3筆假資料了。另外補充:只要符合Mock條件的方法也是可以被Mock的喔!

有了mock DBContext我們就可以把它注入到Service裡面,代替真的DBContext了。

IDataService _dataService = new DataService(_mockContext.Object);

前置作業準備好之後,就可以來撰寫TestMethod主體的部分了。TestMethod再撰寫時,必須要符合3A Pattern(何謂3A Pattern請參考:ASP.NET MVC 單元測試系列 (1):新手上路 / 開始撰寫!)

1 => 排列(Arrange):要進行測試的資料,或準備要執行該測試所需的變數

2 => 作用(Act):執行要被測試的方法,並取得執行的結果

3 => 判斷提示(Assert):每個「測試方法」的標準程式結構。例如我這裡就很單純的只判斷是否有錯誤

接著在TestMethod點擊右鍵出現選單,選取執行測試(也可以選真錯測試進入Debug模式)

測試如果沒有問題的話,測試總管的此TestMethod就會出現綠燈(反之,如果有exception的話,則會出現紅燈),這樣就完成了一隻單元測試方法了。

以上。

 

  • Mock BulkInsert
    待續…

 

Ref:
1.保哥-單元測試相關系列文章
2.[Unit Test]使用ContextMock以不連結資料庫的方式做單元測試
3.BulkInsert Mock => https://stackoverflow.com/questions/50318941/how-can-i-create-an-interface-for-my-dbcontext-if-i-use-efcore-bulkextensions
4.ASP.NET Core 練習 - EF Core 單元測試
5.【Unit Test】Day 1 - 為何要寫單元測試
6.Mock 框架 Moq 的使用
7.Moq 在.net Core 單元測試中的使用