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 AccountDao21 {22     internal string GetPassword(string id)23     {24         //連接DB25         throw new NotImplementedException();26     }27 }28 29 public class Hash30 {31     internal string GetHashResult(string passwordByDao)32     {33         //使用SHA51234         throw new NotImplementedException();35     }36 }相依性的問題
再來看一次,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 : IAccountDao12     {13         public string GetPassword(string id)14         {15             throw new NotImplementedException();16         }17     }18 19     public class Hash : IHash20     {21         public string GetHashResult(string password)22         {23             throw new NotImplementedException();24         }25     }26 27     public class Validation28     {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),它為相互依賴的組件提供抽象,將依賴(低層模塊)對象的獲得交給第三方(系統)來控制,
即依賴對象不在被依賴模塊的類中直接通過new來獲取 依賴
注入(DI),
它提供一種機制,將需要依賴(低層模塊)對象的引用傳遞給被依賴(高層模塊)對象。
    把初始化動作,由原本目標對象內,轉移到目標對象之外,稱作「控制反轉」,也就是 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 等前輩