[30天快速上手TDD][Day 29]TDD 實戰練習 part 3–單元測試綠燈

[30天快速上手TDD][Day 29]TDD 實戰練習 part 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 繼續進行了。

 

目前的程式碼

  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 。如下圖所示:

1-MyHash feature

接著一樣產生 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 。如下圖所示:

2-CardDao feature

產生對應的 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;
            }
        }
    }

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

3-all pass

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

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

 

重構 Authentication

細節的部份,讀者可以回顧一下前面的文章: [30天快速上手TDD][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();
        }

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

4-all pass again

 

小結

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

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

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

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

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

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

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

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