測試驅動開發 (TDD) 是一種編寫整潔代碼的“規程”或“方法論”,而不僅僅是測試技術。
JaCoCo 在運行測試后生成詳細的覆蓋率報告的工具, maven 引用。
測試驅動開發
測試驅動開發(TDD)是什么?
TDD 不是說寫完代碼再寫測試,而是先寫測試,再寫代碼。它是一種開發流程,一個不斷循環的“節奏”:
- 紅燈 (Red): 寫一個針對某個新功能的自動化測試。運行這個測試,它應該失敗,因為你還沒寫對應的功能代碼。這個失敗告訴你,“我想要的功能還不存在”。
- 綠燈 (Green): 寫最少量的程序代碼,讓剛才失敗的測試通過。你的目標只是讓測試變綠,代碼可能寫得不好看、效率不高都沒關系。然后運行所有的測試(包括之前寫過的),確保沒有破壞已有的功能。
- 重構 (Refactor): 現在所有測試都通過了,功能是正確的。這時,你可以放心地改進和優化你的程序代碼和測試代碼,讓它們更整潔、更高效、結構更好。重構過程中,要持續運行所有測試,確保改進沒有引入新的 Bug。
這個 紅 -> 綠 -> 重構 的循環非常短,可能只需要幾分鐘到十幾分鐘。你不斷地重復這個循環,逐步完善你的功能。
TDD 的三定律
- 在你編寫一個失敗的測試之前,不能編寫任何生產代碼。
- 在一個失敗的測試中,你不能編寫多于恰好能夠暴露失敗的測試代碼。
- 在你編寫一個失敗的測試之外,你不能編寫多于恰好能夠使得當前失敗測試通過的生產代碼。
舉例說明:構建一個簡單的字符串計算器
假設我們要寫一個函數,能夠接收一個包含數字和逗號的字符串,并計算所有數字的和。例如 "1,2,3" 應該返回 6。
我們使用 TDD 的流程來開發這個功能。
我們要測試的函數:int add(String numbers)
步驟 1:紅燈 (Red) - 測試空字符串
- 需求: 空字符串應該返回 0。
寫測試:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;// 我們要測試的類,也就是生產類
class StringCalculator {// 我們還沒寫這個 add 方法// public int add(String numbers) { ... }
}class StringCalculatorTest {@Testvoid shouldReturnZeroForEmptyString() {StringCalculator calculator = new StringCalculator();// 斷言:調用 add("") 應該返回 0assertEquals(0, calculator.add(""));}
}
- 運行測試: 測試會編譯失敗(因為
StringCalculator
類可能不存在,或者add
方法不存在),或者運行時失敗(比如add
方法拋異常)。測試失敗了,紅燈!
步驟 2:綠燈 (Green) - 讓測試通過
- 目標: 讓
shouldReturnZeroForEmptyString
測試通過。
寫最少量代碼:
class StringCalculator {public int add(String numbers) {// 最簡單讓上面測試通過的代碼就是判斷是不是空字符串if (numbers == null || numbers.isEmpty()) {return 0;}// 現在還不能處理非空字符串,先隨便返回個東西或拋異常 (這里暫不處理非空)// 實際上,為了讓測試盡快通過,我們可能直接返回 0return 0; // !!! 讓 shouldReturnZeroForEmptyString 通過的最少量代碼 !!!}
}
- 運行測試:
shouldReturnZeroForEmptyString
測試通過了。如果之前有其他測試,也運行一下。所有測試都通過了,綠燈!
步驟 3:重構 (Refactor) - 改進代碼
- 代碼量很少,暫時不需要大的重構。可能把
if
判斷寫得更清晰一點,或者給類和方法加注釋。這里跳過大的重構。
步驟 4:紅燈 (Red) - 測試單個數字
- 需求: 輸入 "1" 應該返回 1。
寫測試:
class StringCalculatorTest {// ... shouldReturnZeroForEmptyString 測試 ...@Testvoid shouldReturnNumberForSingleNumberString() {StringCalculator calculator = new StringCalculator();// 斷言:調用 add("1") 應該返回 1assertEquals(1, calculator.add("1"));// 斷言:調用 add("5") 應該返回 5assertEquals(5, calculator.add("5"));}
}
- 運行測試:
shouldReturnNumberForSingleNumberString
測試會失敗(因為add("1")
仍然返回 0)。shouldReturnZeroForEmptyString
應該仍然通過。測試失敗,紅燈!
步驟 5:綠燈 (Green) - 讓測試通過
- 目標: 讓
shouldReturnNumberForSingleNumberString
通過。
寫最少量代碼:
class StringCalculator {public int add(String numbers) {if (numbers == null || numbers.isEmpty()) {return 0;}// !!! 添加處理單個數字的代碼 !!!// 嘗試將字符串轉換為整數return Integer.parseInt(numbers); // !!! 讓測試通過的最少量代碼 !!!}
}
- 運行測試:
shouldReturnZeroForEmptyString
和shouldReturnNumberForSingleNumberString
都通過了。所有測試都通過了,綠燈!
步驟 6:重構 (Refactor) - 改進代碼
Integer.parseInt
可能會拋出NumberFormatException
,雖然當前測試沒有覆蓋到無效數字字符串,但為了健壯性,可以在這里考慮異常處理(或者等寫了相關測試后再處理)。這里暫時不展開。
步驟 7:紅燈 (Red) - 測試兩個數字
- 需求: 輸入 "1,2" 應該返回 3。
寫測試:
class StringCalculatorTest {// ... shouldReturnZeroForEmptyString 測試 ...// ... shouldReturnNumberForSingleNumberString 測試 ...@Testvoid shouldReturnSumForTwoNumbersSeparatedByComma() {StringCalculator calculator = new StringCalculator();// 斷言:調用 add("1,2") 應該返回 3assertEquals(3, calculator.add("1,2"));// 斷言:調用 add("5,7") 應該返回 12assertEquals(12, calculator.add("5,7"));}
}
- 運行測試:
shouldReturnSumForTwoNumbersSeparatedByComma
測試會失敗(因為add("1,2")
會因為無法直接解析 "1,2" 而拋出NumberFormatException
)。測試失敗,紅燈!
步驟 8:綠燈 (Green) - 讓測試通過
- 目標: 讓
shouldReturnSumForTwoNumbersSeparatedByComma
通過。
寫最少量代碼:
class StringCalculator {public int add(String numbers) {if (numbers == null || numbers.isEmpty()) {return 0;}// !!! 添加處理逗號分隔的代碼 !!!String[] numberArray = numbers.split(","); // 按逗號分割if (numberArray.length == 1) {// 如果分割后只有一個元素 (處理單個數字的情況)return Integer.parseInt(numberArray[0]);} else {// 如果分割后有兩個元素 (處理兩個數字的情況)int num1 = Integer.parseInt(numberArray[0]);int num2 = Integer.parseInt(numberArray[1]);return num1 + num2; // 求和}// 注意:這段代碼現在還不能處理三個或更多數字,甚至無效數字字符串// 但它讓當前的測試通過了}
}
- 運行測試: 所有三個測試都應該通過。所有測試都通過了,綠燈!
步驟 9:重構 (Refactor) - 改進代碼
現在的代碼有點簡陋,只能處理空字符串、一個數字或兩個數字。我們可以重構它,讓它能處理任意數量的數字(注意這里違反了第三條定律):
(需求就是實現測試,目前已經完成了,這里是重構,不過重構不建議加新功能哈,這里不加新功能沒啥好重構的了hhhh)
class StringCalculator {public int add(String numbers) {if (numbers == null || numbers.isEmpty()) {return 0;}// 重構:處理任意數量的數字String[] numberArray = numbers.split(","); // 按逗號分割int sum = 0;for (String numberStr : numberArray) {// 這里應該加上 NumberFormatException 的處理,但為了例子簡潔暫不加sum += Integer.parseInt(numberStr); // 累加每個數字}return sum;}
}
- 運行測試: 再次運行所有測試,確保重構沒有破壞功能。它們都應該通過。
這個過程會一直進行下去,每次只添加一點點功能(比如處理換行符分隔、處理負數、忽略大于 1000 的數字等等),為每個新功能寫一個測試,讓測試通過,然后重構。
這就是 TDD 的基本流程。它通過小步快跑、頻繁測試和重構,確保你構建的功能是正確的,并且代碼保持整潔。
測試的整潔
整潔測試三要素:可讀性、可讀性和可讀性。
核心思想: 好的測試和好的生產代碼一樣重要,它們必須是整潔且易于維護的。
整潔測試的五大原則 (F.I.R.S.T.):
F - Fast (快速):
- 什么意思: 你的測試應該運行得非常快。
- 為什么重要: 如果測試運行得慢,開發者就不會頻繁地運行它們(比如在每次修改代碼后)。不頻繁運行測試,測試的價值就大打折扣,無法及時發現問題。快速的測試才能融入到小步快跑的 TDD 循環中。
I - Independent (獨立):
- 什么意思: 每個測試用例都應該是獨立的,它們不應該相互依賴。一個測試的通過或失敗不應該影響到其他測試的運行結果。
- 為什么重要: 如果測試相互依賴,當一個測試失敗時,可能會導致一系列其他測試也跟著失敗(級聯失敗),讓你很難判斷是哪個測試真正發現了問題,調試會非常困難。獨立性也意味著你可以隨意調整測試的運行順序,或者只運行某個特定的測試,而不用擔心遺漏依賴項。
R - Repeatable (可重復):
- 什么意思: 在任何環境(你的開發機、測試服務器、CI/CD 環境)下,無論何時運行,測試都應該給出相同的結果。
- 為什么重要: 如果測試的結果不可重復(有時通過,有時失敗),你就無法信任你的測試套件。它可能是因為外部因素(如網絡、時間、文件狀態)或測試本身的設計問題導致的不穩定(Flaky Test)。不可重復的測試是最大的障礙,會讓人失去對測試的信心。
S - Self-validating (自我驗證): 就是用斷言,控制臺通過或報錯,而不是看控制臺輸出
- 什么意思: 測試的輸出必須是明確的“通過”或“失敗”。它應該通過自動化斷言(Assert)來判斷結果,而不是需要人工去查看日志、比較文件或觀察程序行為來判斷是否正確。
- 為什么重要: 自動化測試的目的就是減少人工干預。測試運行完畢后,你只需要看一個簡單的報告(比如綠條或紅條)就知道代碼是否工作正常,不需要花費時間去分析結果。
T - Timely (及時):
- 什么意思: 測試應該在正確的時間編寫。在 TDD 中,正確的時間就是恰好在需要實現對應功能之前。
- 為什么重要: 及時編寫測試(先于代碼)是 TDD 方法論的核心,它驅動你思考代碼如何使用,促進更好的設計,并確保不會遺漏測試。