TDD 實踐-FizzFuzzWhizz(二)

lyning發表於2019-02-26

標籤 | TDD Java
字數 | 2728 字

說明:該 TDD 系列案例主要是為了鞏固和記錄自己 TDD 實踐過程中的思考與總結。個人認為 TDD 本身並不難,難的大部分是程式設計之外的技能,比如分析能力、設計能力、表達能力和溝通能力;所以在 TDD 的過程中,個人認為 TDD 可以鍛鍊一個人事先思考、化繁為簡、制定計劃、精益求精的習慣和品質。本文的原始碼放在個人的 Github 上,案例需求來自於網上。

目標收益

  1. 熟悉掌握 TDD 整體流程。
  2. 識別程式碼壞味道 Deplicated Code 以及重構手法。
  3. 瞭解 java8 特性 lambda 和部分函式式介面的使用。
  4. 得到滿意的測試覆蓋率。
  5. 提高對程式碼的自信和重構的勇氣。

任務回顧

  • 學生報數。
    1. 如果是第一個特殊數字的倍數,就報 Fizz。
    2. 如果是第二個特殊數字的倍數,就報 Buzz。
    3. 如果是第三個特殊數字的倍數,就報 Whizz (當前任務)
    4. 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    5. 如果包含第一個特殊數字,只報 Fizz (忽略規則 1、2、3、4)
    6. 如果不是特殊數字的倍數,並且不包含第一個特殊數字,就報對應的序號。

程式碼回顧

public class Student {

    public static String countOff(Integer position, List<GameRule> gameRules) {
        if (position % gameRules.get(0).getNumber() == 0) {
            return gameRules.get(0).getTerm();
        } else if (position % gameRules.get(1).getNumber() == 0) {
            return gameRules.get(1).getTerm();
        } else if (position % gameRules.get(2).getNumber() == 0) {
            return gameRules.get(2).getTerm();
        }
        return position.toString();
    }
}
複製程式碼

測試驅動開發

TDD 實踐-FizzFuzzWhizz(二)

如果有任何重複的邏輯或無法解釋的程式碼,重構可以消除重複並提高表達能力(減少耦合,增加內聚力)。

上一篇文章的內容,此時我們需要解決程式碼中的壞味道——Duplicated Code。分析發現,程式碼之間只是類似,並非完全相同,而且程式碼表達的意圖很不清晰,可以使用 Extract Method 重構手法來解決這個問題,通過抽出 isMultiple 方法用於判學生的序號是否是特殊數的倍數,使程式碼意圖清晰一些,很快我就完成了初步的重構:

public class Student {

    public static String countOff(Integer position, List<GameRule> gameRules) {
        if (isMultiple(position, gameRules.get(0).getNumber())) {
            return gameRules.get(0).getTerm();
        } else if (isMultiple(position, gameRules.get(1).getNumber())) {
            return gameRules.get(1).getTerm();
        } else if (isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(2).getTerm();
        }
        return position.toString();
    }

    private static boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }
}
複製程式碼

執行自動化測試全部通過,不過取值的方式還是有點笨,然後我把上面那種取值方式改成通過迴圈自動取值以降低錯誤率,此時程式碼變得更加簡潔,表達的意圖也更加清晰:

public static String countOff(Integer position, List<GameRule> gameRules) {
    for (GameRule gameRule : gameRules) {
        if (isMultiple(position, gameRule.getNumber())) {
            return gameRule.getTerm();
        }
    }
    return position.toString();
}

private static boolean isMultiple(Integer divisor, Integer dividend) {
    return divisor % dividend == 0;
}
複製程式碼

再次執行測試驗證重構是否引入新的錯誤。如果沒有通過,很可能是在重構時犯了一些錯誤,需要立即修復並重新執行,直到所有測試通過。

TDD 實踐-FizzFuzzWhizz(二)
經過自動化測試的檢驗,測試全部通過,此時可以放心開始下一個子任務。


  • 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。

從描述中可以看出第 4 個子任務也非常簡單,很快我就寫好了對應的單元測試,並驅動出了具體實現:

public class StudentTest {
    private final List<GameRule> gameRules = Lists.list(
            new GameRule(3, "Fizz"),
            new GameRule(5, "Buzz"),
            new GameRule(7, "Whizz")
    );
    ...
    @Test
    public void should_return_fizzbuzz_when_just_a_multiple_of_the_first_number_and_second_number() {
        assertThat(Student.countOff(15, gameRules)).isEqualTo("FizzBuzz");
        assertThat(Student.countOff(45, gameRules)).isEqualTo("FizzBuzz");
    }

    @Test
    public void should_return_fizzwhizz_when_just_a_multiple_of_the_first_number_and_third_number() {
        assertThat(Student.countOff(21, gameRules)).isEqualTo("FizzWhizz");
        assertThat(Student.countOff(42, gameRules)).isEqualTo("FizzWhizz");
        assertThat(Student.countOff(63, gameRules)).isEqualTo("FizzWhizz");
    }

    @Test
    public void should_return_buzzwhizz_when_just_a_multiple_of_the_second_number_and_third_number() {
        assertThat(Student.countOff(35, gameRules)).isEqualTo("BuzzWhizz");
        assertThat(Student.countOff(70, gameRules)).isEqualTo("BuzzWhizz");
    }

    @Test
    public void should_return_fizzbuzzwhizz_when_at_the_same_time_is_a_multiple_of_the_three_number() {
        List<GameRule> gameRules = Lists.list(
                new GameRule(2, "Fizz"),
                new GameRule(3, "Buzz"),
                new GameRule(4, "Whizz")
        );
        assertThat(Student.countOff(24, gameRules)).isEqualTo("FizzBuzzWhizz");
        assertThat(Student.countOff(48, gameRules)).isEqualTo("FizzBuzzWhizz");
        assertThat(Student.countOff(96, gameRules)).isEqualTo("FizzBuzzWhizz");
    }
}

public class Student {
    public static String countOff(Integer position, List<GameRule> gameRules) {
    
        if (isMultiple(position, gameRules.get(0).getNumber()) 
                && isMultiple(position, gameRules.get(1).getNumber()) 
                && isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(0).getTerm() + gameRules.get(1).getTerm() + gameRules.get(2).getTerm();
        } else if (isMultiple(position, gameRules.get(0).getNumber()) 
                && isMultiple(position, gameRules.get(1).getNumber())) {
            return gameRules.get(0).getTerm() + gameRules.get(1).getTerm();
        } else if (isMultiple(position, gameRules.get(0).getNumber()) 
                && isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(0).getTerm() + gameRules.get(2).getTerm();
        } else if (isMultiple(position, gameRules.get(1).getNumber()) 
                && isMultiple(position, gameRules.get(2).getNumber())) {
            return gameRules.get(1).getTerm() + gameRules.get(2).getTerm();
        }
    
        for (GameRule gameRule : gameRules) {
            if (isMultiple(position, gameRule.getNumber())) {
                return gameRule.getTerm();
            }
        }
        return position.toString();
    }
}
複製程式碼

此時我遇到了兩個問題,一個是第四個子任務的描述缺了 FizzWhizz 這種可能,所以我先完善了任務清單;第二個是我又從程式碼中聞到熟悉的壞味道,因此在自動化測試的支撐下,我開始建立起自信,並解決了 if else 過於冗長的問題:

public static String countOff(Integer position, List<GameRule> gameRules) {
    String terms = gameRules
            .stream()
            .filter(rule -> isMultiple(position, rule.getNumber()))
            .map(rule -> rule.getTerm())
            .reduce((t1, t2) -> t1 + t2)
            .orElse(null);
    if (terms != null) {
        return terms;
    }

    for (GameRule gameRule : gameRules) {
        if (isMultiple(position, gameRule.getNumber())) {
            return gameRule.getTerm();
        }
    }
    return position.toString();
}
複製程式碼

此時自動化測試全部通過,然後分析發現,下面的 for 迴圈已經變成冗餘程式碼,因為它已經被合併到新寫入的程式碼中,現在可以刪除掉它了:

public static String countOff(Integer position, List<GameRule> gameRules) {
    String term = gameRules
            .stream()
            .filter(rule -> isMultiple(position, rule.getNumber()))
            .map(rule -> rule.getTerm())
            .reduce((t1, t2) -> t1 + t2)
            .orElse(position.toString());
    return term;
}
複製程式碼

自動化測試全部通過,這裡我引入 java 8 的特性 lambel 和函式式介面,函數語言程式設計在程式碼實現層面增強了程式碼的語義,也使得程式碼更加精練,如今總算得到一份滿意的程式碼,可以開始“學生報數”的最後一個子任務。


  • 學生報數。
    1. 如果是第一個特殊數字的倍數,就報 Fizz。
    2. 如果是第二個特殊數字的倍數,就報 Buzz。
    3. 如果是第三個特殊數字的倍數,就報 Whizz (當前任務)
    4. 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、FizzWhizz、BuzzWhizz、FizzBuzzWhizz。
    5. 如果包含第一個特殊數字,只報 Fizz (忽略規則 1、2、3、4)
    6. 如果不是特殊數字的倍數,並且不包含第一個特殊數字,就報對應的序號。

看著心裡樂,最後一個子任務預計 2 分鐘搞定,然後就可以把“學生報數”這個核心任務劃掉。於是乎我很快的編寫了對應的單元測試,並驅動出對應的具體實現:

public class StudentTest {
    private final List<GameRule> gameRules = Lists.list(
            new GameRule(3, "Fizz"),
            new GameRule(5, "Buzz"),
            new GameRule(7, "Whizz")
    );
    ...
    @Test
    public void should_return_fizz_when_included_the_first_number() {
        assertThat(Student.countOff(3, gameRules)).isEqualTo("Fizz");
        assertThat(Student.countOff(13, gameRules)).isEqualTo("Fizz");
        assertThat(Student.countOff(30, gameRules)).isEqualTo("Fizz");
        assertThat(Student.countOff(31, gameRules)).isEqualTo("Fizz");
    }
}

public class Student {

    public static String countOff(final Integer position, final List<GameRule> gameRules) {
        if (position.toString().contains(gameRules.get(0).getNumber().toString())) {
            return gameRules.get(0).getTerm();
        }
        String term = gameRules
                .stream()
                .filter(rule -> isMultiple(position, rule.getNumber()))
                .map(rule -> rule.getTerm())
                .reduce((t1, t2) -> t1 + t2)
                .orElse(position.toString());
        return term;
    }

    private static boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }
}
複製程式碼

執行自動化單元測試:

TDD 實踐-FizzFuzzWhizz(二)

新增的單元測試通過,但是卻出現其它三個單元測試執行失敗,出現這種情況我下意識覺得是新加入的程式碼有 BUG,因為是在我加入實現程式碼之後才出現測試失敗的情況。經過分析,發現原來是最後一個子任務優先順序最高,而剛好那些失敗的單元測試的部分測試樣本資料受到當前子任務的條件約束,解決起來很簡單,刪除對應的測試程式碼就好,現在所有單元測試執行通過,並且完成“學生報數”任務。

知識:是什麼讓開發人員變得更有勇氣去重構程式碼?

這得益於 TDD 的核心思想——不可執行/可執行/重構。這樣的機制可以保證擁有足夠多的單元測試以便於支撐實施程式碼重構,在細微持續的反饋中可以非常自信的做到小步快跑,因為我們可以非常放心的把“後背”交給自動化 BUG 偵察機。

討論:新加入的程式碼是否需要再優化?

可能有人覺得新加入的程式碼 if(...) 有點冗長,表達的含義也不是特別清晰,其實我也有很強烈的程式碼潔癖症(處女座一枚),不過現在的節奏我是認為很好了,如果還需要優化,我認為只需補充加上適當的註釋表明程式碼的意圖。您覺得呢?期待您的建議。

反思:到目前為止,程式是否存在更加優秀的設計?

我認為是的,不過目前看起來還不錯,具體等到引入遊戲上下文和實現其它任務時再綜合思考這個問題。

TDD 成果

任務清單:

  1. 發起遊戲。
  2. 定義遊戲規則。
  3. 說出 3 個不重複的個位數數字。
  4. !!! 學生報數。
    • 如果是第一個特殊數字的倍數,就報 Fizz。
    • 如果是第二個特殊數字的倍數,就報 Buzz。
    • 如果是第三個特殊數字的倍數,就報 Whizz。
    • 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    • 如果包含第一個特殊數字,只報 Fizz (忽略規則 1、2、3、4)
    • 如果不是特殊數字的倍數,並且不包含第一個特殊數字,就報對應的序號。
  5. 驗證入參。

測試報告:

TDD 實踐-FizzFuzzWhizz(二)

測試覆蓋率:

TDD 實踐-FizzFuzzWhizz(二)

截止到目前一共編寫了 9 個單元測試並驅動出“學生報數”功能,測試覆蓋率幾乎到達 100%(除了 Student 建構函式沒有被覆蓋),完成了案例中最核心的功能。在這個過程通過實踐不斷加深對 TDD 整體流程的理解,慢慢熟悉如何識別程式碼中的壞味道,同時也掌握一些重構手法,有趣的是我之前一直以為分析技術只會在需求分析和任務分解這兩個階段才會用到,現在看來在程式設計的過程中經常會使用到分析技術,收穫還不錯,可別忘了還有一點,在這個過程中自己變得越來越自信,越來越有勇氣去寫更好的程式碼。

閱讀系列文章

原始碼

github.com/lynings/tdd…


歡迎關注我的微信訂閱號,我將會持續輸出更多技術文章,希望我們可以互相學習。

TDD 實踐-FizzFuzzWhizz(二)

相關文章