iT邦幫忙

DAY 30
3

30天快速上手TDD系列 第 30

[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天了,最後筆者會再整理一篇,當作是整個系列的目錄以及補充一些不錯的參考資料。

上一篇文章:[Day 29]TDD實戰練習-3
本系列文章專區
@目前的程式碼
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。如下圖所示:

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

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

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

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

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

接下來,依據scenario,自動產生step檔案後,來撰寫我們的單元測試程式碼。這邊會運用到前面文章所提及的stub技巧,細節部分讀者可以參考前面的文章:[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中的描述,來給定預期的回傳值。

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

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

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

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

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

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

  1. [Day 17]Refactoring - Strategy Pattern
  2. [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();
        }
    }

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

@完成的程式碼
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,如下圖所示:

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

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

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

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

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

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

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

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

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

@Sample Code
Sample code 下載位置


上一篇
[Day 29]TDD實戰練習-3
下一篇
[30天快速上手TDD]目錄與附錄
系列文
30天快速上手TDD31
0
ted99tw
iT邦高手 1 級 ‧ 2012-11-07 00:09:57

灑花灑花灑花灑花

恭喜加菲貓大人完成鐵人賽!!!

讚讚讚讚

就是91 iT邦研究生 4 級‧ 2012-11-07 10:17:54 檢舉

謝謝

0
海綿寶寶
iT邦超人 1 級 ‧ 2012-11-07 07:28:46

恭喜鐵人鍊成
拍手灑花拍手

另外
我花了一千萬買你贏
你可別輸給那位神功練成的那位
下雨

看更多先前的回應...收起先前的回應...
就是91 iT邦研究生 4 級‧ 2012-11-07 10:16:28 檢舉

XD 這是什麼哏...

(其實我一直是外圍的最大莊家?偷笑

不過謝謝大家一路的相挺啦,今年總算也擠出一份代表作了,每一年都要嘔心瀝血一下...(就差沒妻離子散了 XD)

ted99tw iT邦高手 1 級‧ 2012-11-07 10:34:52 檢舉

iT邦幫忙MVPantijava提到:
你可別輸給那位神功練成的那位

怎麼樣,怕了吧,就跟你說唄,都只會欺負小水母,這下遇到水母俠就沒辦法了吧...失神

Dive..Dive...Dive....

就是91 iT邦研究生 4 級‧ 2012-11-07 13:41:31 檢舉

哈哈,我看到ted兄的神功練成了...

0
mosil
iT邦新手 4 級 ‧ 2012-11-07 08:31:02

恭喜灑花
相當棒的一系列謝謝

就是91 iT邦研究生 4 級‧ 2012-11-07 10:17:31 檢舉

謝謝,真心希望可以對大家有幫助。

也真心希望,台灣資訊業的環境、文化、開發工法,可以慢慢的讓每個工程師更開心、更輕鬆的做出大家都歡喜的系統。

0
pajace2001
iT邦研究生 1 級 ‧ 2012-11-07 10:32:37

恭喜恭喜~~~灑花
我非常喜歡這一系列的作品~~~
太讚啦~~
讚讚讚

就是91 iT邦研究生 4 級‧ 2012-11-07 13:35:06 檢舉

感謝您的支持與參與,讓人很窩心。

0
小成
iT邦高手 10 級 ‧ 2012-11-07 13:46:06

恭喜91
91的文也是每年必讀好文啊!!

就是91 iT邦研究生 4 級‧ 2012-11-07 16:49:32 檢舉

感謝支持!

0
就是91
iT邦研究生 4 級 ‧ 2012-11-13 18:17:29

剛剛才發現,之前沒有把範例的sample code放上來。

剛剛補上範例的下載位置了,請各位讀者輕鬆享用 :)

我要留言

立即登入留言