[30天快速上手TDD][Day 15]Refactoring - 食神歸位
前言
前兩篇文章,我們先以當下物件的角度,思考屬於自己的職責是什麼。而不屬於自己職責的部份,該委託給哪個物件來進行。並思考清楚當下物件所需要的,究竟是什麼,接著不必去管相依的物件如何實作,儘管開口叫它做,再跟它要結果即可。
接著,要怎麼確保其他物件的執行結果是我們要的呢?就是建立測試案例,先行建立單元測試。在其他物件的行為中,都還沒有實際的內容時,在我們動手撰寫其內容前,我們已經都思考好,這些物件接受到什麼資訊,該回傳怎麼樣的結果。有了這樣的前提,才能確保我們設計相依物件時,目的就是為了滿足當下物件的需求,目的就是為了通過測試案例。
而相依物件的測試案例怎麼產生呢?因為我們是先撰寫更上層的整合測試(在這例子是 Selenium ),在整合測試的 input 中,有一些脈絡可循,可以找到對應相依物件的 input 值,以及預期的 output 值。當下物件是頁面,只負責蒐集資訊,呼叫其他物件,呈現結果。
到這,已經是萬事具備,只欠東風。欠什麼東風?就是想辦法通過測試。因為這一系列是重構,所以我們根本不需要額外撰寫太多的程式碼(甚至只需要挪動程式碼),只需要把原本的程式碼,一個蘿蔔一個坑的,擺到它應該放的位置上,接著按個鍵就可以確認這樣的搬移動作是否有錯。
搬 code ,按下執行測試,我可以肯定你 3 分鐘就學會了!
目前的程式碼
先來回顧一下目前的物流商物件程式碼,仍然是透過「產生」功能所產生的殼:
public class BlackCat
{
public void Calculate()
{
throw new NotImplementedException();
}
public Product ShipProduct { get; set; }
public string GetsComapanyName()
{
throw new NotImplementedException();
}
public double GetsFee()
{
throw new NotImplementedException();
}
}
public class Hsinchu
{
public void Calculate()
{
throw new NotImplementedException();
}
public Product ShipProduct { get; set; }
public string GetsComapanyName()
{
throw new NotImplementedException();
}
public double GetsFee()
{
throw new NotImplementedException();
}
}
public class PostOffice
{
public void Calculate()
{
throw new NotImplementedException();
}
public Product ShipProduct { get; set; }
public string GetsComapanyName()
{
throw new NotImplementedException();
}
public double GetsFee()
{
throw new NotImplementedException();
}
}
而頁面的程式碼,則是把計算運費的 function 都註解掉了,程式碼如下:
//將各方法內容搬到各個物流商的計算運費方法中
//private void CalculatedByPostOffice()
//{
// //頁面呈現物流商名稱
// this.lblCompany.Text = "郵局";
// //頁面取值
// var weight = Convert.ToDouble(this.txtProductWeight.Text);
// var feeByWeight = 80 + weight * 10;
// var length = Convert.ToDouble(this.txtProductLength.Text);
// var width = Convert.ToDouble(this.txtProductWidth.Text);
// var height = Convert.ToDouble(this.txtProductHeight.Text);
// var size = length * width * height;
// var feeBySize = size * 0.0000353 * 1100;
// //計算運費邏輯
// if (feeByWeight < feeBySize)
// {
// //頁面呈現計算的運費結果
// this.lblCharge.Text = feeByWeight.ToString();
// }
// else
// {
// //頁面呈現計算的運費結果
// this.lblCharge.Text = feeBySize.ToString();
// }
//}
//private void CalculatedByHsinchu()
//{
// //頁面呈現物流商名稱
// this.lblCompany.Text = "新竹貨運";
// //頁面取值
// var length = Convert.ToDouble(this.txtProductLength.Text);
// var width = Convert.ToDouble(this.txtProductWidth.Text);
// var height = Convert.ToDouble(this.txtProductHeight.Text);
// var size = length * width * height;
// //計算運費邏輯
// //長 x 寬 x 高(公分)x 0.0000353
// if (length > 100 || width > 100 || height > 100)
// {
// //頁面呈現計算的運費結果
// this.lblCharge.Text = (size * 0.0000353 * 1100 + 500).ToString();
// }
// else
// {
// //頁面呈現計算的運費結果
// this.lblCharge.Text = (size * 0.0000353 * 1200).ToString();
// }
//}
//private void CalculatedByBlackCat()
//{
// //頁面呈現物流商名稱
// this.lblCompany.Text = "黑貓";
// //頁面取值
// var weight = Convert.ToDouble(this.txtProductWeight.Text);
// //計算運費邏輯
// if (weight > 20)
// {
// //頁面呈現計算的運費結果
// this.lblCharge.Text = "500";
// }
// else
// {
// //頁面呈現計算的運費結果
// var fee = 100 + weight * 10;
// this.lblCharge.Text = fee.ToString();
// }
//}
測試程式碼,也就是那該死的 9 個紅燈,程式碼如下:
/// <summary>
///GetsComapanyName 的測試
///</summary>
[TestMethod()]
public void GetsComapanyNameTest_v3()
{
BlackCat target = new BlackCat();
string expected = "黑貓";
string actual;
actual = target.GetsComapanyName();
Assert.AreEqual(expected, actual);
}
/// <summary>
///GetsFee 的測試
///</summary>
[TestMethod()]
public void GetsFeeTest_v3()
{
BlackCat target = new BlackCat();
double expected = 0F;
double actual;
actual = target.GetsFee();
Assert.AreEqual(expected, actual);
}
/// <summary>
///Calculate 的測試
///</summary>
[TestMethod()]
public void CalculateTest_v3()
{
//從整合測試的test case,來當做單元測試的test case
//arrange
BlackCat target = new BlackCat()
{
ShipProduct = new Product
{
IsNeedCool = true,
Name = "商品測試1",
Size = new Size
{
Height = 10,
Length = 30,
Width = 20
},
Weight = 10
}
};
//act
target.Calculate();
var expectedName = "黑貓";
var expectedFee = 200;
var actualName = target.GetsComapanyName();
var actualFee = target.GetsFee();
//assert
Assert.AreEqual(expectedName, actualName);
Assert.AreEqual(expectedFee, actualFee);
}
/// <summary>
///Calculate 的測試
///</summary>
[TestMethod()]
public void CalculateTest_v3()
{
Hsinchu target = new Hsinchu()
{
ShipProduct = new Product
{
IsNeedCool = true,
Name = "商品測試1",
Size = new Size
{
Height = 10,
Length = 30,
Width = 20
},
Weight = 10
}
};
//act
target.Calculate();
//assert
var expectedName = "新竹貨運";
var expectedFee = 254.16;
var actualName = target.GetsComapanyName();
var actualFee = target.GetsFee();
//assert
Assert.AreEqual(expectedName, actualName);
Assert.AreEqual(expectedFee, actualFee);
}
/// <summary>
///GetsComapanyName 的測試
///</summary>
[TestMethod()]
public void GetsComapanyNameTest_v3()
{
Hsinchu target = new Hsinchu();
string expected = "新竹貨運";
string actual;
actual = target.GetsComapanyName();
Assert.AreEqual(expected, actual);
}
/// <summary>
///GetsFee 的測試
///</summary>
[TestMethod()]
public void GetsFeeTest_v3()
{
Hsinchu target = new Hsinchu();
double expected = 0F;
double actual;
actual = target.GetsFee();
Assert.AreEqual(expected, actual);
}
/// <summary>
///Calculate 的測試
///</summary>
[TestMethod()]
public void CalculateTest_v3()
{
PostOffice target = new PostOffice()
{
ShipProduct = new Product
{
IsNeedCool = true,
Name = "商品測試1",
Size = new Size
{
Height = 10,
Length = 30,
Width = 20
},
Weight = 10
}
};
//act
target.Calculate();
//assert
var expectedName = "郵局";
var expectedFee = 180;
var actualName = target.GetsComapanyName();
var actualFee = target.GetsFee();
//assert
Assert.AreEqual(expectedName, actualName);
Assert.AreEqual(expectedFee, actualFee);
}
/// <summary>
///GetsFee 的測試
///</summary>
[TestMethod()]
public void GetsFeeTest_v3()
{
PostOffice target = new PostOffice();
double expected = 0F;
double actual;
actual = target.GetsFee();
Assert.AreEqual(expected, actual);
}
/// <summary>
///GetsComapanyName 的測試
///</summary>
[TestMethod()]
public void GetsComapanyNameTest_v3()
{
PostOffice target = new PostOffice();
string expected = "郵局";
string actual;
actual = target.GetsComapanyName();
Assert.AreEqual(expected, actual);
}
重構第七式:食神歸位
既然已經定義好,誰該做什麼事,也定義好大家應該有的產出結果,接下來就是要進行重構循環的『填入』動作。這個動作的重點在於,要想辦法讓紅燈(包括單元測試與整合測試),變成綠燈。現在在循環中,所處的階段如下圖所示:
我們將原本頁面上計算運費的方法內容,分別搬到所屬的物流商計算運費的方法裡面。
食神歸位的版本,以黑貓為例(頁面程式沒有改變,就不在此列出):
public class BlackCat
{
private double _fee;
private readonly string _companyName = "黑貓";
public Product ShipProduct { get; set; }
public void Calculate()
{
////頁面呈現物流商名稱
//this.lblCompany.Text = "黑貓";
////頁面取值
//var weight = Convert.ToDouble(this.txtProductWeight.Text);
////計算運費邏輯
//if (weight > 20)
//{
// //頁面呈現計算的運費結果
// this.lblCharge.Text = "500";
//}
//else
//{
// //頁面呈現計算的運費結果
// var fee = 100 + weight * 10;
// this.lblCharge.Text = fee.ToString();
//}
var weight = this.ShipProduct.Weight;
//計算運費邏輯
if (weight > 20)
{
this._fee = 500;
}
else
{
//頁面呈現計算的運費結果
var fee = 100 + weight * 10;
this._fee = fee;
}
}
public string GetsComapanyName()
{
return this._companyName;
}
public double GetsFee()
{
return this._fee;
}
}
物流商的相關程式碼都歸位後,檢視一下執行單元測試與整合測試結果。
單元測試那 9 個紅燈,都變成綠燈了,請見下圖:
原本 Selenium 的紅燈,也變成綠燈了,請見下圖:
很好,我們又回到了綠燈了。
小結
這一篇所提到的動作,看起來似乎沒啥重點,但是這是一個循環的最後收尾動作。在重構中,基本上根本不會改動到原本的商業邏輯,絕大部分(也最有效)的重構動作,都是小幅變更一些壞味道的程式碼,例如排版、註解、重新命名跟擷取方法,接下來才是中等程度的重構,例如獨立物件職責、擷取介面、透過 pattern 解決需求所需要的彈性。
到這邊,要提醒一下讀者,在重構的循環中,只要是綠燈的情況,就代表可以 deploy 到正式環境。也就代表不管我們改了什麼,程式仍可如預期般的執行出正確的結果。
以此為原則,各位讀者在進行重構的時候,就可以考量需求與資源,來調整要重構的細度需要到哪。
設計時遵循著這樣的順序:
- 先滿足使用上的需求;
- 需求也就是測試案例;
- 滿足測試案例也就是測試程式要通過;
- 測試程式通過就代表 production code 滿足需求。
只要綠燈,基本上就可以 deploy ,前提是測試案例要足夠代表使用者的所有需求。否則系統功能還是會跟下圖的起司一樣:
重構一個重點:適可而止。
這是避免over design的一大原則,重構到一個極致,用了一堆 design pattern 和特殊的架構設計,如果無法滿足可讀性、可維護性,再有彈性也沒人看的懂、改的動。
心裡一定要記住 YAGNI 原則! You ain't gonna need it!
補充
針對上述重構應該適可而止,怎麼樣的設計可以稱為簡單又足夠的設計,可以參考一下 ihower 這一篇文章所提到的 Kent Beck 的四個簡單程式設計原則:
- Pass All Tests
- Reveals Intent (Self-Documenting Code)
- No Duplication (DRY)
- Has no superfluous parts (Minimizes the number of classes and methods)
基本上我自己也覺得這四個基本原則還不足夠充分,但仍可以點出:「至少最應該滿足的四項原則」。
我個人是認為至少還要到滿足 SOLID 原則以及 IoC 的設計。一旦前面沒有 interface-oriented ,或是 interface 與方法簽章沒有穩定就 publish 出去給外面使用,那後續的重構成本跟難度都會大幅增加,所以個人認為要把重構成本也納入的話,至少要做到這樣的程度。
blog 與課程更新內容,請前往新站位置:http://tdd.best/