iT邦幫忙

DAY 5
14

30天快速上手TDD系列 第 5

[Day 5]如何隔離相依性 - 基本的可測試性

相信許多讀者都聽過「可測試性」,甚至被它搞的要死要活的,還覺得根本是莫名其妙,徒勞無功。

今天這篇文章,主要要講的是物件的相依性,以及物件之間直接相依,會帶來什麼問題。

為了避免發生因相依性而導致設計與測試上的問題,本文會清楚地說明該如何隔絕物件的相依性。

最後說明如何透過簡單的stub物件來進行測試,而不必相依於production code中執行時實際相依的物件。

補充的部分,更是我覺得測試所能帶來的龐大優點,怎麼驗證物件設計的好壞,讓測試告訴你。

上一篇文章:[Day 4]單元測試:是否需針對非 public method 進行測試?
本系列文章專區
@什麼是相依性
假設現在有一個Validation的服務,要針對使用者輸入的id與密碼進行驗證。Validation的CheckAuthentication方法商業邏輯如下:

  1. 根據id,取得存在資料來源中的密碼(僅存放經過hash運算後的結果)
  2. 根據傳入的密碼,進行hash運算
  3. 比對資料來源回傳的密碼,與輸入密碼經過雜湊運算的結果,是否吻合

簡單的程式碼(AccountDao與Hash的內容不是重點,為節省篇幅就先省略)如下:

    public class Validation
    {
        public bool CheckAuthentication(string id, string password)
        {
            // 取得資料中,id對應的密碼           
            AccountDao dao = new AccountDao();
            var passwordByDao = dao.GetPassword(id);

            // 針對傳入的password,進行hash運算
            Hash hash = new Hash();
            var hashResult = hash.GetHashResult(password);

            // 比對hash後的密碼,與資料中的密碼是否吻合
            return passwordByDao == hashResult;
        }
    }

    public class AccountDao
    {
        internal string GetPassword(string id)
        {
			//連接DB
            throw new NotImplementedException();
        }
    }

    public class Hash
    {
        internal string GetHashResult(string passwordByDao)
        {
			//使用SHA512
            throw new NotImplementedException();
        }
    }

先將職責分離,所以取得資料是透過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方法的商業邏輯,程式碼改寫成下面方式:

    public interface IAccountDao
    {
        string GetPassword(string id);
    }

    public interface IHash
    {
        string GetHashResult(string password);
    }

    public class AccountDao : IAccountDao
    {
        public string GetPassword(string id)
        {
            throw new NotImplementedException();
        }
    }

    public class Hash : IHash
    {
        public string GetHashResult(string password)
        {
            throw new NotImplementedException();
        }
    }

    public class Validation
    {
        private IAccountDao _accountDao;
        private IHash _hash;

        public Validation(IAccountDao dao, IHash hash)
        {
            this._accountDao = dao;
            this._hash = hash;
        }

        public bool CheckAuthentication(string id, string password)
        {
            // 取得資料中,id對應的密碼                       
            var passwordByDao = this._accountDao.GetPassword(id);

            // 針對傳入的password,進行hash運算
            var hashResult = this._hash.GetHashResult(password);

            // 比對hash後的密碼,與資料中的密碼是否吻合
            return passwordByDao == hashResult;
        }
    }

上面可以看到,原本直接相依的物件,現在都透過相依於介面。而CheckAuthentication邏輯更加清楚了,如同註解所述:

  1. 取得資料中id對應的密碼 (資料怎麼來的,不必關注)
  2. 針對password進行hash (怎麼hash的,不必關注)
  3. 針對hash結果與資料中存放的密碼比對,回傳比對結果

類別相依關係如下所示:

這就是介面導向的設計。而原本初始化相依物件的動作,透過目標物件的公開建構式,可由外部傳入介面所屬的執行個體,也就是在目標物件外初始化完成後傳入。這個把初始化動作,由原本目標物件內,轉移到目標物件之外,稱作「控制反轉」,也就是IoC。這個把依賴的物件,透過目標物件公開建構式,交給外部來決定,稱作「依賴注入」,也就是DI

如此一來,目標物件就可以專注於自身的商業邏輯,而不直接相依於任何實體物件,僅相依於介面。而這也是目標物件的擴充點,或是接縫,提供了未來實作新的物件,來進行擴充或抽換相依物件模組,而不必修改到目標物件的context內容。

透過IoC的方式,來隔絕物件之間的相依性,也帶來了上述提到的擴充點,這其實就是最基本的可測試性。下一段我們將來介紹,為什麼這樣的設計,可以提供可測試性。

@如何進行測試
針對剛剛用IoC方式設計的目標物件,透過VS2010建立單元測試時,測試程式碼如下:

        [TestMethod()]
        public void CheckAuthenticationTest()
        {
            IAccountDao dao = null; // TODO: 初始化為適當值
            IHash hash = null; // TODO: 初始化為適當值
            Validation target = new Validation(dao, hash); // 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("驗證這個測試方法的正確性。");
        }

看到了嗎?VS2010會自動幫我們把建構式需要的參數也都列出來。

為什麼這樣的設計方式,就可以幫助我們只獨立的測試Validation的CheckAuthentication方法呢?

接下來要用到「手動設計」的stub。

大家回過頭看一下,CheckAuthentication方法中,使用到了IAccountDao的GetPassword方法,取得id對應密碼。也使用到了IHash的GetHashResult方法,取得hash運算結果。接著才是比對兩者是否相同。

透過介面可進行擴充,多型與覆寫(如果是繼承父類或抽象類別,而非實作介面時)的特性,我們這邊舉IAccountDao為例,建立一個StubAccountDao的類別,來實作IAccountDao。並且,在GetPassword方法中,不管傳入參數為何,都固定回傳"91",代表Dao回來的密碼。程式碼如下所示:

    public class StubAccountDao : IAccountDao
    {
        public string GetPassword(string id)
        {
            return "91";
        }
    }

接著用同樣的方式,讓StubHash的GetHashResult,也回傳"91",代表hash後的結果。程式碼如下:

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

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

        [TestMethod()]
        public void CheckAuthenticationTest()
        {
            //arrange
            // 初始化StubAccountDao,來當作IAccountDao的執行個體
            IAccountDao dao = new StubAccountDao();

            //初始化StubHash,來當作IHash的執行個體
            IHash hash = new StubHash();

            // 將自訂的兩個stub object,注入到目標物件中,也就是Validation物件
            Validation target = new Validation(dao, hash);

            string id = "id隨便啦";
            string password = "密碼也沒關係";

            // 期望為true,因為預期hash後的結果是"91",而IAccountDao回來的結果也是"91",所以為true
            bool expected = true;

            //act
            bool actual;
            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值,高的嚇人!
讚


上一篇
[Day 4]單元測試:是否需針對非 public method 進行測試?
下一篇
[Day 6]隔絕相依性的方式與特性
系列文
30天快速上手TDD31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0
pajace2001
iT邦研究生 1 級 ‧ 2012-10-13 00:36:08

沙發
這麼棒的文章,一定要先搶沙發再好好欣賞的~~

就是91 iT邦研究生 4 級 ‧ 2012-10-13 00:57:03 檢舉

謝謝!相信內容不會讓您失望的!

pajace2001 iT邦研究生 1 級 ‧ 2012-10-13 01:08:22 檢舉

我只能說~~讚讚讚

0
pajace2001
iT邦研究生 1 級 ‧ 2012-10-13 15:51:09

hatelove 大大~~
剛剛因為發文寫錯了~不小心手滑按到刪除討論 OrzOrz落寞

0
pajace2001
iT邦研究生 1 級 ‧ 2012-10-13 15:52:38

不過我還是有想出來~如下:

<pre class="c" name="code">
public class Validation {
    IAccountDao accountDao = null;
    ITokenDao tokenDao = null;
    IPasswordManager passwordMgr = null;

    public Validation(IAccountDao _accountDao, ITokenDao _tokenDao, IPasswordManager _passwordMgr) {
        this.accountDao = _accountDao;
        this.tokenDao = _tokenDao;
        this.passwordMgr = _passwordMgr;
    }
    
    public bool CheckValidId(string id, string password) {
        string passwordByDao = accountDao.GetPassword(id);
        string tokenNumber = tokenDao.GetNumber();
        string fullPassword = passwordMgr.GetFullPassword(password, tokenNumber);

        return fullPassword == passwordByDao + tokenNumber;
    }
}
看更多先前的回應...收起先前的回應...
pajace2001 iT邦研究生 1 級 ‧ 2012-10-13 15:53:14 檢舉
<pre class="c" name="code">
   public interface IAccountDao {
        string GetPassword(string id);
    }

    public interface ITokenDao {
        string GetNumber();
    }

    public interface IPasswordManager {
        string GetFullPassword(string password, string tokenNumber);
    }

    public class AccountDao {
        internal string GetPassword(string id) {
            throw new NotImplementedException();
        }
    }

    public class Token {
        internal string GetNumber() {
            return new Random().Next(0, 999999).ToString("000000");  
        }
    }

    public class PasswordManager : IPasswordManager {
        public string GetFullPassword(string password, string tokenNumber) {
            return password + tokenNumber;
        }
    }
pajace2001 iT邦研究生 1 級 ‧ 2012-10-13 15:54:02 檢舉
<pre class="c" name="code">
public class StubAccountDao : IAccountDao {
    public string GetPassword(string id) {
        return "91";
    }
}
public class StubTokenDao : ITokenDao {
    public string GetNumber() {
        return "91";
    }
}

class StubPasswordManager : IPasswordManager {
    public string GetFullPassword(string password, string tokenNumber) {
        return "9191";
    }
}
pajace2001 iT邦研究生 1 級 ‧ 2012-10-13 15:54:54 檢舉

最後測試為:

<pre class="c" name="code">
[TestClass]
public class UnitTest1 {
    [TestMethod]
    public void TestMethod1() {
        // arrange
        IAccountDao accountDao = new StubAccountDao();
        ITokenDao tokenDao = new StubTokenDao();
        IPasswordManager passwordMgr = new StubPasswordManager();
        string id = "9567";
        string password = "19567";


        bool expected = true;

        Validation v = new Validation(accountDao, tokenDao, passwordMgr);

        Assert.AreEqual(expected, v.CheckValidId(id, password));
    }
}
pajace2001 iT邦研究生 1 級 ‧ 2012-10-13 15:56:26 檢舉

這樣OK嗎? 我把 password+tokenNumber 的邏輯拉出來~放到 PasswordManager 裡面!這樣才比較好測試~ 恭請大大指點~謝謝

就是91 iT邦研究生 4 級 ‧ 2012-10-13 22:56:32 檢舉

good! 讚
不過PasswordManager的職責拆法就要看需求而定。
如果要拆出來,或許直接assign IToken跟IAccountDao給PasswordManager就可以了。

就不需要交給Validation這個高層模組自行組合。但這離題了,這偏物件導向原則了。

另外AccountDao跟TokenDao應該要實作介面 :)
雖然在您這例子因為用IoC,所以還沒有感覺 哈...

就是91 iT邦研究生 4 級 ‧ 2012-10-13 22:57:30 檢舉

等下兩篇文章,還會有其他方式可以來作測試,以及透過mock/stub framework,讓測試時不用每次手動刻stub object。

pajace2001 iT邦研究生 1 級 ‧ 2012-10-14 00:48:40 檢舉

期待您的大作 謝謝

0
ted99tw
iT邦高手 1 級 ‧ 2012-10-14 09:28:53

太好了,這個班級可向P大借作業來抄了....偷笑

我要留言

立即登入留言