iT邦幫忙

DAY 29
2

30天快速上手TDD系列 第 29

[Day 29]TDD實戰練習-3

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與測試案例

接下來這篇文章,則是要針對物件更細部的實作,來進行重構,把後面的重構招式也運用上,並且讓production code更符合domain與需求的本質。

這一篇文章,會將需要的驗收測試與整合測試,以及大部分相關的production code都撰寫完畢。

最後還有一個部分沒有提及,就是建立Authentication的單元測試,來保護當相依物件的實作細節或相關需求改變時,Authentication物件的商業邏輯,仍能被正常測試到。而context端也會套用strategy pattern與factory pattern。這個部分因篇幅限制,會挪到下一篇文章當做整個TDD實戰系列的結尾。

當全部重構完成後,我們一整個ATDD/BDD/TDD的流程也就告一段落,喝杯咖啡之後,就可以挑下一個story繼續進行了。

上一篇文章:[Day 28]TDD實戰練習-2
本系列文章專區
@目前的程式碼

  1. Authentication Steps (Authentication的測試程式):

    [Binding]
    public class AuthenticationSteps
    {
        private static Authentication target;
    
        [BeforeScenario("Authentication")]
        public static void BeforeFeatureAuthentication()
        {
            target = new Authentication();
            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);
        }
    }
    
  2. Authentication的程式碼:

    public class Authentication
    {
        public bool Verify(string id, string password)
        {
            if (id == "1234" && password == "91")
            {
                //LoginSuccess();
                return true;
            }
    
            if (id == "1234" && password == "1234")
            {
                //LoginFailed();
                return false;
            }
    
            return false;
        }
    }
    

3.Login.aspx.cs的程式碼 (Context端):

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

        var authentication = new Authentication();
        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");
    }

@前言
看到上面的程式碼,可以知道我們到上一篇為止,其實主要邏輯還是hard-code的判斷式。但其實context端已經可以按照scenario來看到基本運作方式與流程了。

接下來,則是要再往下drill down下去,實作更符合使用者需求的物件內容。

主要的重構目標,是Authentication物件的Verify內容。

@資料存取職責分離
就如同前一篇文章所提,Authentication的驗證職責,應該不屬於頁面。

讓我們再來釐清一下Authentication的Verify()應該要做些什麼。

Verify()的context邏輯如下:

  1. 依據id,取得存放在資料來源中的密碼。
  2. 存放在資料來源中的密碼,是經過SHA512處理過的。
  3. 將傳入參數中的password,經過SHA512處理。
  4. 比較兩個值是否相同,相同則代表身分驗證合法,則回傳true。否則為不合法,則回傳false。

有了這樣的思維之後,我們等等就應該依照測試案例,來準備資料來源中的測試資料。

既然有了測試程式保護,我們就先來完成Authentication中,屬於Verify應該處理的商業邏輯。

@Verify的重構
按照上面提到的Verify商業邏輯,我們先top-down的設計商業邏輯的部份,Authentication.Verify()內容如下:

    public class Authentication
    {
        public bool Verify(string id, string password)
        {
            //1. 依據id,取得存放在資料來源中的密碼。
            //2. 存放在資料來源中的密碼,是經過SHA512處理過的。
            string passwordFromDao = this.GetPasswordFromCardDao(id);

            //3. 將傳入參數中的password,經過SHA512處理。
            string passwordAfterHash = this.GetHash(password);

            //4. 比較兩個值是否相同,相同則代表身分驗證合法,則回傳true。否則為不合法,則回傳false。
            var isValid = passwordFromDao == passwordAfterHash;

            return isValid;

            //if (id == "1234" && password == "91")
            //{
            //    //LoginSuccess();
            //    return true;
            //}

            //if (id == "1234" && password == "1234")
            //{
            //    //LoginFailed();
            //    return false;
            //}

            //return false;
        }

        private string GetHash(string password)
        {
            throw new System.NotImplementedException();
        }

        private string GetPasswordFromCardDao(string id)
        {
            throw new System.NotImplementedException();
        }
    }

這時,測試肯定會失敗。因為還沒實作那兩個private function。

註:這邊要注意一個地方,我們從黑箱的角度或使用者行為的角度,一直沒有看到有所謂的Hash這個動作或是行為。但因為現在是在設計Authentication,所以屬於單元測試層級,單元測試屬於白箱測試,因此這邊的重構,既要把hash的邏輯加進去,而且最後要能通過所有原本的測試。

@GetHash的職責,交給Hash的物件負責
一樣的,計算Hash的職責交給Hash的物件來負責,程式碼如下:

        private string GetHash(string password)
        {
            var hash = new MyHash();

            var result = hash.GetHash(password);

            return result;
        }

@取得資料來源中,id所對應的密碼,交給Dao來負責
一樣的,取得資料來源中,id所對應的密碼,交給Dao來負責,程式碼如下:

        private string GetPasswordFromCardDao(string id)
        {
            var cardDao = new CardDao();

            var password = cardDao.GetPassword(id);

            return password;
        }

ok,到這,Verify的內容算是完成了,職責也分離完成了。

也就是BLL中的商業邏輯物件,已經完成自己的責任了,這時測試失敗的原因,是因為MyHash與CardDao還沒有實作完畢。

因此,接下來,我們要來撰寫MyHash與CardDao物件的內容。

@MyHash物件的TDD
在開始針對MyHash物件進行TDD之前,這邊因為「Hash」的特性,得先提醒一下讀者。

Hash是不可逆的,因此我們很難從外部的input,來回推預期hash之後的結果。所以,針對這個情況,基本上有兩種偷吃步的方式:

  1. 從之前已經存在的SHA512的test cases拿過來用,例如DB或其他module的test cases。
  2. 先寫好SHA512 Hash模組,並相信其執行結果,拿來當test cases。

實不相瞞,筆者也是用第二個方式來偷吃步。

註:即使是Hash用這種方式來撰寫測試程式,並不代表這樣的測試程式就沒有意義。因為當之後哪一個人明明是對的id與密碼,但密碼驗證一直失敗時,屆時新增的test cases就相當有意義了。

有了上述的前提之後,我們先來建立MyHash的feature與scenario。如下圖所示:

接著一樣產生step檔,並依照scenario撰寫其測試程式,測試程式碼如下:

    [Binding]
    public class MyHashSteps
    {
        private static MyHash target;

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

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

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

        [When(@"呼叫GetHash方法")]
        public void When呼叫GetHash方法()
        {
            var input = ScenarioContext.Current["input"].ToString();
            var result = target.GetHash(input);
            ScenarioContext.Current.Add("result", result);
        }

        [Then(@"回傳Hash結果為""(.*)""")]
        public void Then回傳Hash結果為(string expected)
        {
            var actual = ScenarioContext.Current["result"].ToString();

            Assert.AreEqual(expected, actual);
        }
    }

接下來,執行一下MyHash的scenario測試,想當然耳,還是測試失敗,因為MyHash還沒實作內容。但這是屬於MyHash TDD中的第一個紅燈。

接下來在MyHash中實作SHA512的模組,我們試圖通過這個紅燈。

@MyHash的GetHash()實作
程式碼如下:

    public class MyHash
    {
        public string GetHash(string password)
        {
            var hash = SHA512.Create();
            var encoding = new UTF8Encoding();

            var inputByteArray = encoding.GetBytes(password);
            var hashValue = hash.ComputeHash(inputByteArray);

            var result = Convert.ToBase64String(hashValue);
            return result;
        }
    }

這時執行測試,很好,通過測試了。我們的MyHash物件到這邊就完成了。

接下來把目光拉回CardDao的實作。

@CardDao物件的TDD
這邊有個分界點,一種是在開發環境,CardDao就可以被完成且測試。另一種是在真實環境中,CardDao才能被使用,真正連接到某個外部服務之類的情況。

倘若是在開發環境就可以實作完成,那麼就直接透過TDD的方式去實作。倘或是要到真實環境才能界接的外部服務,那麼程式還是依然要寫,但是Authentication的Verify,就應該要用stub/mock來模擬CardDao,才能測試商業邏輯。(後面會再提到)

註:這邊為了示範方便,筆者先簡單的用一個Dictionary,來當作存放於SQL server的id, password資料集合。(其實就是stub object的味道)

一樣,先建立feature與scenario。如下圖所示:

產生對應的step檔之後,接下來一樣先寫好測試程式,測試程式碼如下:

    [Binding]
    public class CardDaoSteps
    {
        private static CardDao target;

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

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

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

        [When(@"呼叫GetPassword的方法")]
        public void When呼叫GetPassword的方法()
        {
            var id = ScenarioContext.Current["id"].ToString();

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

        [Then(@"回傳對應密碼為""(.*)""")]
        public void Then回傳對應密碼為(string expected)
        {
            var actual = ScenarioContext.Current["result"].ToString();

            Assert.AreEqual(expected, actual);
        }
    }

測試程式撰寫完畢之後,接著來撰寫我們供範例使用的CardDao內容。(用Dictionary實作的Dao)

CardDao的程式碼如下:

    /// <summary>
    /// just for sample
    /// </summary>
    public class CardDao
    {
        private Dictionary<string, string> _data = new Dictionary<string, string>();

        public CardDao()
        {
            this._data.Add("1234", "2VHCS0uee3jJTDJM3Prw7L8PrW+PuuyjTWTBUhkC6LHq+OM/AIYX+OGY6Hot9+nCw2R4vMU52uZ96O/DDbB/Ig==");
        }

        public string GetPassword(string id)
        {
            if (this._data.ContainsKey(id))
            {
                return this._data[id];
            }
            else
            {
                return string.Empty;
            }
        }
    }

接下來執行所有測試,會發現所有測試都通過了。如下圖所示:

結束了嘛? 還沒,現階段的程式滿足了所有測試,但還沒滿足重構中OOD的principle。

接下來我們將繼續重構Authentication,以去除其物件之間的相依性。

@重構Authentication
細節的部份,讀者可以回顧一下前面的文章:[Day 16]Refactoring - 介面導向,這邊就直接帶相關的程式碼。

接下來,我們要先運用依賴反轉原則(DIP),讓Authentication先相依於介面。程式碼如下:

    public class Authentication
    {
        public bool Verify(string id, string password)
        {
            //1. 依據id,取得存放在資料來源中的密碼。
            //2. 存放在資料來源中的密碼,是經過SHA512處理過的。
            string passwordFromDao = this.GetPasswordFromCardDao(id);

            //3. 將傳入參數中的password,經過SHA512處理。
            string passwordAfterHash = this.GetHash(password);

            //4. 比較兩個值是否相同,相同則代表身分驗證合法,則回傳true。否則為不合法,則回傳false。
            var isValid = passwordFromDao == passwordAfterHash;

            return isValid;
        }

        private string GetHash(string password)
        {
            IHash hash = new MyHash();

            var result = hash.GetHash(password);

            return result;
        }

        private string GetPasswordFromCardDao(string id)
        {
            ICardDao cardDao = new CardDao();

            var password = cardDao.GetPassword(id);

            return password;
        }
    }

沒錯,就只是把宣告的部份,由var變成介面。

@運用IoC的方式來重構Authentication
接著把new相依物件的職責往外抽,讓Authentication就只是負責自己的職責,程式碼如下:

    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)
        {
            //1. 依據id,取得存放在資料來源中的密碼。
            //2. 存放在資料來源中的密碼,是經過SHA512處理過的。
            string passwordFromDao = this.GetPasswordFromCardDao(id);

            //3. 將傳入參數中的password,經過SHA512處理。
            string passwordAfterHash = this.GetHash(password);

            //4. 比較兩個值是否相同,相同則代表身分驗證合法,則回傳true。否則為不合法,則回傳false。
            var isValid = passwordFromDao == passwordAfterHash;

            return isValid;
        }

        private string GetHash(string password)
        {
            //IHash hash = new MyHash();
            var result = this._hash.GetHash(password);

            return result;
        }

        private string GetPasswordFromCardDao(string id)
        {
            //ICardDao cardDao = new CardDao();
            var password = this._cardDao.GetPassword(id);

            return password;
        }
    }

因為修改到了Authentication的建構式,所以context端,也就是Login.aspx.cs,以及測試程式也要跟著修改。

因為測試紅燈了,所以我們首要任務還是通過測試。

先修改Login.aspx.cs,程式碼如下:

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

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

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

接著是修改Authentication Steps的測試程式:

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

執行測試,很好,全數綠燈了。如下圖所示:

@小結
到現在,Authentication已經經過IoC的方式重構了。單就Authentication物件來說,已經設計的相當乾淨,也符合OOD的原則了。

因為內容實在有點太長,為避免超過篇幅限制,筆者只好把後面重構的部份,挪到下一篇文章了。

下一篇文章,我們將會介紹,如何使用mock framework,來建立Authentication的單元測試,讓我們不必相依於MyHash與CardDao物件。

即使未來CardDao或MyHash的邏輯或需求改變,我們的Authentication物件,仍能正確無誤的被測試其商業邏輯,是否仍符合預期。

而context端也將透過strategy pattern與factory pattern,來隔絕layer與layer之間的相依性。


上一篇
[Day 28]TDD實戰練習-2
下一篇
[Day 30]TDD實戰練習-END
系列文
30天快速上手TDD31

2 則留言

0
pajace2001
iT邦研究生 1 級 ‧ 2012-11-06 10:29:26

沙發
先坐著~下班後再來好好欣賞~~~

0
ted99tw
iT邦高手 1 級 ‧ 2012-11-06 21:17:06

讚讚讚

先瞄過,等退休再來啃...毆飛

我要留言

立即登入留言