[30天快速上手TDD][Day 28]TDD 實戰練習 part 2–第一個綠燈

[30天快速上手TDD][Day 28]TDD 實戰練習 part 2–第一個綠燈

前言

前面介紹完每一塊拼圖的概念之後,上一篇文章開始進入實戰演練。

實戰練習的範例,是透過一個網路銀行的範例專案,挑選了登入功能這個 user story ,在上一篇文章中我們完成了下面幾個部分:

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

接下來這篇文章,則是要用最快速的方式,通過目前兩個還處於紅燈的 scenario 。

 

目前的進度

  1. 目前 feature 上的 scenario 如下:
    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 呈現訊息為"密碼輸入錯誤"
    
  2. 目前的測試程式如下:
    using System;
    using System.Text;
    using Microsoft.VisualStudio.TestTools.UnitTesting;
    using OpenQA.Selenium;
    using OpenQA.Selenium.Firefox;
    using TechTalk.SpecFlow;
    
    namespace TestWebBank
    {
        [Binding]
        public class 登入功能Steps
        {
            #region Test Setting
    
            private static IWebDriver driver;
            private static StringBuilder verificationErrors;
            private static string baseURL;
    
            [BeforeFeature("WebBank")]
            public static void BeforeFeatureWebAtm()
            {
                driver = new FirefoxDriver();
                //請自行修改為網站的domain name與port
                baseURL = "http://localhost:10542";
                verificationErrors = new StringBuilder();
            }
    
            [AfterFeature("WebBank")]
            public static void AfterFeatureWebAtm()
            {
                try
                {
                    driver.Quit();
                }
                catch (Exception)
                {
                    // Ignore errors if unable to close the browser
                }
                Assert.AreEqual("", verificationErrors.ToString());
            }
    
            #endregion Test Setting
    
            [Given(@"在登入頁面")]
            public void Given在登入頁面()
            {
                driver.Navigate().GoToUrl(baseURL + "/WebBankSite/Login.aspx");
            }
    
            [Given(@"提款卡Id輸入""(.*)""")]
            public void Given提款卡Id輸入(string cardId)
            {
                driver.FindElement(By.Id("txtCardId")).Clear();
                driver.FindElement(By.Id("txtCardId")).SendKeys(cardId);
            }
    
            [Given(@"密碼輸入""(.*)""")]
            public void Given密碼輸入(string password)
            {
                driver.FindElement(By.Id("txtPassword")).Clear();
                driver.FindElement(By.Id("txtPassword")).SendKeys(password);
            }
    
            [When(@"按下確認按鈕")]
            public void When按下確認按鈕()
            {
                driver.FindElement(By.Id("btnLogin")).Click();
            }
    
            [Then(@"頁面url為""(.*)""")]
            public void Then頁面Url為(string url)
            {
                var expected = string.Format("{0}/WebBankSite/{1}", baseURL, url);
                Assert.AreEqual(expected, driver.Url);
            }
    
            [Then(@"呈現訊息為""(.*)""")]
            public void Then呈現訊息為(string message)
            {
                Assert.AreEqual(message, driver.FindElement(By.Id("Message")).Text);
            }
        }
    }
  3. 目前的production code 如下:
    public partial class Login : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
        }
    
        protected void btnLogin_Click(object sender, EventArgs e)
        {
            //密碼驗證錯誤
            //this.Message.Text = @"密碼輸入錯誤";
    
            //密碼驗證成功
            //Response.Redirect("index.aspx");
        }
    }

 

TDD 的原則:剛剛好的程式碼

TDD 的一個原則就是,當出現紅燈的時候,接下來撰寫 production code ,期望只寫出「剛好滿足」測試程式的 production code ,一行不多、一行不少。

這個原則在一開始接觸時,開發人員可能會很不習慣,尤其是幾乎每一本或每一篇從頭介紹 TDD 的書籍文章,都會先建議讀者先用最快、最笨的方式來實作出通過測試的 code 。

這看起來似乎很不 make sense ,但這會有個很特別的效果,就是又往目標前進一步的效果。也能收到在 TDD 循環中,擁有節奏的感覺。

不過,當需求明確且開發人員已經可以知道幾步以後的重構模樣,那建議就不必每次都從最一開始的 hard-code 開始,因為每一份重構的工,還是得花掉一些些時間。

這篇文章,仍舊先從最一開始 hard-code 來解說,以方便完全沒有相關經驗的讀者可以循序漸進。

 

依據 Scenario ,迅速通過測試

首先,針對 login success 的 scenario ,我們來撰寫 production code ,使其迅速通過測試。

Production code 程式碼如下:

    protected void btnLogin_Click(object sender, EventArgs e)
    {
        //密碼驗證錯誤
        //this.Message.Text = @"密碼輸入錯誤";

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

        //密碼驗證成功
        var id = this.txtCardId.Text.Trim();
        var password = this.txtPassword.Text;

        if (id == "1234" && password == "91")
        {
            Response.Redirect("index.aspx");
        }
    }

一個 hard-code 的判斷式,讓我們通過了 login success 的scenario 。(但 login failed 的 scenario 仍舊是測試失敗)

如下圖所示:

1-just pass login success

接下來我們用同一個方式,想辦法先通過 login failed 的scenario 。

程式碼如下:

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

        if (id == "1234" && password == "91")
        {
            //密碼驗證成功
            Response.Redirect("index.aspx");
        }

        //Scenario: 當提款卡Id為1234時,輸入密碼為1234時,驗證失敗,出現密碼錯誤
        //Given 在登入頁面
        //And 提款卡Id輸入"1234"
        //And 密碼輸入"1234"
        //When 按下確認按鈕
        //Then 呈現訊息為"密碼輸入錯誤"
        if (id == "1234" && password == "1234")
        {
            //密碼驗證錯誤
            this.Message.Text = @"密碼輸入錯誤";
        }
    }

OK,按照 Scenario 的描述,寫完剛好通過測試的程式碼,執行一下測試,可以看到,兩個 scenario 都通過了。如下圖所示:

2-all scenario pass

很好,我們已經進入 TDD 的第二個階段:綠燈

這時候,如果 scenario 已經能夠 100% 代表整個 user story 的話,就代表可以 deploy 了。(當然啦,在這個範例中,這樣的 scenario 根本不可能是 100% )

Anyway, 綠燈之後,接下來我們就要進行重構。

 

重構

有了測試的保護,我們在進行重構的過程中,仍須確保原本的綠燈不會因為重構的動作,而導致變成紅燈。這也是重構的起手式,務必先建立自動測試保護。讀者想了解細節的話,可以參考前面的文章: [30天快速上手TDD][Day 10]Refactoring 起手式 - 建立測試

 

垃圾分類

目前的程式碼已經有相關的註解了,我們可以先來進行垃圾分類。將 login success 的處理與 login failed 的處理,用 extract method 的方式,擷取成 function 。

程式碼如下:

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

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

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

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

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

執行測試,仍是綠燈,通過測試。

 

職責分離

Authentication 這一件事,不應該交由頁面來決定,而是該由特定的 Authentication class 來處理。

這邊建議至少按照 3-layer 的方式,將相關的 BLL, DAL 抽離 PL ,因此 Authentication class 會放到新建的 library 中)

所以定義出負責 Authentication 的 class 之後,程式碼如下:

    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();
        }

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

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

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

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

我們一樣透過 Visual Studio 的產生功能,來幫我們產生對應的 class 與 function 。

這時候執行測試,我們會得到測試失敗的結果,原因是 NotImplementedException 的 exception 。

4-nonImplementtationException

 

建立單元測試

我們有了 library , Authentication 的 class , Verify 的方法。也知道了期望的結果,接下來在寫 Verify 方法的內容之前,我們用一樣的方式先來建立測試案例與測試程式。

我們建立一個 Authentication 的 feature ,其 Scenario 如下圖所示:

3-authentication feature

有了 Scenario ,接下來完成我們的測試程式。

 

Authentication 的測試程式

測試程式就依照 Scenario 的 template來撰寫。想了解細節的讀者,可以參考前面的文章:

  1. [30天快速上手TDD][Day 24]BDD - SpecFlow Introduction
  2. [30天快速上手TDD][Day 25]BDD - TDD from BDD

程式碼如下:

    [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);
        }
    }

這時候一樣執行測試會失敗,因為 production code 還沒開始實作。

 

撰寫 Authentication ,以通過相關測試案例

接下來,只要把原本放在頁面上的程式,轉移到 Authentication 的 Verify 方法中即可。

這個手法,可以參考前面文章: [30天快速上手TDD][Day 15]Refactoring - 食神歸位

程式碼如下:

    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;
        }
    }

執行一下測試,可以看到四個測試案例都通過了。如下圖所示:

5-all green

 

小結

OK ,整個步驟到這邊,我們已經從 acceptance testing (透過 Selenium.WebDriver ),往下 drill down 到integration testing 了,也就是 Authentication 物件的測試。

整體的程式碼,演變過程如下:

  1. 沒有任何實作內容。
  2. 在頁面上加入 hard-code 的兩個判斷式,通過 acceptance testing 。
  3. 重構成 LoginSuccess() 與 LoginFailed() 兩個 function。
  4. 職責分離,建立 Authentication class , Verify 的方法。
  5. 建立 Authentication 的 feature 與 scenario (屬於物件層級的 feature )
  6. 建立 Authentication 的測試程式
  7. 實作 Authentication 的 Verify 方法內容,將頁面的邏輯判斷移到 Verify() 裡。
  8. 通過 Authentication 的測試,同時通過 Login Feature 上的測試。

現在我們已經有 Login 頁面的驗收測試案例,也有了 Authentication 物件的測試案例,並且擁有剛好可以通過 Login Feature 與 Authentication Feature 的 production code 。

下一篇文章,則要針對物件層級的 integration testing 與 unit testing ,以及運用 OOD/OOP 的基本原則,來讓整個設計更臻完善,都完成後,就能開始挑選下一個 user story 。

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

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

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