[30天快速上手TDD][Day 30]TDD 實戰練習 END

[30天快速上手TDD][Day 30]TDD 實戰練習 END

前言

TDD 實戰練習第一篇,介紹了:

  1. 如何從 PO 的描述中,定義出 user story 與 acceptance test cases 。
  2. 如何建立 BDD 相關的 feature 與 scenario 。
  3. 如何透過 Selenium 來設計驗收測試程式。
  4. 如何結合 BDD的 steps 與 Selenium.WebDriver 。

TDD 實戰練習第二篇則介紹了:

  1. 如何迅速的通過驗收測試
  2. 如何在有測試保護的情況下重構
  3. 如何運用前面的重構九式來重構程式
  4. 如何從 acceptance testing drill down 到 integration testing
  5. 如何透過 BDD 來建立物件的 scenario 與測試案例

TDD 實戰練習第三篇則介紹了:

  1. 在測試的保護下,完成更符合需求本質的商業邏輯內容
  2. 從整合測試的開發過程,依照職責分出相關物件
  3. 建立相關物件的單元測試
  4. 完成相關物件的內容以通過單元測試
  5. 依據 DIP, IoC 的設計,降低物件之間的相依性

接下來這一篇文章,將建立 Authentication 的單元測試,來保護當「相依物件的實作細節或相關需求改變」時, Authentication 物件的商業邏輯,仍能被正常測試到。而 context 端也會套用 strategy pattern 與 factory pattern 。

當全部重構完成後,我們一整個 ATDD/BDD/TDD 的流程也就告一段落了。

雖然已經 30 天了,最後筆者會再整理一篇,當作是整個系列的目錄以及補充一些不錯的參考資料。

 

目前的程式碼

Authentication 的scenario 如下所示:

@Authentication
Feature: Authentication
	In order to 驗證登入資訊是否合法
	As a 呼叫端物件
	I want to 取得存放資料,驗證登入資訊是否吻合


Scenario: 驗證成功:當Id為1234時,輸入密碼為91時,回傳true
	Given id為"1234"
	And password為"91"
	When 呼叫Verify
	Then 回傳"true"

Scenario: 驗證失敗:當Id為1234時,輸入密碼為1234時,回傳false
	Given id為"1234"
	And password為"1234"
	When 呼叫Verify
	Then 回傳"false"

Authentication 測試程式,程式碼如下所示:

    [Binding]
    public class AuthenticationSteps
    {
        private static Authentication target;

        [BeforeScenario("Authentication")]
        public static void BeforeFeatureAuthentication()
        {
            target = new Authentication(new MyHash(), new CardDao());
            ScenarioContext.Current.Clear();
        }

        [AfterScenario("Authentication")]
        public static void AfterFeatureAuthentication()
        {
            ScenarioContext.Current.Clear();
        }

        [Given(@"id為""(.*)""")]
        public void GivenId為(string id)
        {
            ScenarioContext.Current.Add("id", id);
        }

        [Given(@"password為""(.*)""")]
        public void GivenPassword為(string password)
        {
            ScenarioContext.Current.Add("password", password);
        }

        [When(@"呼叫Verify")]
        public void When呼叫Verify()
        {
            var id = ScenarioContext.Current["id"].ToString();
            var password = ScenarioContext.Current["password"].ToString();

            var result = target.Verify(id, password);
            ScenarioContext.Current.Add("result", result);
        }

        [Then(@"回傳""(.*)""")]
        public void Then回傳(string result)
        {
            var isValid = Convert.ToBoolean(result);
            var actual = Convert.ToBoolean(ScenarioContext.Current["result"]);
            Assert.AreEqual(isValid, actual);
        }
    }

這是屬於整合測試的部份,因為測試目標 Authentication ,是直接使用 MyHash 與 CardDao 。

Authentication 的 production code ,程式碼如下:

    public class Authentication
    {
        private IHash _hash;
        private ICardDao _cardDao;

        public Authentication(IHash hash, ICardDao cardDao)
        {
            this._hash = hash;
            this._cardDao = cardDao;
        }

        public bool Verify(string id, string password)
        {
            string passwordFromDao = this.GetPasswordFromCardDao(id);

            string passwordAfterHash = this.GetHash(password);

            var isValid = passwordFromDao == passwordAfterHash;

            return isValid;
        }

        private string GetHash(string password)
        {
            var result = this._hash.GetHash(password);

            return result;
        }

        private string GetPasswordFromCardDao(string id)
        {
            var password = this._cardDao.GetPassword(id);

            return password;
        }
    }

 

建立 Authentication 的單元測試

雖然 Authentication 已經有整合測試保護了,但以 Business Object 來說,還是有為其建立單元測試的必要性。

一來這樣才能有單元測試的好處,二來粒度越細的測試程式越穩定,也越能發揮迴歸測試的效果。

只要我們幫 Authentication 建立了單元測試,那麼要驗證 Verify 的商業邏輯是否符合預期與使用者的需求,就完全不需要考慮到 MyHash 與 CardDao 的實作內容,甚至沒有這兩個物件,單元測試仍能運作。

首先,我們先建立一個單元測試的 project ,並新增一個 Authentication 的 feature 檔,並加入對應的 scenario 。如下圖所示:

1-Unit Test of Authentication

這邊要注意一點,在 scenario 上,我們加上了幾個原本整合測試上沒有的東西:

  1. 定義 IHash 回傳的值
  2. 定義 ICardDao 回傳的值

這也是單元測試被稱為白箱測試的原因,在測試一個行為時,除了物件本身的邏輯以外,任何外部相依的部份,都應該被模擬物件隔開,以達到單元測試目標物件的獨立性。

以這例子來說, ICardDao 的實作,資料來源從哪來,怎麼存取, Authentication 根本不在意。 IHash 怎麼取得 Hash 運算之後的結果,透過什麼演算法來運作, Authentication 根本不在意。

Authentication在意的只有一點:Verify 本身的邏輯內容,是否符合使用這個物件的預期。

接下來,依據 scenario ,自動產生 step 檔案後,來撰寫我們的單元測試程式碼。這邊會運用到前面文章所提及的 stub 技巧,細節部分讀者可以參考前面的文章: [30天快速上手TDD][Day 7]Unit Test - Stub, Mock, Fake 簡介

程式碼如下:

    [Binding]
    public class AuthenticationSteps
    {
        private static Authentication target;
        private static IHash hashStub;
        private static ICardDao cardDaoStub;

        [BeforeScenario("Authentication")]
        public static void BeforeScenarioAuthentication()
        {
            hashStub = MockRepository.GenerateStub<IHash>();
            cardDaoStub = MockRepository.GenerateStub<ICardDao>();

            target = new Authentication(hashStub, cardDaoStub);
            ScenarioContext.Current.Clear();
        }

        [AfterScenario("Authentication")]
        public static void AfterScenarioAuthentication()
        {
            hashStub = null;
            cardDaoStub = null;
            ScenarioContext.Current.Clear();
        }

        [Given(@"輸入id為""(.*)""")]
        public void Given輸入Id為(string id)
        {
            ScenarioContext.Current.Add("id", id);
        }

        [Given(@"輸入password為""(.*)""")]
        public void Given輸入Password為(string password)
        {
            ScenarioContext.Current.Add("password", password);
        }

        [Given(@"ICardDao回傳""(.*)""")]
        public void GivenICardDao回傳(string password)
        {
            cardDaoStub.Stub(x => x.GetPassword(Arg<string>.Is.Anything)).Return(password);
        }

        [Given(@"IHash回傳""(.*)""")]
        public void GivenIHash回傳(string hashResult)
        {
            hashStub.Stub(x => x.GetHash(Arg<string>.Is.Anything)).Return(hashResult);
        }

        [When(@"呼叫Verify")]
        public void When呼叫Verify()
        {
            var id = ScenarioContext.Current["id"].ToString();
            var password = ScenarioContext.Current["password"].ToString();
            var result = target.Verify(id, password);
            ScenarioContext.Current.Add("result", result);
        }

        [Then(@"回傳""(.*)""")]
        public void Then回傳(string expected)
        {
            var isValid = Convert.ToBoolean(expected);
            var actual = Convert.ToBoolean(ScenarioContext.Current["result"]);

            Assert.AreEqual(isValid, actual);
        }
    }

值得留意的就是透過 RhinoMocks 來產生 Stub 的部份。

一樣依照 scenario 中的描述,來給定預期的回傳值。

跑一下測試結果,全數通過測試,如下圖所示:

2-all pass testing

 

單元測試與整合測試的先後順序

這邊先提醒讀者一下,其實依照筆者的開發順序,依照這個 Authentication 的例子,其實筆者會先完成 Authentication 的單元測試後,才接著完成 CardDao 與 MyHash 兩個物件的 TDD 流程。

但這已經是有過相關經驗之後, tuning 完流程的結果,原因是,當需要一個物件時,完成物件中 context 的商業邏輯後,就要能夠通過測試,而不必考慮其相依物件。

anyway, 讀者如果開始使用 TDD 一段時間後,大概就能體會每個物件可以獨立測試的樂趣,以及開發人員的協同合作,只需要透過介面溝通,就可以平行開發的快感。

 

Strategy Pattern 與 Factory Pattern 的運用

接下來,我們快速的把 Login.aspx.cs 透過 DIP 的原則,相依於 Authentication 的介面,並將生成物件的動作,交給 factory 類別來負責。

有興趣了解細節的讀者,請參考前面的文章:

  1. [30天快速上手TDD][Day 17]Refactoring - Strategy Pattern
  2. [30天快速上手TDD][Day 18]Refactoring - Factory Pattern

 

第一步,改成相依於 Authentication 介面

一樣,只是把宣告的部份,換成 interface 。程式碼如下:

    protected void btnLogin_Click(object sender, EventArgs e)
    {
        var id = this.txtCardId.Text.Trim();
        var password = this.txtPassword.Text;

        IAuthentication authentication = new Authentication(new MyHash(), new CardDao());
        bool isValid = authentication.Verify(id, password);

        if (isValid)
        {
            LoginSuccess();
        }
        else
        {
            LoginFailed();
        }
    }

 

第二步,工廠物件的 TDD

一樣先用簡單工廠,先把 context 改成相依於工廠類別,程式碼如下:

    protected void btnLogin_Click(object sender, EventArgs e)
    {
        var id = this.txtCardId.Text.Trim();
        var password = this.txtPassword.Text;

        //IAuthentication authentication = new Authentication(new MyHash(), new CardDao());
        IAuthentication authentication = RepositoryFactory.GetIAuthentication();

        bool isValid = authentication.Verify(id, password);

        if (isValid)
        {
            LoginSuccess();
        }
        else
        {
            LoginFailed();
        }
    }

筆者的習慣,工廠類別我是直接建立單元測試,而沒有透過 scenario 來建立測試程式。

RepositoryFactory 測試程式碼如下:

        [TestMethod()]
        public void GetIAuthenticationTest()
        {
            IAuthentication expected = new Authentication(null, null);
            IAuthentication actual;
            actual = RepositoryFactory.GetIAuthentication();
            Assert.AreEqual(expected.GetType(), actual.GetType());
        }

因為工廠內容還沒有實作,所以現在是紅燈。

接下來實作工廠內容,並通過測試。程式碼如下:

    public class RepositoryFactory
    {
        public static Interface.BLL.IAuthentication GetIAuthentication()
        {
            ICardDao cardDao = GetCardDao();
            IHash hash = GetHash();

            return new Authentication(hash, cardDao);
        }

        private static IHash GetHash()
        {
            return new MyHash();
        }

        private static ICardDao GetCardDao()
        {
            return new CardDao();
        }
    }

目前完成所有程式了,執行所有測試,確保每一種層級的測試都是綠燈。如下圖所示:

3-9 test cases pass

 

完成的程式碼

Login.aspx.cs 程式碼如下:

    protected void btnLogin_Click(object sender, EventArgs e)
    {
        var id = this.txtCardId.Text.Trim();
        var password = this.txtPassword.Text;

        IAuthentication authentication = RepositoryFactory.GetIAuthentication();

        bool isValid = authentication.Verify(id, password);

        if (isValid)
        {
            LoginSuccess();
        }
        else
        {
            LoginFailed();
        }
    }

    /// <summary>
    /// 密碼驗證錯誤
    /// </summary>
    private void LoginFailed()
    {
        this.Message.Text = @"密碼輸入錯誤";
    }

    /// <summary>
    /// 密碼驗證成功
    /// </summary>
    private void LoginSuccess()
    {
        Response.Redirect("index.aspx");
    }

Authentication 程式碼如下:

    public class Authentication : IAuthentication
    {
        private IHash _hash;
        private ICardDao _cardDao;

        public Authentication(IHash hash, ICardDao cardDao)
        {
            this._hash = hash;
            this._cardDao = cardDao;
        }

        public bool Verify(string id, string password)
        {
            string passwordFromDao = this.GetPasswordFromCardDao(id);

            string passwordAfterHash = this.GetHash(password);

            var isValid = passwordFromDao == passwordAfterHash;

            return isValid;
        }

        private string GetHash(string password)
        {
            var result = this._hash.GetHash(password);

            return result;
        }

        private string GetPasswordFromCardDao(string id)
        {
            var password = this._cardDao.GetPassword(id);

            return password;
        }
    }

CardDao 與 MyHash 就不需要特地列上來了,因為那只是實作細節。

工廠的部份,上一段已經有完整的程式碼,這邊也不列出來。

接下來是測試案例的部份。

 

測試案例

Login 的 Feature 檔如下:

@WebBank
Feature: 登入功能
	In order to 驗證身份,避免非法使用者使用系統
	As a 線上使用者
	I want to 驗證使用者身份

Scenario: 當提款卡Id為1234時,輸入密碼為91時,驗證成功,導到index
	Given 在登入頁面
	And 提款卡Id輸入"1234"
	And 密碼輸入"91"
	When 按下確認按鈕
	Then 頁面url為"index.aspx"	

Scenario: 當提款卡Id為1234時,輸入密碼為1234時,驗證失敗,出現密碼錯誤
	Given 在登入頁面
	And 提款卡Id輸入"1234"
	And 密碼輸入"1234"
	When 按下確認按鈕
	Then 呈現訊息為"密碼輸入錯誤"

Authentication 的整合測試的 Feature 檔,如下:

@Authentication
Feature: Authentication
	In order to 驗證登入資訊是否合法
	As a 呼叫端物件
	I want to 取得存放資料,驗證登入資訊是否吻合


Scenario: 驗證成功:當Id為1234時,輸入密碼為91時,回傳true
	Given id為"1234"
	And password為"91"
	When 呼叫Verify
	Then 回傳"true"

Scenario: 驗證失敗:當Id為1234時,輸入密碼為1234時,回傳false
	Given id為"1234"
	And password為"1234"
	When 呼叫Verify
	Then 回傳"false"

Authentication 的單元測試 Feature 檔如下:

@Authentication
Feature: Authentication
	In order to 驗證登入資訊是否合法
	As a 呼叫端物件
	I want to 取得存放資料,驗證登入資訊是否吻合


Scenario: 驗證成功:當輸入Id為1234時,輸入密碼為91時,ICardDao與IHash都回傳"abc"時,回傳true
	Given 輸入id為"1234"
	And 輸入password為"91"
	And ICardDao回傳"abc"
	And IHash回傳"abc"
	When 呼叫Verify
	Then 回傳"true"

Scenario: 驗證失敗:當Id為1234時,輸入密碼為1234時,ICardDao回傳"abc",IHash回傳"bcd"時,回傳false
	Given 輸入id為"1234"
	And 輸入password為"1234"
	And ICardDao回傳"abc"
	And IHash回傳"bcd"
	When 呼叫Verify
	Then 回傳"false"

MyHash 的 Feature 檔如下:

@MyHash
Feature: MyHash
	In order to 避免密碼明碼外洩
	As a Authentication物件
	I want to 取得密碼hash之後的結果

Scenario: 輸入為"91",應回傳"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="
	Given 輸入字串為"91"	
	When 呼叫GetHash方法
	Then 回傳Hash結果為"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="

CardDao 的 Feature 檔如下:

@CardDao
Feature: CardDao
	In order to 存取Card的相關資料
	As a Authentictaion物件
	I want to 存取Card的相關資料

Scenario: 取得id為"1234",對應的密碼應為"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="
	Given 使用者id為"1234"	
	When 呼叫GetPassword的方法
	Then 回傳對應密碼為"2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig=="

 

程式碼涵蓋率

TDD 實戰練習的例子,所測出來的 code coverage ,如下圖所示:

4-code coverage

除了一個防呆,是我們在測試案例中沒有描述到的情況,這時候就應該去思考,是測試案例少了必要的 scenario ,還是 production code 多了不必要的程式碼。

測試程式碼涵蓋率高達 97.56% ,很誇張的高吧...別再說這是不可能的事囉。

 

結論

有了這些活著的測試案例,並且是透過 DSL 方式描述的 feature 以及 scenario ,不管是使用者、測試人員、開發人員,甚至是未來的維護人員,都可以透過這樣的測試案例,來了解:

  1. 系統有什麼樣的功能
  2. 這些功能用什麼樣的方式在運作
  3. 這些功能用什麼樣的方式在運作
  4. 系統、模組、物件,該如何使用

最後的成品,則有下列特色:

  1. 是一個每個物件都可以抽換其相依物件的物件導向設計的系統。
  2. 幾乎每一行 code 都有其存在的意義。
  3. 幾乎每一行 code 都有被測試涵蓋到。
  4. 未來任何需求異動或是 defect ,測試程式與測試案例都可以有迴歸測試的保護效果。

在TDD實戰練習中的一些實作細節,都可以從前面介紹每一塊拼圖的文章中了解。而整個流程的意義與回顧,則可以參考前面的文章: [30天快速上手TDD][Day 26]User Story/ATDD/BDD/TDD - 總結,這篇已經介紹的相當完整。

最後,希望這一個系列,可以讓讀者朋友們真的體會到 TDD 的 total solution 。

透過每一塊拼圖的解說,透過實戰演練的完整例子,可以讓大家更有感覺,這不是一個烏托邦的世界,這樣的設計方式真的沒這麼難。如同重構系列中,每一篇文章都只是一個3分鐘就能學會的技巧,動動腦,動動手,您們也絕對可以 TDD !

 

Sample Code

Sample code 下載位置

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

對 TDD 課程有興趣的朋友,課程內容、大綱與學員心得,可以參考 skilltree 的公開課程:自動測試與 TDD 實務開發

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。