[Unit Test Tricks] Extract and Override

當 legacy code 不具備可測試性,又想為其建立 isolated unit test 且不影響所有使用到這個 class 的場景端,可以透過 extract and override 的手法,使用繼承+覆寫,就能達到很有效益的 isolated unit test ,是針對 legacy code 撰寫 isolated unit test 最好用的技巧之一。

前言

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

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

  1. ** 建立粒度更大的自動測試**(例如整合測試或驗收測試):讓整個 Legacy Code 的物件變成大黑箱,只需確認黑箱出來的結果符合需求預期即可。
  2. 使用「黑魔法」類型的 mock framework:可直接在 runtime 把 dependency 的物件抽換成 stub/mock object (例如:Microsoft FakesTypeMocks ),如此可以先建立可運作且有效的單元測試,等重構成為可測物件時,請記得要把使用黑魔法的測試案例,改成一般 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 行為

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容