[30天快速上手TDD][Day 29]TDD 實戰練習 part 3–單元測試綠燈
前言
TDD 實戰練習第一篇,介紹了:
- 如何從 PO 的描述中,定義出 user story 與 acceptance test cases 。
- 如何建立 BDD 相關的 feature 與 scenario 。
- 如何透過 Selenium 來設計驗收測試程式。
- 如何結合 BDD 的 steps 與 Selenium.WebDriver 。
TDD 實戰練習第二篇則介紹了:
- 如何迅速的通過驗收測試
- 如何在有測試保護的情況下重構
- 如何運用前面的重構九式來重構程式
- 如何從 acceptance testing drill down 到 integration testing
- 如何透過 BDD 來建立物件的 scenario 與測試案例
接下來這篇文章,則是要針對物件更細部的實作,來進行重構,把後面的重構招式也運用上,並且讓 production code 更符合 domain 與需求的本質。
這一篇文章,會將需要的驗收測試與整合測試,以及大部分相關的 production code 都撰寫完畢。
最後還有一個部分沒有提及,就是建立 Authentication 的單元測試,來保護當相依物件的實作細節或相關需求改變時, Authentication 物件的商業邏輯,仍能被正常測試到。而 context 端也會套用 strategy pattern 與 factory pattern 。這個部分因篇幅限制,會挪到下一篇文章當做整個 TDD 實戰系列的結尾。
當全部重構完成後,我們一整個 ATDD/BDD/TDD 的流程也就告一段落,喝杯咖啡之後,就可以挑下一個 story 繼續進行了。
目前的程式碼
-
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); } }
-
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; } }
-
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 邏輯如下:
- 依據 id ,取得存放在資料來源中的密碼。
- 存放在資料來源中的密碼,是經過 SHA512 處理過的。
- 將傳入參數中的 password ,經過 SHA512 處理。
- 比較兩個值是否相同,相同則代表身分驗證合法,則回傳 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之後的結果。所以,針對這個情況,基本上有兩種偷吃步的方式:
- 從之前已經存在的 SHA512 的 test cases 拿過來用,例如 DB 或其他 module 的 test cases 。
- 先寫好 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
細節的部份,讀者可以回顧一下前面的文章: [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();
}
執行測試,很好,全數綠燈了。如下圖所示:
小結
到現在, Authentication 已經經過 IoC 的方式重構了。單就 Authentication 物件來說,已經設計的相當乾淨,也符合 OOD 的原則了。
因為內容實在有點太長,為避免超過篇幅限制,筆者只好把後面重構的部份,挪到下一篇文章了。
下一篇文章,我們將會介紹,如何使用 mock framework ,來建立 Authentication 的單元測試,讓我們不必相依於 MyHash 與 CardDao 物件。
即使未來CardDao 或 MyHash 的邏輯或需求改變,我們的 Authentication 物件,仍能正確無誤的被測試其商業邏輯,是否仍符合預期。
而 context 端也將透過 strategy pattern 與 factory pattern ,來隔絕 layer 與 layer 之間的相依性。
blog 與課程更新內容,請前往新站位置:http://tdd.best/