[30天快速上手TDD][Day 10]Refactoring 起手式 - 建立測試

[30天快速上手TDD][Day 10]Refactoring 起手式 - 建立測試

前言

上一篇文章中,介紹了如何透過一些靜態程式碼分析的工具,搭配品質指標的門檻,來快速找到系統中需要重構的程式。也稍微的介紹了,重構目標的程式基本功能與樣式。

這一篇文章則要開始進行重構了,保證每一步都相當簡單,大家都可以跟著做到。

 

現況

目前的程式碼如下:

.aspx 的程式碼:


<%@ Page Title="" Language="C#" MasterPageFile="~/Site.master" AutoEventWireup="true"
    CodeFile="Product_v0.aspx.cs" Inherits="Product_v0" %>

<asp:Content ID="Content1" ContentPlaceHolderID="HeadContent" runat="Server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="Server">
    <div>
        <fieldset>
            <legend>商品資訊</legend>
            <table style="width: 100%;">
                <tr>
                    <td>
                        商品名稱
                    </td>
                    <td>
                        <asp:TextBox ID="txtProductName" runat="server"></asp:TextBox>
                        <asp:RequiredFieldValidator ID="RequiredFieldValidator2" runat="server" ErrorMessage="請輸入商品名稱"
                            ControlToValidate="txtProductName"></asp:RequiredFieldValidator>
                    </td>
                </tr>
                <tr>
                    <td>
                        重量
                    </td>
                    <td>
                        <asp:TextBox ID="txtProductWeight" runat="server"></asp:TextBox>
                        <asp:RequiredFieldValidator ID="RequiredFieldValidator3" runat="server" ErrorMessage="請輸入商品重量"
                            ControlToValidate="txtProductWeight"></asp:RequiredFieldValidator>
                    </td>
                </tr>
                <tr>
                    <td>
                        長
                    </td>
                    <td>
                        <asp:TextBox ID="txtProductLength" runat="server"></asp:TextBox>
                        <asp:RequiredFieldValidator ID="RequiredFieldValidator4" runat="server" ErrorMessage="請輸入商品長度"
                            ControlToValidate="txtProductLength"></asp:RequiredFieldValidator>
                    </td>
                </tr>
                <tr>
                    <td>
                        寬
                    </td>
                    <td>
                        <asp:TextBox ID="txtProductWidth" runat="server"></asp:TextBox>
                        <asp:RequiredFieldValidator ID="RequiredFieldValidator5" runat="server" ErrorMessage="請輸入商品寬度"
                            ControlToValidate="txtProductWidth"></asp:RequiredFieldValidator>
                    </td>
                </tr>
                <tr>
                    <td>
                        高
                    </td>
                    <td>
                        <asp:TextBox ID="txtProductHeight" runat="server"></asp:TextBox>
                        <asp:RequiredFieldValidator ID="RequiredFieldValidator6" runat="server" ErrorMessage="請輸入商品高度"
                            ControlToValidate="txtProductHeight"></asp:RequiredFieldValidator>
                    </td>
                </tr>
                <tr>
                    <td>
                        是否需低溫冷藏
                    </td>
                    <td>
                        <asp:RadioButtonList ID="rdoNeedCool" runat="server" RepeatDirection="Horizontal">
                            <asp:ListItem Value="1">是</asp:ListItem>
                            <asp:ListItem Value="0">否</asp:ListItem>
                        </asp:RadioButtonList>
                        <asp:RequiredFieldValidator ID="RequiredFieldValidator7" runat="server" ErrorMessage="請輸入是否需低溫冷藏" ControlToValidate="rdoNeedCool"></asp:RequiredFieldValidator>
                    </td>
                </tr>
                <tr>
                    <td>
                        物流商
                    </td>
                    <td>
                        <asp:DropDownList ID="drpCompany" runat="server">
                            <asp:ListItem>請選擇</asp:ListItem>
                            <asp:ListItem Value="1">黑貓</asp:ListItem>
                            <asp:ListItem Value="2">新竹貨運</asp:ListItem>
                            <asp:ListItem Value="3">郵局</asp:ListItem>
                        </asp:DropDownList>
                        <asp:RequiredFieldValidator ID="RequiredFieldValidator1" ControlToValidate="drpCompany"
                            InitialValue="請選擇" runat="server" ErrorMessage="請選擇物流商"></asp:RequiredFieldValidator>
                    </td>
                </tr>
            </table>
            <asp:Button ID="btnCalculate" runat="server" Text="計算運費" 
                onclick="btnCalculate_Click" />
        </fieldset>
    </div>
    <div>
        <fieldset>
            <legend>結果</legend>物流商:<asp:Label ID="lblCompany" runat="server"></asp:Label>
            <br />
            運費:<asp:Label ID="lblCharge" runat="server"></asp:Label>
        </fieldset>
    </div>
</asp:Content>

網頁畫面如下:

6

 

找到目標後,如何開始重構

重構的循環有幾個階段,分別為綠燈、重構、紅燈、填入。如下圖所示:

1

當我們想要進行『重構』的動作:

2

就應該先進行『綠燈』的前置作業:

3

 

重構起手式:口說無憑、錄影存證

要記住,現況的程式碼,雖然彷彿一坨垃圾,但他是可以執行出正確結果的垃圾。寫得再好、再完美的程式,如果無法執行出正確的結果,那也沒啥價值可言。

既然,我們要進行重構,重構的意義就在於:『不改變系統外在行為的條件下,改善系統內部的品質』,改程式很簡單,要確保只影響到我們改的程式,要確保原本的行為沒有改變,這個前提要比改程式重要得多。

所以,這邊透過 Selenium IDE ,先來幫助我們記錄下來現在可以執行出正確結果的行為。

時間,應該浪費在美好的事物上,而不是每次修改完程式,都還要手動去 key in 一堆沒意義的資料。用最少的 effort ,達到自動化的效果。 Selenium 的使用介紹,請見:[30天快速上手TDD][Day 8]Integration Testing & Web UI Testing

 

步驟

確保現在程式可以執行出正確的結果後,開始錄製腳本:

  1. 打開 Selenium IDE 後,按下錄製;
  2. 輸入商品的資訊;
  3. 選擇物流商;
  4. 按下計算按鈕;
  5. 將物流商名稱與運費結果記錄下來,並加入驗證項目;

錄製的腳本如下圖所示:

4

錄製過程中,請記得要在適當的步驟,加入 verify 的項目,確保到哪一個步驟時,應該有對應的預期結果。

以這邊的例子來說,就是「當選完物流商,重新點選計算運費時,我們會去驗證物流商的名稱,以及運費的結果,是否符合預期。」

看一下測試腳本,大概就知道測試案例進行了哪些動作,如下圖:

5

這裡,暫時還不需要將腳本轉換成 C# 的測試程式。因為我們的目的,只是確保重構完成後,原本可以正常執行的程式,仍然可以符合預期般正常執行。

 

小結

TDD 中循環的三大步驟:紅燈、綠燈、重構。

當切入點為重構時,首先就是要確認系統可以正常運作,接下來建立測試,這個測試建立完成後,應該可以通過測試,也就是第一個綠燈。

即使 legacy 的程式碼,是屬於物件直接相依,條件判斷邏輯可能也相當複雜或醜陋,沒關係,我們的第一步,就是確保最後的執行結果,仍然符合使用者的需求。

還記得嗎?越抽象、越上層的測試,基本上花的成本越小,但異動的頻率可能也會越高。 Selenium 在這個例子中,就可以發揮效益比最大的功效。因為我們花的成本,只有再操作一次系統而已。

當建立好了這個可以迅速、可重複、可自動執行的 Web UI 測試後,就可以當作是進入了 TDD 循環的綠燈階段。

接下來不管改了什麼程式,動了什麼手腳,即便是傷筋動骨,也可以確保最後產出結果,仍符合使用者預期。

更棒的是,基本上不會發生程式不小心改錯而不知情的情況,如果沒有這重要的起手式,就沒人可以保證修改完的程式是對的。

有了這一層最終的保護,也是我們最終的目的,就比較不會發生程式要上線後才由使用者發現問題的情況。如果,真有這樣的情況發生,代表測試案例不夠周全、完整,要做的應該是增加測試案例,並且設法通過測試案例。

還記得嗎?「程式碼不是寫給 developer 爽的,程式碼存在的目的,是為了滿足使用者的需求」,而「測試案例,就代表著使用者的需求有沒有被涵蓋與驗證完成」。

最後,以一句話總結:「重構的第一步,請先建立測試」。

 

補充

當懂得如何重構,也學會如何作單元測試的朋友,在實務上會碰到的第一個問題,就是一個矛盾的問題。

  1. 程式碼要避免直接相依,才有可測試性。
  2. 程式碼要重構,才能解開直接相依的耦合性。
  3. 要先建立測試,才能重構。

但,這不就一環咬著一環嗎?

這也是為什麼,前面需要花這麼多篇文章來介紹不同層級的測試。

這三步的矛盾點,在於「單元測試」得程式碼具備可測試性,也就是得物件獨立,得物件不直接相依。但沒有測試,又不給重構。

因此,只需要再建立一層更高層級的測試,成本低,穩定性也低(但即使是用過一次即丟,也還是有其價值所在)透過 UI 的迴歸測試、整合測試,來保護最終結果符合使用者預期,接下來只要小幅度地開始進行重構,直到物件職責分開、相依性分開後,只需要接著建立相關物件的單元測試,那麼整段程式碼的重構循環,也就告一段落了。

如果讀者朋友們,眼前碰到的 legacy system refactoring 難題就是這個矛盾, just try it !您也可以很輕鬆地就解決這個矛盾點唷。

針對 Dependency / Coupling 相當高的 legacy code ,除了先透過整合測試的方式,來進行保護以外,另一種 solution ,則是透過 isolation framework,如前面提過的 Microsoft Fakes, Moles, Typemock Isolator ( Java 則有 JMockIt 之類的工具)等等…這類工具,可以直接在執行時期攔截原本程式對相依類別或靜態方法的呼叫,轉呼叫 developer 的模擬行為,因此在使用上,等同於可以先透過 isolation 來隔離物件之間的相依性,而專注在先為目標物件建立測試保護,以便進行重構。


但這樣的方式,只能當作重構的切入點,用來解決一開始物件強烈耦合導致無法重構與獨立的問題。一旦使用 interface 隔開物件之間相依性之後,應當再為目標物件建立一般的單元測試。最簡單的原因,當然就是為了 interface 背後的 implement 可以抽換,而不需要再 isolate 特定的相依物件來模擬,而是直接抽象地模擬 interface 的行為,這會為測試程式帶來穩定性。


這邊也有一篇在 Martin Folwer 網站上的文章寫的很不錯:Modern Mocking Tools and Black Magic - An example of power corrupting,提供給大家參考一下。

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

對 TDD 課程有興趣的朋友,課程內容、大綱與學員心得,可以參考 skilltree 的公開課程:自動測試與 TDD 實務開發

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