[單元測試]NUnit.Mocks使用介紹 - 以Service Test為例

  • 12216
  • 0

[單元測試]NUnit.Mocks使用介紹 - 以Service Test為例

前言

相信大家多多少少都有撰寫過Unit Test的程式,
當然在軟體開發的過程中,可能因為時程或其他外在因素而導致無法持之以恆。

但套句Ruddy老師的話,『要相信雲端的程式,還是Local端的程式?都不是,應該要相信測試過的程式』。

一個程式怎麼樣才算完成可以交付,怎麼證明這個程式沒有問題,
應該就要有一份測試程式來證明,這些程式在這些test case裡面,程式是沒有問題的。

當接到需求,開始著手設計程式時,
developer應該需要先構思好,這個程式需接收什麼樣的參數,處理什麼樣的邏輯,並預期回傳哪一些東西。
當明確地知道目標是什麼時,寫出來的程式才不會偏差,才不會有太多的重工或思緒遺漏的地方。
這也是TDD的精神,瞭解需求後構思的過程,應該就可以先著手進行測試程式的撰寫。
測試程式應該相當地單純、簡單且幾乎不需維護,
如此一來再撰寫實際程式的時候,可以在任何時間點去驗證是否目前的實際程式符合期望。
在未來需求變更的同時,也可以在任何變更後,來驗證原本的預期結果是否產生了合理或非預期的變化。

在Martin Fowler的Refactoring Improving the Design of Existing Code 第一章裡提到,The First Step in Refactoring:
Whenever I do refactoring, the first step is always the same. I need to build a solid set of tests for
that section of code. The tests are essential because even though I follow refactorings structured
to avoid most of the opportunities for introducing bugs, I'm still human and still make mistakes
.』

重構是維持系統可維護性幾近於無可避免的動作,
想要讓系統更乾淨、更有效率、更好維護,
第一件事仍然是撰寫好測試程式,因為原本程式是可以正常運作,
為了效率更好、更容易維護而導致程式結果錯誤,這是不被允許的。

撰寫可自動化的測試程式,是一個貫穿整個軟體開發過程的活動,
從還沒開始撰寫實際程式,到未來維護、改版時都仍須倚重於這些測試程式。
 

難題

如同之前撰寫BLL與DAL的步驟(包括Unit Test)這篇小小心得裡面提到的,
在分層的架構中,layer與layer之間應該是獨立且低耦合的,
而單元測試的意義,更是希望每一個測試的method,都有相當簡單明確的意義,
就是要證明某一項功能某一個case,程式是如預期一般運作的。

當某一個單元測試test case失敗時,應該迅速的得知這個failed所代表的意義,
且每一個test case應該只驗證某一個東西,
測試程式不應有太多的邏輯,一旦有太複雜的邏輯,就應該要寫『測試程式’』,拿來測試具有複雜邏輯的『元祖測試程式』。
測試程式之間應獨立,舉例來說,要測試Dao的CRUD,一次應該只測一種功能,
不應該使用Dao的Create,再使用Dao的Update,再使用Dao的Delete,最後使用Dao的Receive驗證資料是否為空。
原因是,當這樣具備Senario的Test Case一旦出錯時,無法分辨出是Dao的哪一個method錯了。

但,以Service layer來說,主要負責處理Business Logic,
勢必裡面會使用到一些Dao的物件,來針對傳入參數與Dao回傳資料,進行Business Logic的處理。

如果在測試程式中使用實際的Dao類別,會有幾個問題:

  1. 要等實際Dao寫完,且測試完,沒有問題時,Service Test才有意義,才能撰寫。
  2. Service測試程式,應該著重在測試Business Logic,而非資料流的處理。
    舉例來說,當我撰寫好Service測試程式,目的是為了測試service中的邏輯是否有誤,
    而測試DB被shut down了,那我的Service測試程式若使用實際的Dao,一定會因為無法存取DB而failed。
    但這個failed並不合理,因為Service邏輯並沒有錯,為什麼測試程式會failed。

因此,Service的測試程式應該要與實質的資料層獨立開來,才能穩定、安心的測試Service的邏輯。

困難點就在於,
我該如何在不更動實際程式,甚至根本實際的Dao類別還沒撰寫好時,
能定義Dao的行為回傳的資料物件呢?

因為這個難題,所以Unit Test中,出現了Mock的概念。
 

介紹

Mock的定義,在Testing ASP.NET Web Application書裡的定義:
Mock objects are also a form of test double and work in a similar fashion to stub objects.
Mocks are used to simulate the behavior of a complex object. Any interactions made with the
mock object are verified for correctness, unlike stub objects
.』

以這邊Service test的例子來說,從字面上來看,就是要使用Mock objects來模擬Dao的動作。

先舉Dao測試為例子,請見下圖:

DaoTest

  1. 先在Dao測試程式中,定義好預期的回應物件集合
  2. 在測試程式中,呼叫實際的Dao至測試DB取得相關物件
  3. 定義實際回應物件集合
  4. 比對「預期的回應物件集合」,與「實際回應物件集合」是否相吻合

 

而Service測試程式,應該要想辦法與測試DB切開,
且需要能自訂預期回應物件集合,所以要透過Mock object來取代Dao,達到Service測試程式與DAL切開的目的。
請見下面簡陋的示意圖:

Service Test


看完了上面的示意圖,我們來看實際的程式,如何使用NUnit的Mocks來取代Dao。
 

實作

舉個實際上碰到小小的例子,有一個Service的Method寫的很有改善的空間,
但在改善之前,我決定先替這個Method寫好測試程式,來達到重構前的TDD。
(所以請各位把注意力放在mock object的部分,而不要太挑剔我的實際例子)

  1. Service程式
  2.     public class NscuService : INscuService
        {
            public ICommOrganCodeDao CommOrganCodeDao { get; set; }
            public SubsidyNscuRecord GetSubsidyNscuRecord(string ipORGAN_CODE, string ipPLAN_KIND_12)
            {
                DataTable dtNscuData = CommOrganCodeDao.GetSubsidyNscuData(ipORGAN_CODE);
    
                string opNAD = "Y";
                string opErrReason = string.Empty;
                if (dtNscuData!=null && dtNscuData.Rows.Count > 0  )
                {
                    string sNADApplYN = Convert.ToString( dtNscuData.Rows[0]["NAD_APPL_YN"]);
                    string sPrApplYN =  Convert.ToString( dtNscuData.Rows[0]["PR_APPL_YN"]);
                    string sOrganKind = Convert.ToString( dtNscuData.Rows[0]["ORGAN_KIND"]);
                    string sOrganAcctCode = Convert.ToString( dtNscuData.Rows[0]["ORGAN_ACCT_CODE"]);
    
                    if (sOrganAcctCode.Equals(string.Empty))
                    {
                        opNAD = "N";
                        opErrReason = "Error1";
                    }
                    else
                    {                    
                        if (ipPLAN_KIND_12.Equals("2P"))
                        {
                            if (!sNADApplYN.Equals("Y"))
                            {
                                opNAD = "N";
                                opErrReason = "Error2";
                            }
                        }
                        else 
                        {                   
                            if (!sNADApplYN.Equals("Y"))
                            {
                                opNAD = "N";
                                opErrReason = "Error3";
                            }
                            else
                            {                          
                                ArrayList aryNscu = new ArrayList();
                                aryNscu.Add("A");
                                aryNscu.Add("B");
                                aryNscu.Add("C");
                                aryNscu.Add("D");
                                aryNscu.Add("E");
                                aryNscu.Add("F");
                                aryNscu.Add("G");
    
                                if (aryNscu.Contains(sPrApplYN))
                                {
                                    opNAD = "N";
                                    opErrReason = "Error4";
                                }
                            }
    
                        }
                    }
    
    
                }
    
                SubsidyNscuRecord objRecord = new SubsidyNscuRecord();
    
                objRecord.IsSubsidyNscu = opNAD;
                objRecord.ErrorReason = opErrReason;
    
                return objRecord;
            }
    
      
        }

  3. SubsidyNscuRecord類別
  4.     public class SubsidyNscuRecord
        {
            /// <summary>
            /// 是否為XXXX
            /// </summary>
            /// <value>
            /// Y-是;N-不是
            /// </value>
            public string IsSubsidyNscu { get; set; }
    
            /// <summary>
            /// 錯誤原因
            /// </summary>
            /// <remarks>
            /// If the count of the list equals zero, meaning that the record does not exist.
            /// </remarks>
            public string ErrorReason { get; set; }
        }

  5. ICommOrganCodeDao介面
  6.     public interface ICommOrganCodeDao
        {
            DataTable GetSubsidyNscuData(string ipORGAN_CODE);
        }

  7. Service測試程式
    1. Using NUnit.Mocks
    2. 宣告一個DynamicMock,之後用來取代ICommOrganCodeDao
    3. 定義CommOrganCodeDao.GetSubsidyNscuData預期回傳的DataTable格式
    4. 宣告DynamicMock的type為ICommOrganCodeDao
    5. 定義DynamicMock的GetSubsidyNscuData method回傳的資料,為自訂的預期回傳DataTable
    6. 初始化Service,並將DynamicMock.MockInstance設定給Service的Dao屬性
    7. 定義預期回傳物件集合
    8. 呼叫實際Service的API,得到實際回傳的物件集合
    9. 驗證預期回傳的資訊,與實際回傳的資訊,是否吻合。

      Service Test Code with Mocks

 

以這個Service為例子,我是寫了7個test case,來讓每一個if else的程式都有跑到,所以code coverage是100%。

測試結果:

TestResult

NCover的code coverage:
NocverResult

image


結論

這只是一個很簡單的例子,介紹一下

  1. 為什麼我需要使用Mock
  2. Mock的實作方式
  3. 撰寫使用Mock測試Service的步驟


第一次踏入Mock的領域,感謝老闆、Ruddy老師、流浪小風與Jeff哥的協助。

若有哪邊觀念錯誤或可改善的部分,
歡迎提供建議與指導。


blog 與課程更新內容,請前往新站位置:http://tdd.best/