TDD 實踐-FizzFuzzWhizz(三)

標籤 | TDD Java java

字數 | 4742 字git

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

在以前的實踐文章中着重掌握 TDD 的口號和總體流程,用 9 個 UT 驅動出核心任務的實現代碼,即完成了核心任務,也獲得了將近 100% 的測試覆蓋率,而且在測試的支撐下對程序進行小範圍重構,從目前看來採用 TDD 的效果仍是不錯的。不過上一篇文章留下了一個反思一直困擾着我,並非由於這個問題有多難解決,而是之後再面對這種類型的問題時,我能夠運用何種思路去簡化並解決這種類型的問題,這是寫這篇文章最主要的動機,而 TDD 能夠幫助到我。編程

問題回顧

到目前爲止,程序是否存在更加優秀的設計? 這是上一篇文章結尾留下的一個反思,提這個問題的同時也引起了本身對程序設計的反思,到底應該經過什麼方法來下降錯誤設計和編程浪費(重寫和太多的重構)呢?安全

解題思路

目標: 獲得簡潔可用的代碼和合理的程序設計。bash

準備: 簡化案例爲接下來的活動作好準備。微信

活動:編程語言

  1. 經過面向對象分析提煉領域模型或分析模型。
  2. 在領域模型的指導下實施面向對象設計獲得設計模型。
  3. 可視化領域模型和設計模型,以便於理解和分析。
  4. 任務分解。
  5. TDD。

面向對象分析

OOA 強調的是在問題領域內發現和描述對象(或概念), 關注重要概念類、屬性和關鍵關係,強調調查研究,而非解決方案, 最終提煉出領域模型。獲得領域模型是我第一階段的目標。這裏還要強調的是,建模的目的是爲了理解和溝通,以便於確認模型的合理性,因此建模是一項重要但不該該花太多時間的工做,既能夠在白板上草圖繪製,也可使用 UML 元素。ide

建立領域模型的步驟能夠分爲四步:工具

  1. 確認需求範圍。
  2. 尋找重要概念類。
  3. 可視化(草圖或 UML)。
  4. 添加關聯關係和屬性。

經分析,因爲案例難度通常,因此當前需求範圍涉及整個案例(或者當前迭代所涉及的需求),其中第三點須要學習相關的元素或者畫草圖便可,難度相對簡單,而第二點和第四點是整個建模過程當中的關鍵步驟,直接影響到整個領域模型,因此把主要精力放在這兩個地方。

如何尋找概念類:

  1. 在已有的模型上調整和修改(效率較高,推薦)。
  2. 使用分類列表。
  3. 肯定名詞短語(較爲簡單,須要注意天然語言的二義性)。

這裏我使用"分類列表"策略來尋找重要概念類,在分析案例後我獲得如下分類列表:

概念類類別 重要等級 示例
遊戲 重要 Game
遊戲規則 關鍵 Rule
參與者 重要 Teacher、Student
地點 可有可無 Classroom

經分析,該案例中的核心在於遊戲規則,也是整個案例中的實現難點,Classroom 在當前需求中缺少業務含義,能夠剔除,而後我使用 UML 工具快速繪製初步的領域模型:

圖1

而後給模型加上關聯關係和屬性:

圖2

這個模型主要是站在參與者 Student 的角度進行分析和建模,也是我一開始的理解,可是通過分析,我發現這個模型有點混亂,Student 可能同時跟 TeacherGame 存在耦合關係, startplay也存在歧義,因此我再次站在參與者的角度對模型進行調整:

圖3

我把 Teacher 從領域模型中去掉,而且將 Student 概念抽象爲 Player,此時整個領域模型變得簡潔多了,不過在進一步分析領域模型發現模型中的 PlayerGame 之間的關係很是奇怪,由 100 個玩家去玩一個遊戲,致使遊戲裏面包含了 100 個玩家對象,並且每一個玩家的惟一標識僅僅是經過序號來區分,那麼 Player 究竟是概念類仍是屬性呢?這二者要如何區分呢?

如何分辨概念類和屬性?

若是某個概念類不是現實世界中的數字和文本,那麼該概念類極可能是概念類而不是屬性,反之同理。

這是在《UML與模式應用》這本書的第九章中提到的準則,也正是這句話給了我靈感,經過分析,在這個案例中並無明確區分玩家是張三仍是李四,所以「100個玩家」應該做爲遊戲的一個屬性更加合適不過,因此我從新調整了模型:

圖4

此時的領域模型獲得了簡化,領域聚焦也變得更加合理。那 Rule 的設計合理嗎?經過分析案例發現 GameRule 的關係存在問題,GameRule 並非 1 : 3 的關係,而是 1 : 1 的關係,由於特殊數字只是遊戲規則的一部分,這個問題在程序設計階段能夠很是明顯的反映出來,此時模型調整爲:

圖5

我把一些須要重點關注的信息標上了紅色,以便於聚焦本身的關注點。 雖然在識別概念類和建模的過程當中遇到了一些小問題,可是最終仍是獲得一個合理的領域模型。領域模型是對概念內的概念類或現實世界中對象的可視化表達,它去掉了問題域中的大部分細枝末節,只保留重要的領域概念,這對於分析問題和指導程序設計提供了很是大的幫助,不過有時候領域模型看不出來的問題在程序設計階段可能會反映出來,因此能夠先採用領域模型指導程序設計,而後反過來經過程序設計的反饋來分析領域模型的合理性

思考:如何判斷領域模型是否正確?

不一樣的分析角度獲得的領域模型可能存在異同,因此並無所謂正確的領域模型,模型只是近似地在嘗試描述一個實際領域,它能夠有效地捕獲理解當前問題域所須要的重要信息,幫助人們理解當前問題域中的概念、術語和關係,以便於提升開發人員和業務人員之間的理解和溝通效率。

思考:應該花多少時間去創建領域模型?

假設在爲期 3 周的迭代中,建模時間最好不要超過一天,由於有不少因素阻礙咱們創建一個"完美"的模型,例如業務需求變動、部分重要概念未被髮掘等等狀況,因此應該把主要精力花在覈心問題域的分析和建模,而後經過程序設計階段反過來去驗證模型的合理性,在 TDD 的過程當中經過重構來發現隱式概念並提煉程序設計,所以在實踐過程當中運用好 OOA 、 OOD 和 TDD 能夠達到相輔相成的做用。

面向對象設計

OOD 關注軟件對象的職責和協做以實現軟件需求,強調獲得知足當前需求的概念上的解決方案(能夠被實現,但不是具體實現,設計思想不關注具體實現),而實現則表達了真實且完整的設計,一般會使用 UML 交互圖和類圖進行可視化

動態建模和靜態建模的區別

動態模型有助於設計邏輯和設計代碼行爲,一般會使用以下工具進行動態建模:

  • UML 交互圖
  • UML 順序圖
  • UML 活動圖
  • UML 狀態機圖
  • ...

靜態建模有助於設計包、類名、屬性和方法,一般會使用以下工具進行靜態建模:

  • UML 類圖
  • ...

其中最有價值、最具挑戰性、最有益和有效的設計工做基本上都發生在動態建模的過程當中,在動態建模的過程當中能夠明確知道有哪些對象,對象之間如何進行交互,因此應該在這方面花費更多的精力

思考:領域模型和設計模型(UML交互圖、類圖)之間是什麼關係?

設計模型並非完徹底全臨摹領域模型,由於這兩個階段的目標徹底不一樣, 領域模型描述的是真實世界領域內的概念類,設計模型描述的是軟件類(能夠被某種編程語言實現),所以設計階段須要結合編程語言、編程思想和設計原則,而領域模型在這裏能夠給予設計師靈感並指導程序設計。

在明確設計建模的輸入(領域模型)和輸出(設計模型)以後,如今須要明確輸入到輸出的中間過程。在進行動態建模的過程當中,我主要採用了職責驅動設計和 GRASP來幫助我實施這一過程(一般可能還會有更多的活動,好比開討論會等)。

知識:職責驅動設計

職責驅動設計把軟件對象想象成具備某種職責的人,這我的要與其餘人協做以完成工做,這個過程當中會考慮職責、角色和協做三大要素。

知識:GRASP

GRASP 是 GRAS Pattern 的縮寫,是一系列模式的統稱,它能夠以一種系統的、合理的、能夠被解釋的方式來運用職責驅動設計,並進行設計推理,詳細描述請閱讀《UML與模式應用》。

構建 UML 交互圖

UML 交互圖用於動態對象建模,它能夠幫助描述對象之間的交互。

在通過上面一番對 OOD 的理解以後,這裏我使用 UML 交互圖中的順序圖(UML交互圖還包括通訊圖等)來描述對象之間的交互,在領域模型的指導下我獲得一個初步的 UML 交互圖:

圖6

這個圖體現了 GameRule 的職責和協做,Game 承擔玩遊戲 play(specialNumbers) 職責,Rule 承擔核心的規則匹配 match(number) 職責,GameRule 的協做體如今對象的建立和方法的調用。

到這裏 UML 交互圖總算大功告成,接下來進入設計類圖階段。

構建 UML 類圖

類圖用於靜態對象建模,能夠用來描述類、接口、屬性及其關聯關係。

在領域模型和 UML 交互圖的指導下,類圖已經變得很是簡單,由於大部分信息都已經在 UML 交互圖體現出來,因此我根據 UML 交互圖快速設計瞭如下類圖:

圖7

到這裏 UML 類圖構建完畢,在進行任務分解後就能夠開始 TDD。

任務分解

OOD 已經讓我獲得了實現需求的解決方案,有趣的是設計模型很大程度上已經幫我完成了任務分解的過程,因此我根據設計模型修改了一開始的任務清單。

舊的任務清單:

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

修改後的任務清單:

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

測試驅動開發

Kent Beck:「測試驅動開發不是一種測試技術。它是一種分析技術、設計技術,更是一種組織全部開發活動的技術」。

有效的分析和設計能夠歸納爲:作正確的事(分析)和正確地作事(設計),這應該就是 TDD 中提到的分析技術和設計技術吧。

在以前的 TDD 實踐文章中我驅動出了 Student 併爲其分配了報數 countOff() 職責,可是這跟目前的設計模型徹底不同,好在 TDD 讓程序留下了單元測試,能夠在自動化測試的支撐下進行安全的重寫,因此我運用瞭如下策略幫助我完成重寫任務:

  1. 在不修改原來的代碼的前提下逐漸進行小範圍重寫和替換
    1. Student 替換成 Rule,並使自動化測試經過。
    2. 引入並初始化 List<Rule.Item> items 成員變量,用以保存特殊數字和單詞的映射關係。
    3. 建立 match(number) 方法。
    4. 逐漸使全部測試經過的方式驅動 match(number) 方法的實現。
    5. 在保證新引入的代碼所有經過測試以後刪除舊代碼。
  2. 運行自動化測試以保證沒有引入新的錯誤。
  3. 若是引入錯誤則立刻修改使測試經過。
  4. 回到第一步,直到完成重寫任務。
  5. 保證重構任務完成和全部測試經過的狀況下,刪除多餘的代碼。

遵循上面的重寫策略最終我獲得瞭如下代碼:

public class RuleTest {

    final Rule rule = new Rule(Arrays.asList(3, 5, 7));

    @Test
    public void should_return_1_when_mismatch_any_number() {
        assertThat(rule.match(1)).isEqualTo("1");
    }

    @Test
    public void should_return_fizz_when_just_a_multiple_of_the_first_number() {
        assertThat(rule.match(3)).isEqualTo("Fizz");
        assertThat(rule.match(6)).isEqualTo("Fizz");
    }

    @Test
    public void should_return_buzz_when_just_a_multiple_of_the_second_number() {
        assertThat(rule.match(5)).isEqualTo("Buzz");
        assertThat(rule.match(10)).isEqualTo("Buzz");
    }

    @Test
    public void should_return_whizz_when_just_a_multiple_of_the_third_number() {
        assertThat(rule.match(7)).isEqualTo("Whizz");
        assertThat(rule.match(14)).isEqualTo("Whizz");
    }

    @Test
    public void should_return_fizzbuzz_when_just_a_multiple_of_the_first_number_and_second_number() {
        assertThat(rule.match(15)).isEqualTo("FizzBuzz");
        assertThat(rule.match(45)).isEqualTo("FizzBuzz");
    }

    @Test
    public void should_return_fizzwhizz_when_just_a_multiple_of_the_first_number_and_third_number() {
        assertThat(rule.match(21)).isEqualTo("FizzWhizz");
        assertThat(rule.match(42)).isEqualTo("FizzWhizz");
    }

    @Test
    public void should_return_buzzwhizz_when_just_a_multiple_of_the_second_number_and_third_number() {
        assertThat(rule.match(70)).isEqualTo("BuzzWhizz");
    }

    @Test
    public void should_return_fizzbuzzwhizz_when_at_the_same_time_is_a_multiple_of_the_three_number() {
        Rule rule = new Rule(Arrays.asList(2, 3, 4));
        assertThat(rule.match(48)).isEqualTo("FizzBuzzWhizz");
        assertThat(rule.match(96)).isEqualTo("FizzBuzzWhizz");
    }

    @Test
    public void should_return_fizz_when_included_the_first_number() {
        assertThat(rule.match(3)).isEqualTo("Fizz");
        assertThat(rule.match(13)).isEqualTo("Fizz");
        assertThat(rule.match(30)).isEqualTo("Fizz");
        assertThat(rule.match(31)).isEqualTo("Fizz");
    }
}

public class Rule {

    private List<Item> items;

    public Rule(final List<Integer> specialNumbers) {
        this.items = new ArrayList<>(3);
        this.items.add(new Item(specialNumbers.get(0), "Fizz"));
        this.items.add(new Item(specialNumbers.get(1), "Buzz"));
        this.items.add(new Item(specialNumbers.get(2), "Whizz"));
    }

    public String match(Integer number) {
        if (number.toString().contains(items.get(0).getNumber().toString())) {
            return items.get(0).getWord();
        }
        return items
                .stream()
                .filter(item -> isMultiple(number, item.getNumber()))
                .map(item -> item.getWord())
                .reduce((w1, w2) -> w1 + w2)
                .orElse(number.toString());
    }

    private boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }


    private class Item {
        private Integer number;
        private String word;

        public Item(Integer number, String word) {
            this.number = number;
            this.word = word;
        }

        public Integer getNumber() {
            return number;
        }

        public String getWord() {
            return word;
        }
    }
}
複製代碼

整個過程對原始的代碼改動稍微有點大,不過在自動化測試的支撐下仍是很是順利地完成了重寫任務,接下來能夠進入重構環節識別代碼中的"壞味道",

if (number.toString().contains(items.get(0).getNumber().toString())) {
    return items.get(0).getWord();
}
複製代碼

經過分析發現上面的判斷條件表達的意圖不太清晰,所以我經過重構手法 Extract Method 來提升代碼的表達能力:

public class Rule {
    ...
    public String match(Integer number) {
        if (isContainFirstSpecialNumber(number)) {
            return items.get(0).getWord();
        }
        return items
                .stream()
                .filter(item -> isMultiple(number, item.getNumber()))
                .map(item -> item.getWord())
                .reduce((w1, w2) -> w1 + w2)
                .orElse(number.toString());
    }

    private boolean isMultiple(Integer divisor, Integer dividend) {
        return divisor % dividend == 0;
    }

    private boolean isContainFirstSpecialNumber(Integer number) {
        if (number.toString().contains(items.get(0).getNumber().toString())) {
            return true;
        }
        return false;
    }
    
    ...
}
複製代碼

執行單元測試保證全部的測試經過

TDD 成果

任務清單:

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

測試報告:

測試覆蓋率:

總結

壞消息是一開始因缺少分析和設計留下來的坑早晚要填,好消息是經過上面的分析,這種問題是能夠獲得很大程度上的控制,先經過對問題進行分析和建模,再經過領域模型指導程序設計能夠有效的下降錯誤設計的機率,在解決複雜問題域的時候效果更加明顯,不過須要注意的是 TDD 主張簡單設計,在保證代碼可用的前提下追求代碼簡潔,在重構中消除代碼壞味道,並對原有的設計模型進行微觀層面的演化和提煉,這種方式能夠避免不一樣程度的浪費(設計浪費、沒必要要的重寫、頻繁重構和糾結等)

閱讀系列文章

源碼

github.com/lynings/tdd…


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

相關文章
相關標籤/搜索