TDD實戰練習第一篇,介紹了:
TDD實戰練習第二篇則介紹了:
接下來這篇文章,則是要針對物件更細部的實作,來進行重構,把後面的重構招式也運用上,並且讓production code更符合domain與需求的本質。
這一篇文章,會將需要的驗收測試與整合測試,以及大部分相關的production code都撰寫完畢。
最後還有一個部分沒有提及,就是建立Authentication的單元測試,來保護當相依物件的實作細節或相關需求改變時,Authentication物件的商業邏輯,仍能被正常測試到。而context端也會套用strategy pattern與factory pattern。這個部分因篇幅限制,會挪到下一篇文章當做整個TDD實戰系列的結尾。
當全部重構完成後,我們一整個ATDD/BDD/TDD的流程也就告一段落,喝杯咖啡之後,就可以挑下一個story繼續進行了。
上一篇文章:[Day 28]TDD實戰練習-2
本系列文章專區
@目前的程式碼
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;
}
}
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邏輯如下:
有了這樣的思維之後,我們等等就應該依照測試案例,來準備資料來源中的測試資料。
既然有了測試程式保護,我們就先來完成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之後的結果。所以,針對這個情況,基本上有兩種偷吃步的方式:
實不相瞞,筆者也是用第二個方式來偷吃步。
註:即使是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之間的相依性。