TDD 實踐-FizzFuzzWhizz(一)

lyning發表於2019-02-25

測試驅動開發(TDD)總結——原理篇一文中已經對 TDD 做了概念性總結。而個人覺得理論知識的缺點在於它只強調外部刺激而缺乏學習者的內部心理過程,比如很難基於已有的經驗對理論性知識建立對映關係,因此客觀的實踐才是檢驗真理的唯一標準。奔著這個目標,這些天花了一些時間去選擇案例,因為好的故事或者好的案例既能讓自己更有代入感,也能發揮 TDD 的魅力。

前言

文章包括了案例,任務分解,報數部分的介面設計,單元測試命名規則和 TDD 案例實踐,關於文章的原始碼已經放到我的個人 Github,希望接下來的 TDD 實踐系列文章對讀者也有一些收穫。

範圍

TDD (Test Driven Development) 在不同的圈子、不同的角色的認知中可能會有不同的理解,有人可能會理解成 ATDD(Acceptance Test Driven Development),也有人可能會理解成 UTDD(Unit Test Driven Development),為了避免產生歧義,文章涉及到 TDD 專指 UTDD(Unit Test Driven Development),即 「單元測試驅動開發」。

準備

  1. 理解 OOP。
  2. 瞭解 Java 8。
  3. 熟悉 Intellij IDEA。
  4. 熟悉 TDD 理論性知識,可以參考 測試驅動開發(TDD)總結——原理篇
  5. 瞭解 Google 輕量級依賴注入框架 Guice 。
  6. 熟悉測試工具 Junit 和 Mockito 的使用。
  7. 熟悉搭建自動化單元測試環境。

案例

TDD 實踐-FizzFuzzWhizz(一)

你是一名體育老師,在某次課距離下課還有五分鐘時,你決定搞一個遊戲。此時有 100 名學生在上課。遊戲的規則是:

  1. 你首先說出三個不同的特殊數,要求必須是個位數,比如 3、5、7。
  2. 讓所有學生排成一隊,然後按順序報數。
  3. 學生報數時,如果所報數字是第一個特殊數(3)的倍數,那麼不能說該數字,而要說 Fizz;如果所報數字是第二個特殊數(5)的倍數,那麼要說 Buzz;如果所報數字是第三個特殊數(7)的倍數,那麼要說 Whizz。
  4. 學生報數時,如果所報數字同時是兩個特殊數的倍數情況下,也要特殊處理,比如第一個特殊數和第二個特殊數的倍數,那麼不能說該數字,而是要說 FizzBuzz, 以此類推。如果同時是三個特殊數的倍數,那麼要說 FizzBuzzWhizz。
  5. 學生報數時,如果所報數字包含了第一個特殊數,那麼也不能說該數字,而是要說相應的單詞,比如本例中第一個特殊數是 3,那麼要報 13 的同學應該說 Fizz。如果數字中包含了第一個特殊數,那麼忽略規則 3 和規則 4,比如要報 35 的同學只報 Fizz,不報 BuzzWhizz。

現在,我們需要你完成一個程式來模擬這個遊戲,它首先接受 3 個特殊數,然後輸出 100 名學生應該報數的數或單詞。比如:

輸入:

3,5,7

輸出(片段):

1,2,Fizz,4,Buzz,Fizz,Whizz,8,Fizz,Buzz,11,Fizz,Fizz,Whizz,FizzBuzz,16,17,Fizz,19,Buzz,…,100

任務分解

在 TDD 之前進行需求分析可以在一開始就明確完成任務的目標是什麼,以便於減少理解偏差所帶來的低階錯誤;緊接著對需求進行任務分解,目的是得到一份可以被驗證的任務清單。在實踐 TDD 的過程可能還會調整任務清單,比如新增新的任務,或者刪除掉冗餘的任務等等。

在對需求進行分析的過程中,我會先從參與者的角度分析整個案例涉及到的角色有哪些,發現有兩種角色參與到遊戲中,分別是老師和學生;然後再從職責的角度分析得出老師的職責是發起遊戲、定義遊戲規則和說出 3 個不重複的個位數數字;學生的職責是參與遊戲並根據遊戲規則報數。最終我初步得出以下任務清單:

  1. 發起遊戲。
  2. 定義遊戲規則。
  3. 說出 3 個不重複的個位數數字。
  4. 學生報數。
  5. 驗證入參。

任務細分

我發現“學生報數”任務受到一系列遊戲規則的影響,因此我會對該任務進行細化,並且尋找該任務的特殊需求,以便於對任務做一定程度的規劃。在分析的過程中,對於比較特殊或者比較重要的規則我會做好標註,避免遺忘。下面是我細分後的任務清單:

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

任務規劃

有時候分解出來的任務沒有體現出優先順序和依賴關係,為了提高工作效率,需要對任務進行規劃,讓事情變得更加有條理,避免在一堆任務中迷失方向。

由於案例難度一般,所以這一步能發揮的空間不大,不過在這一步可以思考應該從哪個任務開始,選擇的標準可以參照這三個:

  • 任務的重要程度
  • 任務的依賴關係
  • 任務的難度

通過判斷任務是否是主要流程來判斷任務的重要程度,比如“驗證入參”的重要程式相對“學生報數”較低,可以晚點做。

通過分析任務的依賴關係來識別任務的先後順序,具體優先順序因人而異,有人喜歡採用自頂向下,有人喜歡採用自底向上。好在 Mock 可以幫助開發人員隔離依賴,還可以通過 Mock 的方式驅動出類和介面而不依賴於具體實現,避免陷入尋找任務前後順序的煩惱中。

分析任務的難度需要通過需求分析和經驗得出,通過分析上面的案例可以知道難點在於學生報數的演算法上。對於我個人來說,除非是核心任務,否則我不會一開始就選擇難度大的非核心任務作為開始任務。

敲定任務

通過分析,剛好難度較大的任務“學生報數”是整個遊戲的核心功能,最終我選擇先做這個任務。

測試命名規範

  1. 測試類以 XXXTest 命名.
  2. 測試方法命名必須採用should_xxx_when_xxx,例如:should_return_false_when_1_is_greater_than_2
  3. 測試方法的程式碼邏輯遵循 Given-When-Then 模式。

知識:Given-When-Then

在編寫測試方法時,應該遵循 Given-When-Then 模式(在給定xx情況下,當做了xx操作,會得到xx反饋)這種模式可以讓開發人員專注並思考以下這幾件事情:

  • Given:驅動我們思考這個測試是在一個怎樣的上下文中,用到哪些物件,以便於思考需要建立哪些上下文和物件。
  • When:驅動我們站在使用者的角度去思考這個行為是什麼,它有哪些輸入,以便於思考方法的命名和入參。
  • Then:驅動我們思考行為的反饋是什麼,以便於思考方法的返回值。

思考:測試方法採用 should_xxx_when_xxx 的意義是什麼?

得益於 BDD 思想和工具,這種命名方法是我在 BDD 的實踐過程中琢磨出來的(當然不止我在用這種命名規則),它包含但不僅限於以下優點:

  • 可以在把關注點放到行為上,避免陷入實現的細節中。
  • 命名接近自然語言,表達意圖清晰,可讀性高,受益人群廣。
  • 很好地控制測試的範圍,大到使用者行為(偏 BDD),小到邏輯分支(偏 TDD)。

到現在需求已經明確,測試命名規範已擬定,任務已敲定,可以開始 TDD 了。

常見錯誤

早期的開發習慣(編碼-執行-觀察)會導致開發人員過早陷入實現細節,這種開發習慣的缺陷之一在於反饋週期長,不利於小步快跑的節奏,所以在實踐 TDD 的過程中需要時刻提醒自己 TDD 的口號和規則,培養自己養成新的思維習慣。

測試驅動開發

TDD 實踐-FizzFuzzWhizz(一)

敲定任務

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

根據 TDD 的整體流程,此時需要想一下我要做什麼,想想如何測試它,然後寫一個小測試。思考所需的類、介面、輸入和輸出。

根據之前的需求分析,學生需要明確自己對應的序號和遊戲規則才能進行報數,因此驅動出 Student 類和 String countOff(position, gameRules) 方法,觀察 countOff 方法發現需要用到遊戲規則,所以還驅動出 GameRule 類。


編寫足夠的程式碼使測試失敗(明確失敗總比模模糊糊的感覺要好)。

@Test
public void should_return_fizz_when_just_a_multiple_of_the_first_number() {
    List<GameRule> gameRules = new ArrayList<>();
    assertThat(Student.countOff(3, gameRules)).isEqualTo("Fizz");
}
複製程式碼

這段程式碼執行的時候編譯不通過,是因為缺少了必要的類和方法,所以我很快地補上了以下程式碼:

public class Student {
    public static String countOff(Integer position, List<GameRule> gameRules) {
        return "";
    }
}

public class GameRule {
}
複製程式碼

然後執行了單元測試得到以下錯誤訊息:

TDD 實踐-FizzFuzzWhizz(一)


編寫剛剛好使測試通過的程式碼(保證之前編寫的測試也需要通過)。

檢查完錯誤後,我發現加入了GameRule影響了我對程式碼明顯實現的判斷,所以此時我使用偽實現策略使測試儘快通過,以便於在持續細微的反饋中捕獲明顯實現,因此我很快鍵入以下虛擬碼:

public static String countOff(Integer position, List<GameRule> gameRules) {
    return "Fizz";
}
複製程式碼

謝天謝地測試通過了,非常快就得到了我想要的結果:

TDD 實踐-FizzFuzzWhizz(一)

我知道這段程式碼是有問題的,現在我在思考是繼續編寫GameRule使偽實現變成明顯實現?還是挑下一個任務做並把“編寫GameRule”記錄到任務清單等之後再去做呢?這個選擇的標準很簡單,就是判斷完成這個任務需要花多長時間,如果很快就能做完,那就繼續做,如果需要花上一段時間,那就記下來跳下一個任務。通過我的分析,只需要給 GameRule 增加兩個成員變數(數字和對應的術語)就可以達到我的目標, 然後我調整了對應的測試程式碼:

@Test
public void should_return_fizz_when_just_a_multiple_of_the_first_umbe() {
    List<GameRule> gameRules = Lists.list(
        new GameRule(3, "Fizz"),
        new GameRule(5, "Buzz"),
        new GameRule(7, "Whizz")
    );
    assertThat(Student.countOff(3, gameRules)).isEqualTo("Fizz");
}
複製程式碼

緊接著增加了以下程式碼:

public class GameRule {
    private Integer number;
    private String term;

    public GameRule(Integer number, String term) {
        this.number = number;
        this.term = term;
    }

    ...
}

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

然後執行測試:

TDD 實踐-FizzFuzzWhizz(一)
完美,很快就得到了測試通過的反饋。


因為目前測試和程式碼量很少,也沒有明顯的壞味道,所以暫時不需要重構,直接把當前子任務劃掉並挑下個子任務。由於文章邊幅有限,在重複多次 TDD 的整體流程後來到:

  • 學生報數。
    • 如果是第一個特殊數字的倍數,就報 Fizz。
    • 如果是第二個特殊數字的倍數,就報 Buzz。
    • 如果是第三個特殊數字的倍數,就報 Whizz (當前任務)
    • 如果同時是多個特殊數字的倍數,需要按特殊數字的順序把對應的單詞拼接起來再報出,比如 FizzBuzz、BuzzWhizz、FizzBuzzWhizz。
    • 如果包含第一個特殊數字,只報 Fizz (忽略規則 1、2、3、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_1_when_mismatch_any_number() {
        assertThat(Student.countOff(1, gameRules)).isEqualTo("1");
    }

    @Test
    public void should_return_fizz_when_just_a_multiple_of_the_first_number() {
        assertThat(Student.countOff(3, gameRules)).isEqualTo("Fizz");
        assertThat(Student.countOff(6, gameRules)).isEqualTo("Fizz");
    }

    @Test
    public void should_return_buzz_when_just_a_multiple_of_the_second_number() {
        assertThat(Student.countOff(5, gameRules)).isEqualTo("Buzz");
        assertThat(Student.countOff(10, gameRules)).isEqualTo("Buzz");
    }

    @Test
    public void should_return_whizz_when_just_a_multiple_of_the_third_number() {
        assertThat(Student.countOff(7, gameRules)).isEqualTo("Whizz");
        assertThat(Student.countOff(14, gameRules)).isEqualTo("Whizz");
    }
}


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.com/lynings/tdd…


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

TDD 實踐-FizzFuzzWhizz(一)

相關文章