找到需要重構的部份。「Legacy Code 不具備可測試性。

找到需要重構的部份,「Legacy Code 不具備可測試性

1. 找到壞味道:
透過靜態程式碼分析等工具,找到需要重構的部份。
2. 確認人未是自身殺的:
確定現行程式碼可以健康運作,我們目標只是当重構,不是以
bug fix 或需異動。
3. 錄影存證:
針對可正常運作的網頁,建立 selenium
test ,並且針對希望驗證的局部,加上 Assert 。
4. 說人話:
打開程式碼,靜下心來了解這段程式碼的目的與意義,抽象地來思考每一样段子程式碼代表的各一样起事,並進行排版、重新命名以及增加註解,提昇可讀性,讓自己下次可以迅速了解這段程式碼的意義。
5. 废弃物分類:
針對程式碼所表示的各国一样项事,透過重構技巧:擷取方法,依據人話來定義
function 名稱。讓 context 端僅剩下一堆會說話的 function
,讓程式碼抽象的意義浮現出來,而无欲张最多細節。
6. 職責分離:
摸索有誰,做什麼事。以當下物件的角度為出發點,確認哪一部分職責是屬於當下物件,哪部分職責屬於其他物件。並透過分離
function 中之主詞與動詞,來建立對應的物件與行為。
7. 寻找有需求:
将非屬於當下物件的職責都委託給其他物件,接著就是針對當下物件的需,定義出物件應該需要提供哪些行為。當下物件定義好需求的行為後,不需要询问其他物件背後的實作行為,便只是著手完成當下物件所提供的力量。
8. 驗貨:
確定其他物件給的,是滿足當下物件的求。先建其他物件的測試程式,單元測試案例則可以從
selenium 的測試案例找来端倪。這時執行測試會得到紅燈。
9. 食神歸位:
將原本在頁面上,屬於物流商職責的程式碼,搬至物流商物件中,目的是為了通過單元測試,因為通過測試即意味着滿足頁面需求,滿足頁面需求,即可通過
selenium test ,即意味着滿足使用者需求。

當 legacy code 不具備可測試性,又想為其树立 isolated unit test
且无影響所有以及這個 class 的場景端,可以透過 extract and override
的招数,使用繼承+覆寫,就会達到很有功能的 isolated unit test ,是針對
legacy code 撰寫 isolated unit test 最好用之技艺有。

摘自:http://msdn.microsoft.com/zh-tw/library/dn155891.aspx

前言

「Legacy Code 不具備可測試性,所以無法寫單元測試。但要要重構 Legacy
Code 前,又得測試來保護,那非就變成雞生蛋,蛋生雞的問題了嗎?」

針對這問題,我的建議方案来三:

  1. ** 建立粒度更特别之自動測試**(例如组合測試或驗收測試):讓整個
    Legacy Code
    的物件變成大黑箱,只待確認黑箱出來的結果符合要求預期即可。
  2. 使用「黑魔法」類型的 mock framework:可直接在 runtime 把
    dependency 的物件抽換成 stub/mock object (例如:Microsoft
    Fakes.aspx)
    或 TypeMocks
    ),如此好事先成立可運作且实用之單元測試,等重構成為可測物件時,請記得要将施用私自魔法的測試案例,改成为一般
    mock framework 的單元測試。
  3. 插管治疗療法:使用一些簡單的重構+可測試性技巧,讓絕大部分之
    legacy code 也堪輕鬆做到 isolated 的單元測試。

本文要介紹的,就是插管治療法中之絕招之一:使用「擷取方法 +
繼承+覆寫+擴充
」,就能够讓你的 production code 不需修改對外的别
API(包括 constructor 與 property),就可以得 isolated dependency
效果。

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());
        }
    }

足见到這段程式碼,因一直就外部資源而導致不具備可測試性的地方有第二:

  1. 訂單的資料來源:Parsing CSV 檔時,直接用到 StreamReader 透過 File
    I/O 讀取檔案內容。
  2. BookDao 中,透過 HttpClient 與外部 3rd-party web service
    直接就。(3rd-party API 或者没有構建完成)

如只要對 OrderService.SyncBookOrders() 撰寫 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 中。

因為這邊要驗證 OrderServiceIBookDao 的互動,所以待动用 mock
object 來進行 assertion 的動作。

        [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
,所以預期要與 IBookDaoInsert() 互動兩差。

        [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"));
        }

isolated unit test 通過

結論

這樣的插管治疗療法,只所以了物件導向中最为中心的概念:

  1. 繼承:Stub class 繼承自 target class ,是 is-A 的關係,不管
    target class 未來怎麼修改,基本上 stub class 的行為都與 target class
    一致。
  2. 覆寫:針對需要 isolated 的一对,只待发布成 protected virtual
    ,既不會對外暴露不必要之資訊,仍維持良好封裝的特性,對 stub class
    來說,能輕易地在測試程式中決定要回傳的值。
  3. 擴充:stub class (target
    在測試專案中自动撰寫的子類)可以額外開放一些艺术供測試程式注入价值或
    stub/mock 物件,而休會對 target 的設計有其它影響。

Pros:

  1. 100% 適合針對不享有可測試性的 Legacy Code 加入單元測試。
  2. 沒有改變 Legacy Code 對外的其余 API ,除了不影響原本 context 使用
    target 物件外,還可以讓 production code
    聚焦于需要與產品程式碼上,而非需要為了可測試性增加許多非必要的中介層,或是開放許多无必要之道給外部觀看。不為了測試而測試,是最高原則。
  3. 簡單、直覺、好懂、好維護。

Cons:

  1. Code coverage 可能为此下降,因為執行到 virtual function
    的方式時,會移轉到測試專案中的 stub class override 的法子中。
  2. virtual function
    的行為改變時(也不怕是需要異動),要額外留意是否把商業邏輯也 override
    掉了,有或用只要錯過需要進行驗證的商業邏輯。

信任這麼簡單的作法,帶來這麼強大的威力,可以讓大家将重症纏身的 legacy
code ,透過插管治療而恢復其正常、SOLID 的本質。

最後的叮嚀,在插管完後如果實務上发出中介層的急需,還是請讀者在有插管保護的情況下,重構
target
使其具備實務的彈性外,同時具備正規的只是測試性,如此才能够杜绝病因。

>> 延伸:使用 moq 動態 override protected function
行為