[30天快速上手TDD][Day 15]Refactoring - 食神歸位

[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 的紅燈,也變成綠燈了,請見下圖:

歸位後Selenium測試結果

很好,我們又回到了綠燈了。

綠燈

 

小結

這一篇所提到的動作,看起來似乎沒啥重點,但是這是一個循環的最後收尾動作。在重構中,基本上根本不會改動到原本的商業邏輯,絕大部分(也最有效)的重構動作,都是小幅變更一些壞味道的程式碼,例如排版、註解、重新命名跟擷取方法,接下來才是中等程度的重構,例如獨立物件職責、擷取介面、透過 pattern 解決需求所需要的彈性。

到這邊,要提醒一下讀者,在重構的循環中,只要是綠燈的情況,就代表可以 deploy 到正式環境。也就代表不管我們改了什麼,程式仍可如預期般的執行出正確的結果。

以此為原則,各位讀者在進行重構的時候,就可以考量需求與資源,來調整要重構的細度需要到哪。

設計時遵循著這樣的順序:

  1. 先滿足使用上的需求;
  2. 需求也就是測試案例;
  3. 滿足測試案例也就是測試程式要通過;
  4. 測試程式通過就代表 production code 滿足需求。

只要綠燈,基本上就可以 deploy ,前提是測試案例要足夠代表使用者的所有需求。否則系統功能還是會跟下圖的起司一樣:

起司

重構一個重點:適可而止

這是避免over design的一大原則,重構到一個極致,用了一堆 design pattern 和特殊的架構設計,如果無法滿足可讀性、可維護性,再有彈性也沒人看的懂、改的動。

心裡一定要記住 YAGNI 原則! You ain't gonna need it!

補充

針對上述重構應該適可而止,怎麼樣的設計可以稱為簡單又足夠的設計,可以參考一下 ihower 這一篇文章所提到的 Kent Beck 的四個簡單程式設計原則

  1. Pass All Tests
  2. Reveals Intent (Self-Documenting Code)
  3. No Duplication (DRY) 
  4. Has no superfluous parts (Minimizes the number of classes and methods) 

基本上我自己也覺得這四個基本原則還不足夠充分,但仍可以點出:「至少最應該滿足的四項原則」。

我個人是認為至少還要到滿足 SOLID 原則以及 IoC 的設計。一旦前面沒有 interface-oriented ,或是 interface 與方法簽章沒有穩定就 publish 出去給外面使用,那後續的重構成本跟難度都會大幅增加,所以個人認為要把重構成本也納入的話,至少要做到這樣的程度。

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。