C# Test Legacy Code(1)Isolated by Inheritance and Override
前言在進行許多單元測試的教育訓練時,最常被問到的一個問題就是:
我的建議方案有三:
這篇文章要介紹的,就是插管治療法中的絕招之一:使用「繼承+覆寫+擴充」,就能讓你的 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()); } } 可以看到這段程式碼,因直接相依外部資源而導致不具備可測試性的地方有二:
如果要寫 Isolated Unit Test,就要隔絕 File I/O 及 web service 的相依關係。 重構 Step 1把 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 的訂單,應新增兩筆資料到 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 互動兩次。 [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")); } 結論這樣的插管治療法,只用了物件導向中最基本的概念:
優點:
缺點:
相信這麼簡單的作法,帶來這麼強大的威力,可以讓大家把重症纏身的 Legacy Code ,透過插管治療而恢復其健康、SOLID 的本質。 最後的叮嚀,在插管完後如果實務上有中介層的需求,還是請讀者在有插管保護的情況下,重構 target 使其具備實務的彈性外,同時具備正規的可測試性,如此才能根除病因。 Github Reference |