TDD 實踐-FizzFuzzWhizz(二)

標籤 | TDD Java
字數 | 2728 字
java

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

目標收益

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

任務回顧

  • 學生報數。
    1. 若是是第一個特殊數字的倍數,就報 Fizz。
    2. 若是是第二個特殊數字的倍數,就報 Buzz。
    3. 若是是第三個特殊數字的倍數,就報 Whizz (當前任務)
    4. 若是同時是多個特殊數字的倍數,須要按特殊數字的順序把對應的單詞拼接起來再報出,好比 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    5. 若是包含第一個特殊數字,只報 Fizz (忽略規則 一、二、三、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();
    }
}
複製代碼

測試驅動開發

若是有任何重複的邏輯或沒法解釋的代碼,重構能夠消除重複並提升表達能力(減小耦合,增長內聚力)。github

上一篇文章的內容,此時咱們須要解決代碼中的壞味道——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;
    }
}
複製代碼

運行自動化測試所有經過,不過取值的方式仍是有點笨,而後我把上面那種取值方式改爲經過循環自動取值以下降錯誤率,此時代碼變得更加簡潔,表達的意圖也更加清晰:bash

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;
}
複製代碼

再次運行測試驗證重構是否引入新的錯誤。若是沒有經過,極可能是在重構時犯了一些錯誤,須要當即修復並從新運行,直到全部測試經過。 微信

通過自動化測試的檢驗,測試所有經過,此時能夠放心開始下一個子任務。


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

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

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 (忽略規則 一、二、三、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;
    }
}
複製代碼

運行自動化單元測試:

新增的單元測試經過,可是卻出現其它三個單元測試執行失敗,出現這種狀況我下意識以爲是新加入的代碼有 BUG,由於是在我加入實現代碼以後纔出現測試失敗的狀況。通過分析,發現原來是最後一個子任務優先級最高,而恰好那些失敗的單元測試的部分測試樣本數據受到當前子任務的條件約束,解決起來很簡單,刪除對應的測試代碼就好,如今全部單元測試運行經過,而且完成「學生報數」任務。

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

這得益於 TDD 的核心思想——不可運行/可運行/重構。這樣的機制能夠保證擁有足夠多的單元測試以便於支撐實施代碼重構,在細微持續的反饋中能夠很是自信的作到小步快跑,由於咱們能夠很是放心的把「後背」交給自動化 BUG 偵察機。

討論:新加入的代碼是否須要再優化?

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

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

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

TDD 成果

任務清單:

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

測試報告:

測試覆蓋率:

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

閱讀系列文章

源碼

github.com/lynings/tdd…


歡迎關注個人微信訂閱號,我將會持續輸出更多技術文章,但願咱們能夠互相學習。

相關文章
相關標籤/搜索