當 legacy code 不具備可測試性,又想為其建立 isolated unit test 且不影響所有使用到這個 class 的場景端,可以透過 extract and override 的手法,使用繼承+覆寫,就能達到很有效益的 isolated unit test ,是針對 legacy code 撰寫 isolated unit test 最好用的技巧之一。
前言
在許多單元測試的教育訓練時,最常被問到的一個問題就是:
針對這問題,我的建議方案有三:
- 建立粒度更大的自動測試(例如整合測試或驗收測試):讓整個 Legacy Code 的物件變成大黑箱,只需確認黑箱出來的結果符合需求預期即可。
- 使用「黑魔法」類型的 mock framework:可直接在 runtime 把 dependency 的物件抽換成 stub/mock object (例如:Microsoft Fakes 或 TypeMocks ),如此可以先建立可運作且有效的單元測試,等重構成為可測物件時,請記得要把使用黑魔法的測試案例,改成一般 mock framework 的單元測試。
- 插管治療法:使用一些簡單的重構+可測試性技巧,讓絕大部分的 Legacy Code 也可以輕鬆做到 Isolated 的單元測試。
這篇文章要介紹的,就是插管治療法中的絕招之一:使用「擷取方法 + 繼承+覆寫+擴充」,就能讓你的 production code 不需要修改對外的任何 API(包括 constructor 與 property),就可以做到 Isolated 效果。
Legacy Code Sample
說明:有一個 OrderService 的物件,具有 SyncBookOrders()
的方法,用來讀某一個 csv 檔中的訂單訂單資料,針對 Type 為 Book 的訂單,要呼叫外部的 web service 進行新增資料的動作。
public class OrderService
{
private string _filePath= @"C:\temp\joey.csv";
public void SyncBookOrders()
{
var orders = this.GetOrders();
// only get orders of book
var ordersOfBook = orders.Where(x => x.Type == "Book");
var bookDao = new BookDao();
foreach (var order in ordersOfBook)
{
bookDao.Insert(order);
}
}
private List<Order> GetOrders()
{
// parse csv file to get orders
var result = new List<Order>();
// directly depend on File I/O
using (StreamReader sr = new StreamReader(this._filePath, Encoding.UTF8))
{
int rowCount = 0;
while (sr.Peek() > -1)
{
rowCount++;
var content = sr.ReadLine();
// Skip CSV header line
if (rowCount > 1)
{
string[] line = content.Trim().Split(',');
result.Add(this.Mapping(line));
}
}
}
return result;
}
private Order Mapping(string[] line)
{
var result = new Order
{
ProductName = line[0],
Type = line[1],
Price = Convert.ToInt32(line[2]),
CustomerName = line[3]
};
return result;
}
}
public class BookDao
{
internal void Insert(Order order)
{
// directly depend on some web service
var client = new HttpClient();
client.PostAsync("http://api.joey.io/Order", order, new JsonMediaTypeFormatter());
}
}
可以看到這段程式碼,因直接相依外部資源而導致不具備可測試性的地方有二:
- Parsing CSV 檔時,直接用到 StreamReader 透過 File I/O 讀取檔案內容。
- BookDao 中,透過 HttpClient 與外部 web service 直接相依。
如果要寫 Isolated Unit Test,就要隔絕 File I/O 及 web service 的相依關係。
重構 Step 1
先將需要隔絕相依的 production code 透過擷取方法(Extract Method)抽成 private function ,以這邊的例子來說,就是 GetOrders()
。接著把 private List<Order> GetOrders() 改成 protected virtual 供測試專案中的 stub class 進行覆寫。
protected virtual List<Order> GetOrders()
{
// parse csv file to get orders
var result = new List<Order>();
// directly depend on File I/O
using (StreamReader sr = new StreamReader(this._filePath, Encoding.UTF8))
{
int rowCount = 0;
while (sr.Peek() > -1)
{
rowCount++;
var content = sr.ReadLine();
// Skip CSV header line
if (rowCount > 1)
{
string[] line = content.Trim().Split(',');
result.Add(this.Mapping(line));
}
}
}
return result;
}
在測試專案中,新增一個 stub class 繼承自 OrderService 。覆寫其 GetOrders() ,並擴充一個 SetOrders(orders)
方法,以便在測試程式中,可以注入「當呼叫 GetOrders() 時回傳的值」。
internal class StubOrderService : OrderService
{
private List<Order> _orders= new List<Order>();
// only for test project to set the return values
internal void SetOrders(List<Order> orders)
{
this._orders = orders;
}
// return the stub values, isolated the File I/O of parsing csv file
protected override List<Order> GetOrders()
{
return this._orders;
}
}
重構 Step 2
在測試專案中,增加一個測試案例:若訂單有 3 張,其中 2 張是 Book 的訂單,應新增 2 筆資料到 BookDao。
[TestMethod]
public void Test_SyncBookOrders_3_Orders_Only_2_book_order()
{
// hard to isolate dependency to unit test
var target = new StubOrderService();
var orders = new List<Order>
{
new Order{ Type="Book", Price = 100, ProductName = "91's book"},
new Order{ Type="CD", Price = 200, ProductName = "91's CD"},
new Order{ Type="Book", Price = 300, ProductName = "POP book"},
};
target.SetOrders(orders);
//act
target.SyncBookOrders();
// how to assert interaction of target and web service ?
}
重構 Step 3
把 var bookDao = new BookDao(); 擷取方法後,透過 GetBookDao()
取得 BookDao 的 instance 。
public void SyncBookOrders()
{
var orders = this.GetOrders();
// only get orders of book
var ordersOfBook = orders.Where(x => x.Type == "Book");
// extract method to get BookDao
var bookDao = this.GetBookDao();
foreach (var order in ordersOfBook)
{
bookDao.Insert(order);
}
}
private BookDao GetBookDao()
{
return new BookDao();
}
針對 BookDao 擷取介面,定義一個 IBookDao
,並讓 GetBookDao()
回傳 IBookDao 。
public class OrderService
{
internal virtual IBookDao GetBookDao()
{
return new BookDao();
}
}
internal class BookDao : IBookDao
{
public void Insert(Order order)
{
// directly depend on some web service
var client = new HttpClient();
client.PostAsync("http://api.joey.io/Order", order, new JsonMediaTypeFormatter());
}
}
internal interface IBookDao
{
void Insert(Order order);
}
重構 Step 4
在測試專案的 StubOrderService 中,增加覆寫 GetBookDao()
的方法,並增加 SetBookDao()
供測試程式注入 IBookDao 的 stub/mock 物件。
internal class StubOrderService : OrderService
{
private List<Order> _orders = new List<Order>();
private IBookDao _bookDao;
// only for test project to set the return values
internal void SetOrders(List<Order> orders)
{
this._orders = orders;
}
// return the stub values, isolated the File I/O of parsing csv file
protected override List<Order> GetOrders()
{
return this._orders;
}
internal void SetBookDao(IBookDao bookDao)
{
this._bookDao = bookDao;
}
internal override IBookDao GetBookDao()
{
return this._bookDao;
}
}
在測試程式中,透過 NSubstitute
建立一個 IBookDao
的 mock object ,並透過 SetBookDao()
注入到 target 中。
[TestMethod]
public void Test_SyncBookOrders_3_Orders_Only_2_book_order()
{
// hard to isolate dependency to unit test
var target = new StubOrderService();
var orders = new List<Order>
{
new Order{ Type="Book", Price = 100, ProductName = "91's book"},
new Order{ Type="CD", Price = 200, ProductName = "91's CD"},
new Order{ Type="Book", Price = 300, ProductName = "POP book"},
};
target.SetOrders(orders);
var stubBookDao = Substitute.For<IBookDao>();
target.SetBookDao(stubBookDao);
//act
target.SyncBookOrders();
// how to assert interaction of target and web service ?
}
重構 Step 5
因為 production code 裡面有蠻多宣告成 internal 是為了不給 assembly 外使用,但又希望測試程式能正常測試,所以要修改 AssemblyInfo.cs 加入 [InternalsVisibleTo]
的宣告。
DynamicProxyGenAssembly2
看得到,是因為 mock framework 要能參考到 internal 的 interface ,才能動態建立 stub/mock object。
[assembly: InternalsVisibleTo("IsolatedByInheritanceAndOverride.Test")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
最後,只要在測試程式中,使用 NSub mock object 的 Received()
系列方法,就能驗證 target 與 IBookDao 是否符合預期般互動。在這個測試案例中,因為 3 張訂單有 2 張是 Book ,所以預期要與 IBookDao 的 Insert()
互動兩次。
[TestMethod]
public void Test_SyncBookOrders_3_Orders_Only_2_book_order()
{
//arrange
var target = new StubOrderService();
var orders = new List<Order>
{
new Order{ Type="Book", Price = 100, ProductName = "91's book"},
new Order{ Type="CD", Price = 200, ProductName = "91's CD"},
new Order{ Type="Book", Price = 300, ProductName = "POP book"},
};
target.SetOrders(orders);
var stubBookDao = Substitute.For<IBookDao>();
target.SetBookDao(stubBookDao);
//act
target.SyncBookOrders();
// assert
// there are 2 orders of Type="Book", so IBookDao.Insert() should be called 2 times
stubBookDao.Received(2).Insert(Arg.Is<Order>(x => x.Type == "Book"));
}
結論
這樣的插管治療法,只用了物件導向中最基本的概念:
- 繼承:Stub Class 繼承自 target class ,是 is-A 的關係,不管 target class 未來怎麼修改,基本上 Stub class 的行為都與 target class 一致。
- 覆寫:針對需要 Isolated 的部分,只需要宣告成
protected virtual
,既不會對外暴露不必要的資訊,仍維持良好封裝的特性,對 Stub Class 來說,能輕易地在測試程式中決定要回傳的值。 - 擴充:Stub Class (target 在測試專案中自行撰寫的子類)可以額外開放一些方法供測試程式注入值或 stub/mock 物件,而不會對 target 的設計有任何影響。
Pros:
- 100% 適合針對不具可測試性的 Legacy Code 加入單元測試。
- 沒有改變 Legacy Code 對外的任何 API ,除了不影響原本 context 使用 target 物件以外,還可以讓 production code 聚焦在需求與產品程式碼上,而不需要為了可測試性增加許多不必要的中介層,或是開放許多不必要的方法給外部觀看。不為了測試而測試,是最高原則。
- 簡單、直覺、好懂、好維護。
Cons:
- Code Coverage 可能因此下滑,因為執行到 virtual 的方法時,會移轉到測試專案中的 Stub Class override 的方法中。
- 當 virtual 的行為改變時(也就是需求異動),要額外留意是否把商業邏輯也 override 掉了,有可能因此而錯過需要進行驗證的商業邏輯。
相信這麼簡單的作法,帶來這麼強大的威力,可以讓大家把重症纏身的 legacy code ,透過插管治療而恢復其健康、SOLID 的本質。
blog 與課程更新內容,請前往新站位置:http://tdd.best/