常見的一種敏捷開發實踐就是 TDD。TDD 是一種編寫軟件的模式,它使用測試 幫助您了解需求階段的最後步驟。先寫測試,再編寫代碼,這樣可以鞏固您對代 碼所需執行的操作的理解。
大多數開發人員認為 TDD 帶來的主要好處是最終得到的綜合單元測試集。但 是,如果正確執行的話,TDD 可以改進代碼的整體設計,因為它將決策推遲到最 後責任時刻(last responsible moment)。由於您沒有預先做出任何設計決定, 因此它讓您隨時可以采用更好的設計選擇或者重構為更好的設計。本文將介紹一 個示例,用於演示根據單元測試的結果進行設計的強大之處。
TDD 工作流程
測試驅動開發 術語中的關鍵詞是驅動,表示測試將驅動開發流程。圖 1 顯示 了 TDD 工作流程:
圖 1. TDD 工作流程
圖 1 中的工作流程是:
編寫一個失敗的測試。
編寫代碼以使測試通過。
重復第 1 步和第 2 步。
在此過程中積極地重構。
當您無法再想到任何測試時,那麼就必須做決策了。
TDD 與先開發後測試的比較
測試驅動 開發強調首先進行測試。只有在編寫了測試(並失敗)後,您才可 以編寫測試中的代碼。許多開發人員使用稱為後測試開發(test-after development,TAD)的各種測試,您將首先編寫代碼,然後編寫單元測試。在這 種情況下,您仍然進行了測試,但是沒有涉及到 TDD 的緊急設計方面。您可以很 輕松地編寫一些非常惡劣的代碼,然後費勁腦筋地想辦法測試。通過先編寫代碼 ,您在代碼中嵌入了有關代碼如何工作的想法,然後測試這些代碼。TDD 要求您 反過來做:先編寫測試,並讓它來提示如何編寫可以讓測試通過的代碼。為了演 示這個重要區別,我將著手實現一個擴展示例。
完全數
要展示 TDD 的設計優點,我需要用到一個待解決的問題。在 Kent Beck 的 Test Driven Development 一書中,他使用貨幣作為示例 — 非常優秀的 TDD 例 子,但是有點過分簡單。真正的挑戰是找到這樣一個示例,該示例本身並沒有復 雜到讓您對問題束手無策,但是它的復雜度足以展示真正的價值。
為此,我選擇了完全數。對於不熟悉數學知識的人,此概念可追溯到 Euclid 之前(他完成了導出完全數的早期驗證之一)。完全數指其真因子相加等於數字 本身的數字。例如,6 是一個完全數,因為 6 的因子(不包括 6 本身)是 1、2 和 3,而 1 + 2 + 3 = 6。更規則的完全數定義是因子(不包括該數字本身)之 和等於該數字的數字。在我的示例中,計算結果是 1 + 2 + 3 +6 - 6 = 6。
這就是要處理的問題域:創建一個完全數查找程序。我將用兩種不同的方法實 現此解決方案。首先,我將打消想要執行 TDD 的念頭並且只是編寫解決方案,然 後為它編寫測試。然後,我將設計出 TDD 版本的解決方案,以便可以比較和對照 兩種方法。
對於本例,我用 Java 語言(版本 5 或更高版本,因為我將在測試中使用注 釋)、JUnit 4.x(最新版本)和來自 Google 代碼的 Hamcrest 匹配器實現一個 完全數查找程序。Hamcrest 匹配器將在標准的 JUnit 匹配器頂部提供一個人本 接口(humane interface)語法糖。例如,不必編寫 assertEquals(expected, actual),您可以編寫 assertEquals(actual, is(expected)),這段代碼讀起來 更像是一個句子。JUnit 4.x 附帶了 Hamcrest 匹配器(這些匹配器只是靜態導 入);如果仍然要使用 JUnit 3.x,您可以下載一個兼容版本。
後測試開發
清單 1 顯示了第一個版本的 PerfectNumberFinder:
清單 1. 後測試開發的 PerfectNumberFinder
public class PerfectNumberFinder1 {
public static boolean isPerfect(int number) {
// get factors
List<Integer> factors = new ArrayList<Integer>();
factors.add(1);
factors.add(number);
for (int i = 2; i < number; i++)
if (number % i == 0)
factors.add(i);
// sum factors
int sum = 0;
for (int n : factors)
sum += n;
// decide if it's perfect
return sum - number == number;
}
}
這並不是特別好的代碼,但是它完成了工作。首先把所有因子創建為一張動態 列表(ArrayList)。我把 1 和目標數字添加到列表中(我在遵守上面給出的公 式,並且所有因子列表都包括 1 和該數字本身)。然後,我迭代可能的因子直到 該數字本身,逐個檢查以查看它是不是一個因子。如果是,我將把它添加到列表 中。接下來,我將把所有因子加起來,並最終編寫上面所示的公式的 Java 版本 以確定是否為完全數。
現在,我需要一個後測試的單元測試以確定它是否可以工作。我至少需要兩個 測試:一個測試用於查看是否正確報告了完全數,另一個測試用於檢查我沒有得 到誤判斷(false positives)。單元測試位於清單 2 中:
清單 2. PerfectNumberFinder 的單元測試
public class PerfectNumberFinderTest {
private static Integer[] PERFECT_NUMS = {6, 28, 496, 8128, 33550336};
@Test public void test_perfection() {
for (int i : PERFECT_NUMS)
assertTrue(PerfectNumberFinder1.isPerfect(i));
}
@Test public void test_non_perfection() {
List<Integer>expected = new ArrayList<Integer>(
Arrays.asList(PERFECT_NUMS));
for (int i = 2; i < 100000; i++) {
if (expected.contains(i))
assertTrue(PerfectNumberFinder1.isPerfect(i));
else
assertFalse(PerfectNumberFinder1.isPerfect(i));
}
}
@Test public void test_perfection_for_2nd_version() {
for (int i : PERFECT_NUMS)
assertTrue(PerfectNumberFinder2.isPerfect(i));
}
@Test public void test_non_perfection_for_2nd_version() {
List<Integer> expected = new ArrayList<Integer>(Arrays.asList(PERFECT_NUMS));
for (int i = 2; i < 100000; i++) {
if (expected.contains(i))
assertTrue(PerfectNumberFinder2.isPerfect(i));
else
assertFalse(PerfectNumberFinder2.isPerfect(i));
}
assertTrue(PerfectNumberFinder2.isPerfect(PERFECT_NUMS [4]));
}
}
測試名稱中的 “_” 是怎麼回事?
在編寫單元測試時在方法名稱中使用下劃線是我的一個編程怪癖。當然,Java 標准中規定方法名稱可以是大小寫混合的。但是我一直保持測試方法名稱不同於 普通方法名稱。測試方法名稱應當指出正在測試的是什麼方法,因此這些名稱是 很長的描述性名稱,在測試失敗時,您就知道哪些方法出現了問題。但是,讀取 較長的大小寫混合名稱十分困難,尤其是在包含幾十個或幾百個測試的單元測試 運行程序中,因為大多數測試名稱都以相似值為開頭,並且只在快到末尾時才有 所不同。在我做過的所有項目中,我強烈建議使用下劃線(僅在測試名稱中)以 提高可讀性。
這段代碼正確地報告了完全數,但是由於反向測試的原因,代碼運行得非常慢 ,因為我需要檢查大量數字。單元測試會引發性能問題,這使得我重新審視代碼 以查看是否可以進行一些改進。目前,我把循環集中在數字本身以獲得因子。但 是我必須這樣做嗎?如果我可以成對獲得因子的話就不需要。所有因子都是成對 的(例如,如果目標數字為 28,當我找到因子 2 時,我也可以獲得 14)。如果 我可以成對獲得因子,那麼我只需要循環到該數字的平方根。為此,我改進了算 法並將代碼重構為清單 3:
清單 3. 算法的改進版本
public class PerfectNumberFinder2 {
public static boolean isPerfect(int number) {
// get factors
List<Integer> factors = new ArrayList<Integer>();
factors.add(1);
factors.add(number);
for (int i = 2; i <= sqrt(number); i++)
if (number % i == 0) {
factors.add(i);
}
// sum factors
int sum = 0;
for (int n : factors)
sum += n;
// decide if it's perfect
return sum - number == number;
}
}
這段代碼運行的時間十分合理,但是幾個測試斷言都失敗了。結果是當您成對 地獲得數字時,您在到達整數平方根時將意外地獲得兩次數字。例如,對於數字 16,平方根是 4,該數字將被意外地添加到列表中兩次。通過創建一個處理這種 情況的保護條件可以輕松地解決此問題,如清單 4 所示:
清單 4. 修正的改進算法
for (int i = 2; i <= sqrt (number); i++)
if (number % i == 0) {
factors.add(i);
if (number / i != i)
factors.add(number / i);
}
現在我有了後測試版本的完全數查找程序。它可以正常工作,但是一些設計問 題也顯現出來。首先,我使用了注釋來描繪代碼的各個部分。這永遠是代碼的一 部分:希望重構為自己的方法。我剛添加的新內容可能需要使用注釋說明保護條 件的用途,但是我現在不管這一點。最大的問題在於其長度。我的 Java 項目的 經驗表明,任何方法永遠不能超過 10 行代碼。如果方法行數超過這個數,它幾 乎肯定不止做一件事,而這是不應該的。此方法明顯地違背了這條經驗,因此我 將進行另外一種嘗試,這次使用 TDD。
通過 TDD 進行緊急設計
編寫 TDD 的信條是:“可以為其編寫測試的最簡單內容是什麼?” 在本例中 ,是否為 “是否是一個完全數?” 不 — 這個答案過於寬泛。我必須分解問題 並回想 “完全數” 的含義。我可以輕松地舉出查找完全數必需的幾個步驟:
我需要所求數字的因子。
我需要確定某個數字是不是因子。
我需要把因子加起來。
想一想最簡單的事情是什麼,此列表中的哪一條看上去最簡單?我認為是確定 數字是不是另一個數字的因子,因此這是我的第一個測試,如清單 5 所示:
清單 5. 測試 “數字是不是因子?”
public class Classifier1Test {
@Test public void is_1_a_factor_of_10() {
assertTrue(Classifier1.isFactor(1, 10));
}
}
這項簡單測試瑣碎得有些愚蠢,這就是我需要的。要編譯此測試,您必須有名 為 Classifier1 的類,並且它有 isFactor() 方法。因此我必須先創建類的骨架 結構,然後才可以得到表示測試結果不正確的紅條。編寫極度瑣碎的單元測試可 以先把結構准備就緒,然後才需要開始通過所有有意義的方法考慮問題域。我希 望一次只考慮一件事,而且這使得我可以處理骨架結構,而無需擔心正在解決的 問題的細微差別。一旦我可以編譯這段代碼並且得到表示測試失敗的紅條,我就 准備好編寫代碼,如清單 6 所示:
清單 6. 確定因子的方法
public class Classifier1 {
public static boolean isFactor(int factor, int number) {
return number % factor == 0;
}
}
好的,這段代碼很好而且很簡單,並且它可以完成工作。現在我可以轉到下一 項最簡單的任務:獲得數字的因子列表。測試顯示在清單 7 中:
清單 7. 下一個測試:數字的因子
@Test public void factors_for() {
int[] expected = new int[] {1};
assertThat(Classifier1.factorsFor(1), is(expected));
}
清單 7 顯示了我為獲得因子編寫的最簡單測試,因此現在我可以編寫使此測 試通過的最簡單代碼(並在以後將其重構以使其更復雜)。下一個方法顯示在清 單 8 中:
清單 8. 簡單的 factorsFor() 方法
public static int[] factorsFor(int number) {
return new int[] {number};
}
雖然這個方法可以工作,但是它使我完全停了下來。將 isFactor() 方法變成 靜態方法似乎是個好主意,因為它只不過根據其輸入返回一些內容。但是,現在 我也已經使 factorsFor() 方法成為了靜態方法,意味著我必須將名為 number 的參數傳遞給兩個方法。這段代碼將變得非常過程化,這是過分使用靜態的副作 用。為了解決此問題,我將重構已有的兩個方法,這很簡單,因為到目前為止我 只有很少的代碼。重構後的 Classifier 類顯示在清單 9 中:
清單 9. 改進後的 Classifier 類
public class Classifier2 {
private int _number;
public Classifier2(int number) {
_number = number;
}
public boolean isFactor(int factor) {
return _number % factor == 0;
}
}
我把數字變成是 Classifier2 類中的成員變量,這將允許我避免將其作為參 數傳遞給一大堆靜態方法。
我的分解列表中的下一件事表明我需要找到數字的因子。因此,我的下一個測 試應當檢查這一點(如清單 10 中所示):
清單 10. 下一個測試:數字的因子
@Test public void factors_for_6() {
int[] expected = new int[] {1, 2, 3, 6};
Classifier2 c = new Classifier2(6);
assertThat(c.getFactors(), is(expected));
}
現在,我將試著實現返回給定參數的因子數組的方法,如清單 11 中所示:
清單 11. getFactors() 方法的第一步
public int[] getFactors() {
List<Integer> factors = new ArrayList<Integer> ();
factors.add(1);
factors.add(_number);
for (int i = 2; i < _number; i++) {
if (isFactor(i))
factors.add(i);
}
int[] intListOfFactors = new int[factors.size()];
int i = 0;
for (Integer f : factors)
intListOfFactors[i++] = f.intValue();
return intListOfFactors;
}
這段代碼允許測試通過,但是再考慮一下,它十分糟糕!在使用測試研究實現 代碼的方法時有時會出現這種情況。這段代碼中哪些部分非常糟糕?首先,它非 常長而且復雜,並且它也有 “不止一件事” 的問題。我的本能指引我返回 int [],但是它給底部的代碼增加了很多復雜度而沒有給我帶來任何好處。開始過多 地考慮怎樣做才能使將來可能調用此方法的方法更方便,將令您遭遇危險的處境 。您需要一個非常有說服力的理由才能在此接合點添加復雜的內容,而我還沒有 那樣的理由。查看這段代碼,我發現 factors 也應當作為類的內部狀態而存在, 使我可以分解該方法的功能。
測試顯現的有益特性之一是真正內聚的方法。Kent Beck 在十分有影響力的 Smalltalk Best Practice Patterns 一書中提到了這一點。在該書中,Kent 定 義了一種名為組合方法(composed method)的模式。組合方法模式將定義三條主 要語句:
把程序劃分為多個可執行一項可識別任務的方法。
把方法中的所有操作保持在同一個抽象級別
這將自然而然地得到擁有許多小方法的程序,每個小方法都只有幾行代碼。
組合方法是 TDD 提倡的有益設計特性之一,而我已經在 清單 11 的 getFactors() 方法中明顯違反了這種模式。我可以通過執行以下步驟來修正:
將 factors 提升為內部狀態。
將 factors 的初始化代碼移到構造函數中。
去掉對 int[] 代碼的轉換,等到它變得有益時再處理它。
添加 addFactors() 的另一項測試。
第四步非常微妙但是很重要。編寫出這個有缺陷的代碼版本揭示出分解的第一 步並不完整。隱藏在這個長方法中間的 addFactors() 代碼行是可測試的行為。 它是如此地微不足道,以至於在第一次查看問題時我都沒有注意到它,但是現在 我看到了。這是經常出現的情況。一個測試可以指引您進一步將問題分解為越來 越小的塊,每個塊都是可以測試的。
我將暫停處理 getFactors() 的比較大的問題,而處理我新遇到的小問題。因 此,我的下一個測試是 addFactors(),如清單 12 中所示:
清單 12. 測試 addFactors()
@Test public void add_factors () {
Classifier3 c = new Classifier3(6);
c.addFactor(2);
c.addFactor(3);
assertThat(c.getFactors(), is(Arrays.asList(1, 2, 3, 6)));
}
清單 13 所示的測試中的代碼本身十分簡單:
清單 13. 添加因子的簡單代碼
public void addFactor(int factor) {
_factors.add(factor);
}
我運行我的單元測試,充滿信心地認為我會看到表示測試成功的綠條,但是卻 失敗了!這樣一個簡單的測試怎麼會失敗?根本原因顯示在圖 2 中:
圖 2. 測試失敗的根本原因
我期望看到的列表有 1, 2, 3, 6 幾個值,而實際返回的是 1, 6, 2, 3。那 是因為我將代碼改為在構造函數中添加 1 和數字本身。這個問題的一種解決方案 是,始終在假定應先添加 1 和該數字的情況下編寫期望的代碼。但是這是正確的 解決方案嗎?不是。問題更為基礎。因子是不是一個數字列表?不是,它們是一 個數字集合。我的第一個(錯誤)假定導致我使用一列整數作為因子,但是這是 個糟糕的抽象。通過將我的代碼重構為使用集合而非列表,我不但解決了這個問 題,而且優化了整個解決方案,因為我現在使用的是更精確的抽象。
如果在讓代碼影響您的判斷力之前編寫測試,這正是測試可以揭露的有缺陷的 思維方式。現在,由於這項簡單的測試,我編寫的代碼的整體設計更好了,因為 我已經發現了更合適的抽象。
結束語
到目前為止,我以處理完全數為背景討論了緊急設計。特別是,注意第一版的 解決方案(後測試版本)對數據類型做出了同樣有缺陷的假設。“後測試” 將測 試代碼的粗糙功能,而非各個部分。TDD 將測試構成粗糙功能的構建塊,在測試 過程中揭露更多信息。
在下一期文章中,我將繼續討論完全數問題,演示在執行測試時形成的各種設 計的更多示例。在我完成 TDD 版本時,我將比較一下兩個代碼庫。我還將解答其 他某些棘手的 TDD 設計問題,例如是否測試及何時測試私有方法。