iT邦幫忙

2021 iThome 鐵人賽

DAY 17
2
Software Development

你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)系列 第 17

Day 17 「提槍上陣」在測試保護下重構出 State 設計模式


報告班長,圖片截自網路

大家有聽過「報告班長」嗎?這部 1987 年的電影,當年推出後一炮而紅,帶領一陣中華民國軍教片的風潮,由庾澄慶演唱的同名片尾曲也為這位歌手打響名號。但你有發現,有句歌詞唱錯了嗎?

歌詞裡有句唱著:「出槍快、轉槍慢」,但是根據中華民國單兵作戰準則,標準用槍技巧,為了使單兵記得出槍動作不要太大驚動敵人,且準備好後立即進入備射姿勢,其實應該要是「出槍慢、轉槍面快」才對。

你想,要是在戰場上,阿兵哥想起歌詞,先快速出槍吸引敵人注意,再慢慢準備射擊動作,那還怎麼活命啊?幸好大家都沒有很認真在背歌詞,不然我們要怎麼反共救國呢?

好,總之我們要來提槍上陣了。本文將實際找一個案例,在程式與測試都具備的情況下,試著透過一些重構手法,將原程式調整成可讀性與易修改性都比較好的樣貌,同時不破壞原邏輯。最後驗證當新需求來臨時,如此重構後的新設計,是否真能限縮修改範圍,以更快速地因應。

Console Interaction

Console Interaction 是筆者在網路上找到的一個 TDD 練習題,這裡我們還沒有要聊 TDD 的部份,所以我們假設已經有了可運行的程式,並且有測試保護。接下來我們會先了解題目內容,再進行下一步:

大家可能像筆者年紀夠大,有玩過像 Mud 這種「線上文字遊戲」,如果沒有,那應該也使用過坊間一些使用 CLI 介面來互動的電腦軟體。這種介面沒有視窗介面那麼美觀親切,但在某些場合還蠻能快速解決問題的,譬如安裝系統應用程式。


圖片截自 Wikipedia

Console Interaction 的題目要求我們為一個「計算周長與面積」的應用程式編寫 CLI 程式。此程式先問使用者要計算矩型(Rectangle)還是圓型(Circle),再依使用者輸入,決定下一個問題,直到問完必須的問題,就會秀出該圖型的周長與面積。

分析:隱藏的陷阱

這個題目要完成不難,但要寫得好測不容易,關鍵在開發者有沒有發現陷阱,把「使用者介面」與「主邏輯」分開。什麼意思?其實題目雖名為 Console Interaction,但其實使用者介面應該要是可以隨時抽換的。意即,你就算把介面換成 GUI,甚至是 Web,都不該影響核心的「狀態」與「資料」。

開工:先用醜方法完成功能

看穿了這一點,我們發現我們其實可以只管「主邏輯」的程式與測試就好。上文有提到這篇文章著重在「重構」,於是我們假設有個小天使為我們完成了初版的程式如下:

public class Module {
    private final String RECTANGLE_B_SELECTED = "RectangleBSelected";
    private final String RECTANGLE_A_SELECTED = "RectangleASelected";
    private final String RECTANGLE_SELECTED = "RectangleSelected";
    private final String INITIAL = "Initial";


    private String status = INITIAL;
    private int a;
    private int b;

    public String print() {
        if (this.status.equals(RECTANGLE_SELECTED)) {
            return "Rectangle side A length?";
        }

        if (this.status.equals(RECTANGLE_A_SELECTED)) {
            return "Rectangle side B length?";
        }

        if (this.status.equals(RECTANGLE_B_SELECTED)) {
            return "Area=" + (a * b) + ", Circumference=" + (2 * (a + b));
        }
        return "Shape: (C)ircle or (R)ectangle?";
    }

    public void input(String answer) {
        if (this.status.equals(INITIAL) && answer.equals("R")) {
            this.status = RECTANGLE_SELECTED;
        } else if (this.status.equals(RECTANGLE_SELECTED)) {


            try {
                Integer answerInt = Integer.valueOf(answer);
                this.a = answerInt;
                this.status = RECTANGLE_A_SELECTED;
            } catch (NumberFormatException e) {
                return;
            }

        } else if (this.status.equals(RECTANGLE_A_SELECTED)) {

            try {
                Integer answerInt = Integer.valueOf(answer);
                this.b = answerInt;
                this.status = RECTANGLE_B_SELECTED;
            } catch (NumberFormatException e) {
                return;
            }
        }

    }
}

上面的程式,把主邏輯命名為 Module,並且定義 Module 的狀態,每當 User Interface 呼叫 Module 的 print 或 input 指令, Module 就先判斷自己現在處於什麼狀態,並進行對應的行為。

程式完成了,測試當然也不能少,於是補上測試,確保功能正常。這裡礙於篇幅,只列出大約 1/3 的測項,但應能足夠看出測試的設計與意圖:

class ConsoleInteractionTest {
    @Test
    void initial_and_print() {


        Module module = new Module();

        String printed = module.print();

        Assertions.assertEquals("Shape: (C)ircle or (R)ectangle?", printed);


    }

    @Test
    void initial_and_R() {

        Module module = new Module();

        module.input("R");

        String printed = module.print();

        Assertions.assertEquals("Rectangle side A length?", printed);


    }


    @Test
    void initial_and_R_5() {

        Module module = new Module();

        module.input("R");

        module.input("5");

        String printed = module.print();

        Assertions.assertEquals("Rectangle side B length?", printed);


    }
    
    // ... 後略

}

分析:暫停,聞聞壞味道

至此,我停下來了。讀者應該有發現,我只完成一半工作,Circle 的邏輯還沒做。為什麼不繼續?因為這裡已經有非常明顯的壞味道了,如果硬著頭皮繼續,晚點重構成本會太高。因此,我決定在此暫停一下,聞聞壞味道,重構一下,然後再繼續。

首先,If 太多了!每次 User Interface 與 Module 互動,Module 就要經過一連串的 If 判斷,才能找到對應的行為。這裡還沒把 Circle 相關的狀態們放進來,print 與 input 就要承擔這麼多責任,很明顯違反了「單一職責原則」,使得這兩個方法同時變成了修改的熱點,需要改善。

另外,在 input 的字串處理的邏輯也重複了,重複永遠是我們要極力避免的壞味道,這點也需要改善。

重構:決定策略

其實說到底,這個 Module 的「資料」與「狀態」被綁在一起了,但「狀態」與「狀態對應的處理邏輯」又被分開了。換句話說,在 Module 的方法被呼叫時,「現在該怎麼處理」應該要與「現在是什麼狀態」應該要被獨立抽到不同類別去才對,而 Module 只要負責「放一個狀態在身上」就好,其他該做什麼事、什麼時候該跳狀態,讓狀態他們自己去協調就好。

聰明如你,可能已經想到了,這不就是「狀態模式」的場景嗎?沒錯,你果真有讀書!既然如此我們就來試著重構成狀態模式吧!決定了重構的方向,又有測試保護,那就直接開工吧!因為重構步驟繁多(我們沒有要「重寫」),用文字不容易表達,這裡用一段影片,來示範重構細節:

重構:來動手吧!


小步驟重構,參考影片

現在我們再來試著加入剛剛未完成的 Circle 邏輯,看看已經重構成「狀態模式」的這段程式,在需要加入新狀態時好不好加。都已經使用了狀態模式,理應符合「開放封閉原則」,這時應該只需新增類,不需修改舊類別才對。試了就知道:


重構完後,接著完成剩餘功能

結論

今天我們藉由一個程式題,走了一遍分析、寫程式、寫測試、發現有 Code Smell、停下來再分析、接著再重構的流程。在日常工作中,不一定會經常遇到這麼複雜的場景,但這只是個練習,主要是希望讀者們參考一下這樣的工作方式,未來在工作上可以也試著照這樣的流程,一小步一小步慢慢前進,而使每一步都走得穩健而安全,才能做自己老是在「安全的環境」中進行每天的工作。最後,惟有看出設計模式的必要性,才改寫成設計模式。

謎之聲:「設計模式是重構出來的,不是規劃出來的。」

Reference

  1. Console Interaction:https://sites.google.com/site/tddproblems/all-problems-1/Console-interaction
  2. CLI:https://en.wikipedia.org/wiki/Command-line_interface
  3. 狀態模式:https://en.wikipedia.org/wiki/State_pattern
  4. 本文範例程式:https://github.com/bearhsu2/design_patterns/tree/master/src/main/java/com/kuma/playground/console_interaction
tags: ithelp2021

上一篇
Day 16 「聽從你的蜥蜴腦」單元測試、Code Smell 與重構 - If 篇
下一篇
Day 18 「春暖鴨先知」TDD 來了
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

尚未有邦友留言

立即登入留言