TDD學習筆記【四】--- 如何隔離相依性 - 基本的可測試性

前言

相信許多讀者都聽過「可測試性」,甚至被它搞的要死要活的,還以爲根本是莫名其妙,徒勞無功。 今天這篇文章,主要要講的是對象的相依性,以及對象之間直接相依,會帶來什麼問題。 爲了不發生因相依性而致使設計與測試上的問題,本文會清楚地說明該如何隔絕對象的相依性。 最後會說明如何經過簡單的 stub 對象來進行測試,而沒必要相依於production code 中執行時所實際相依的對象。 補充的部分,更是我以爲測試所能帶來的龐大優勢,怎麼驗證對象設計的好壞,讓測試告訴你。

什麼是相依性

假設如今有一個 Validation 的服務,要針對用戶輸入的 id 與密碼進行驗證。 Validation 的 CheckAuthentication 方法的商業邏輯以下:
  •   根據 id,取得存在數據源中的密碼(僅存放通過 hash 運算後的結果)。
  •   根據傳入的密碼,進行 hash 運算。
  •   比對數據源回傳的密碼,與輸入密碼通過哈希運算的結果,是否吻合。

簡單的程序代碼以下(AccountDao與Hash的內容不是重點,爲節省篇幅就先省略):html

 1 using System;
 2 
 3 public class Validation
 4 {
 5     public bool CheckAuthentication(string id, string password)
 6     {
 7         // 取得數據庫中,id對應的密碼           
 8         AccountDao dao = new AccountDao();
 9         var passwordByDao = dao.GetPassword(id);
11         // 針對傳入的password,進行hash運算
12         Hash hash = new Hash();
13         var hashResult = hash.GetHashResult(password);
15         // 對比hash後的密碼,與數據庫中的密碼是否吻合
16         return passwordByDao == hashResult;
17     }
18 }
19 
20 public class AccountDao
21 {
22     internal string GetPassword(string id)
23     {
24         //鏈接DB
25         throw new NotImplementedException();
26     }
27 }
28 
29 public class Hash
30 {
31     internal string GetHashResult(string passwordByDao)
32     {
33         //使用SHA512
34         throw new NotImplementedException();
35     }
36 }
先將職責分離,因此取得數據是經過AccountDao對象,Hash運算則經過Hash對象。
一切都很合理吧。那麼,這樣會有什麼問題?

相依性的問題

再來看一次,CheckAuthentication方法商業邏輯,其實只是爲了取得密碼、取得hash結果、比對是否相同,三個步驟而已。但在面向對象的設計,要知足單一職責原則,因此將不一樣的職責,交由不一樣的對象負責,再經過對象之間的互動來知足用戶需求。
可是,對Validation的CheckAuthentication方法來講,其實根本就無論、不在意AccountDao以及Hash對象,由於那不在它的商業邏輯中。
但卻爲了取得密碼,而直接初始化AccountDao對象,爲了取得hash結果,而直接初始化Hash對象。因此,Validation對象便與AccountDao對象以及Hash對象直接相依。其類別關係以下圖所示:
直接相依會有什麼問題呢?

單元測試的角度

就單元測試的角度來講,當想要測試Validation的CheckAuthentication方法是否符合預期時,會發現要單獨測試Validation對象,是件不可能的事。
由於Validation對象直接相依於其餘對象。如同前面文章提到,咱們爲CheckAuthentication創建單元測試,程序代碼以下:
        [TestMethod()]
        public void CheckAuthenticationTest()
        {
            Validation target = new Validation(); // TODO: 初始化爲適當值
            string id = string.Empty; // TODO: 初始化爲適當值
            string password = string.Empty; // TODO:初始化爲適當值
            bool expected = false; // TODO: 初始化爲適當值
            bool actual;
            actual = target.CheckAuthentication(id, password);
            Assert.AreEqual(expected, actual);
            Assert.Inconclusive("驗證這個測試方法的正確性。");
        }
不論怎麼arrange,當呼叫Validation對象的CheckAuthentication方法時,就確定會使用AccountDao的GetPassword方法,進而聯機至DB,取得對應的密碼數據。
還記得咱們對單元測試的定義與原則嗎?單元測試必須與外部環境、類別、資源、服務獨立,而不能直接相依。這樣纔是單純的測試目標對象自己的邏輯是否符合預期。
並且單元測試須要運行至關快速,假若單元測試還須要數據庫的資源,那麼表明執行單元測試,還須要設定好數據庫聯機或外部服務設定,而且執行確定要花些時間。這,其實就是屬於整合測試,而非單元測試。

彈性設計的角度

除了測試程序的角度之外,直接相依其餘對象在設計上,有什麼問題?但願各位讀者,讀這系列文章時,能夠把這句話記在心理:測試程序就是在模擬外部使用,多是用戶的使用,也多是外部對象的使用狀況。
因此,當咱們用測試程序會碰到直接相依形成的問題,也意味着這樣的 production code ,當在使用 Validation 對象時,就是直接相依於 AccountDao 與 Hash 對象。當需求變更時,例如數據源由數據庫改成讀 csv 檔,那麼要否則就是新寫一個 AccountFileDao 對象,並修改 Validation 對象的內容。或是直接把 AccountDao 讀取數據庫的內容,改寫成讀 csv 檔案的內容。
這兩種修改,都違背了開放封閉原則(Open Close Principle, OCP),也就表明對象的耦合性太高,當需求異動時,沒法輕易的擴充與轉換。當直接改變對象中 context 內容,則表明對象不夠穩固。而在軟件開發過程當中,需求變更是一件正常且頻繁的狀況。
就像之前是經過軟盤來存放文件,接下來 CD, 隨身碟, DVD, 藍光 DVD, 甚至雲端硬盤,假若咱們將備份服務的方法內容中,直接寫死存取軟盤,接着時代變遷,技術改變,咱們得一直去修改本來的程序內容,還不能保證結果是否符合預期。甚至於本來的測試程序都須要跟着修改,由於內容與需求已經改變,而相對的影響到了本來對象商業邏輯的變化。
所以,在設計上不管是爲了彈性或是可測試性,咱們都應該避免讓對象直接相依。(試想一下,實務系統上,對象相依可不僅是兩層關係而已。A 相依於 B,而 B 相依於 C 與 D,這就表明着 A 相依於 B, C, D 三個對象。相依關係將會爆炸性的複雜)

如何隔離對象之間的相依性

直接相依的問題緣由在於,初始化相依對象的動做,是寫在目標對象的內容中,沒法由外部來決定這個相依對象的轉換。因此隔離相依性的重點很簡單,別直接在目標對象中初始化相依對象。怎麼做呢?
首先,爲了擴充性,因此定義出接口,讓目標對象僅相依於接口,這也是面向接口編程方式。如同抽象地描述CheckAuthentication方法的商業邏輯,程序代碼改寫成下面方式:
 
 1     public interface IAccountDao
 2     {
 3         string GetPassword(string id);
 4     }
 5 
 6     public interface IHash
 7     {
 8         string GetHashResult(string password);
 9     }
10 
11     public class AccountDao : IAccountDao
12     {
13         public string GetPassword(string id)
14         {
15             throw new NotImplementedException();
16         }
17     }
18 
19     public class Hash : IHash
20     {
21         public string GetHashResult(string password)
22         {
23             throw new NotImplementedException();
24         }
25     }
26 
27     public class Validation
28     {
29         private IAccountDao _accountDao;
30         private IHash _hash;
31 
32         public Validation(IAccountDao dao, IHash hash)
33         {
34             this._accountDao = dao;
35             this._hash = hash;
36         }
37 
38         public bool CheckAuthentication(string id, string password)
39         {
40              // 取得數據庫中,id對應的密碼           
41             var passwordByDao = this._accountDao.GetPassword(id);
42             // 針對傳入的password,進行hash運算
43             var hashResult = this._hash.GetHashResult(password);
44             // 對比hash後的密碼,與數據庫中的密碼是否吻合
45             return passwordByDao == hashResult;
46         }
47     }
上面能夠看到,本來直接相依的對象,如今都經過相依於接口。而 CheckAuthentication 邏輯更加清楚了,如同批註所述:
取得數據中 id 對應的密碼 (數據怎麼來的,沒必要關注)
針對 password 進行 hash (怎麼 hash 的,沒必要關注)
針對 hash 結果與數據中存放的密碼比對,回傳比對結果
類別相依關係以下所示:
 
這就是面向接口的設計。而本來初始化相依對象的動做,經過目標對象的公開構造函數,可由外部傳入接口所屬的實例,也就是在目標對象外初始化完成後傳入。
    把初始化動做,由本來目標對象內,轉移到目標對象以外,稱做「控制反轉」,也就是 IoC。
    把依賴的對象,經過目標對象公開構造函數,交給外部來決定,稱做「依賴注入」,也就是 DI。
   而 IoC 跟 DI,其實就是同一件事:讓外部決定目標對象的相依對象。

原文可參考 Martin Fowler 的文章:Inversion of Control Containers and the Dependency Injection pattern算法

As a result I think we need a more specific name for this pattern. Inversion of Control is too generic a term, and thus people find it confusing. As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection.數據庫

如此一來,目標對象就能夠專一於自身的商業邏輯,而不直接相依於任何實體對象,僅相依於接口。而這也是目標對象的擴充點,或是接縫,提供了將來實做新的對象,來進行擴充或轉換相依對象模塊,而沒必要修改到目標對象的 context 內容。
經過 IoC 的方式,來隔絕對象之間的相依性,也帶來了上述提到的擴充點,這其實就是最基本的可測試性。下一段咱們未來介紹,爲何這樣的設計,能夠提供可測試性。

如何進行測試

針對剛剛用 IoC 方式設計的目標對象,經過 VS2013 創建單元測試時,測試程序代碼以下:
       [TestMethod()]
        public void CheckAuthenticationTest()
        {
            IAccountDao accountDao = null;// TODO: 初始化爲合適的值
            Hash hash = null;// TODO: 初始化爲合適的值
            Validation target = new Validation(accountDao, hash);
            string id = string.Empty; // TODO: 初始化爲合適的值
            string password = string.Empty;//TODO: 初始化爲合適的值
            bool expected = false;// TODO: 初始化爲合適的值
            bool actual;
            actual = target.CheckAuthentication(id, password);
            Assert.AreEqual(expected, actual);
            Assert.Inconclusive("驗證這個測試的正確性。");
        }
看到了嗎?Visual Studio會自動幫咱們把構造函數須要的參數也都列出來。
爲何這樣的設計方式,就能夠幫助咱們只獨立的測試Validation的CheckAuthentication方法呢?
接下來要用到「手動設計」的stub。
你們回過頭看一下,CheckAuthentication方法中,使用到了IAccountDao的GetPassword方法,取得id對應密碼。也使用到了IHash的GetHashResult方法,取得hash運算結果。接着纔是比對二者是否相同。
經過接口可進行擴充,多態和重載(若是是繼承父類或抽象類,而非實做接口時)的特性,咱們這邊舉IAccountDao爲例,創建一個StubAccountDao的類型,來實現IAccountDao。而且,在GetPassword方法中,無論傳入參數爲什麼,都固定回傳"Hello World",表明Dao回來的密碼。程序代碼以下所示:
public class StubAccountDao : IAccountDao
{
    public string GetPassword(string id)
    {
        return "Hello World";
    }
}

接着用一樣的方式,讓 StubHash 的 GetHashResult,也回傳 "Hello World",表明 hash 後的結果。程序代碼以下:編程

public class StubHash : IHash
{
    public string GetHashResult(string password)
    {
        return "Hello World";
    }
}

聰明的讀者朋友們,應該知道接下來就是來寫單元測試的 3A pattern,單元測試程序代碼以下:函數

        [TestMethod()]
        public void CheckAuthenticationTest()
        {
            //arrange
               // 初始化StubAccountDao,來看成IAccountDao的執行對象
              IAccountDao dao = new StubAccountDao();
              // 初始化StubHash,來看成IStubHash的執行對象
              IHash hash = new StubHash();
            Validation target = new Validation(dao, hash);
            string id = "隨便寫";  
            string password = "隨便寫"; 
            bool expected = true; 
            bool actual;
            //act
            actual = target.CheckAuthentication(id, password);
            //assert
            Assert.AreEqual(expected, actual);
        }
如此一來,就可讓咱們的測試目標對象:Validation,不直接相依於 AccountDao 與 Hash 對象,經過 stub 對象來模擬,以驗證 Validation 對象自己的 CheckAuthentication 方法邏輯,是否符合預期。
測試程序使用 Stub 對象,其類別圖以下所示:

延伸思考

給各位讀者出個做業,假若今天 CheckAuthentication 方法中,相依的是一個隨機數生成器的對象,驗證邏輯則是檢查「輸入的密碼」是否等於「數據存放的密碼」+「隨機數生成器」。這樣的程序代碼,要怎麼撰寫?撰寫完,如何測試?假若沒有經過 IoC 與 Stub object 的方式,是否仍然能夠測試呢?該怎麼模擬或猜到這一次測試執行時,隨機數爲多少?
這是一個標準的 RSA token 用來做登入的例子,也是我最常拿來講明 IoC 與 Stub 的例子。讀者朋友本身動手寫一下這個簡單的 function,並嘗試去測試他,就能體會到這樣設計的好處以及所謂的可測試性。

結論

你們若是把「可測試性」的目的,看成只是爲了測試而致使要花費這麼多功夫,那麼很容易就會變成事倍功半。
每每 developer 會認爲:「爲何我要爲了測試,而多花這麼多功夫,即便我不寫測試,程序的執行結果仍然是對的啊,又沒有錯!」
但,其實這樣設計的重點是在於設計的彈性、擴充性。
以文章例子來講,當數據源的改變,或是Hash算法模塊的改變時,都不須要更改到 Validation 內的程序代碼,由於這一份商業邏輯是不變的。也不須要更改到本來的 AccountDao,由於它的職責和內容也沒有改變。
要改變的是:讓「Validation 經過新的數據源取值,經過新的 Hash 算法取得 hash 運算結果」。因此,只須要改變注入的相依對象便可。
而這樣的方式,就是單元測試中,用來獨立測試目標對象的方式,因此又被稱爲對象的可測試性。
這也是爲何,能夠拿可測試性來確認,對象的設計是否具有低耦合的特性,而低耦合是一個良好設計的指針之一。
但寫程序的人必定都要知道一個邏輯:「程序若不具有可測試性,表明其對象設計不夠良好。但程序具有可測試性,並不太表明對象設計就必定良好。」
 

補充

想請讀者再靜下心思考一下,假若今天的設計,是由需求產生測試案例,由測試程序產生目標對象。咱們只關注在目標對象,如何知足測試案例,也就是使用需求。目標對象之外的職責,都交給外部實做。以這 IoC 的例子,只須要把非目標對象職責,都抽象地經過接口來互動,根本不需思考接口背後如何實做。
那麼,要撰寫 Validation 對象的程序代碼,跟本來沒經過接口所撰寫的程序代碼,哪個比較短,比較輕鬆?
以筆者本身的經驗,當對這樣的 TDD 方式很熟悉時,一有測試案例,撰寫好測試程序後,完成目標對象行爲的時間將至關簡短。由於此次的目標與設計範圍,限定在只須要完成這一個目標對象,這一個測試案例所需行爲的職責,其餘繁複的實做都交給接口背後的對象去處理。
這就是面向接口的設計,也就是抽象地設計對象,抽象地設計可使得對象更加穩定、穩固,不因外在變化而受影響。
而由於 TDD,開發人員會發現,目標對象的設計,相依性將不會太多,也不會太少,只會剛恰好。
由於相依太多,測試程序會很難寫,也表明目標對象複雜,職責切太細、剁太碎,致使要完成一個功能,可能要十幾個對象的組合方能完成。是否十幾個對象,能夠再抽象與凝聚一些職責,改爲相依三個對象,就能知足這項測試案例呢?這是經過測試程序來驗證職責是否被切得太零碎。
相依太少,倒不是太大問題。但由於與其餘對象直接相依,而致使目標對象行爲職責過肥,要測試一個行爲,就需準備至關多的測試案例,方能知足全部執行路徑。這時候就是能夠經過測試程序,來驗證對象設計是否符合單一職責原則。
而可測試性,則是經過測試程序來驗證對象的設計是否低耦合,是否具有良好的擴充與可轉換變化的設計。
若是隻是把測試程序、測試案例、可測試性,看成多一個心安的程序結果,那就真的太惋惜了。由於那個小小的好處,只是整個寶藏的冰山一角。當體會到這整份寶藏,天然就會以爲撰寫測試程序的 CP 值,高的嚇人!
 
 
 備註:這個系列是我畢業後時隔一年從新開始進入開發行業後對大拿們的博文摘要整理進行學習對自個人各個欠缺的方面進行充電記錄博客的過程,非原創,特此感謝91 等前輩
相關文章
相關標籤/搜索