01、前言
很早之前,曾在網路上見到過 TDD 這 3 個大寫的英文字母,它是 Test Driven Development 這三個單詞的縮寫,也就是“測試驅動開發”的意思——聽起來很不錯的一種理念。
其理念主要是確保兩件事:
- 確保所有的需求都能被照顧到。
- 在程式碼不斷增加和重構的過程中,可以檢查所有的功能是否正確。
但後來很長一段時間裡,都沒再聽過 TDD 的訊息。有人說,TDD 已經死了,給出的意見如下:
1)通常來說,開發人員不應該在沒有失敗的測試用例下編寫程式碼——這似乎是合理的,但是它可能導致過度測試。例如,為了保證一行生產程式碼的正確性,你不由得寫了 4 行測試程式碼,這意味著一旦這一行生產程式碼需要修改,你也得修改那 4 行測試程式碼。
2)為了遵循 TDD 而寫的程式碼,容易進入一個誤區:程式碼是為了滿足測試用的,而忽略了實際需求。
02、TDD 到底是什麼?
不管 TDD 到底死了沒有,先讓我們來回顧一下 TDD 到底是什麼。
TDD 的基本思想就是在開發功能程式碼之前,先編寫測試程式碼。也就是說在明確要開發某個功能後,首先思考如何對這個功能進行測試,並完成測試程式碼的編寫,然後編寫相關的程式碼滿足這些測試用例。然後迴圈進行新增其他功能,直到完成全部功能的開發。
TDD 的基本過程可以拆解為以下 6 個步驟:
1) 分析需求,把需求拆分為具體的任務。
2) 從任務列表中取出一個任務,並對其編寫測試用例。
3) 由於沒有實際的功能程式碼,測試程式碼不大可能會通過(紅)。
4) 編寫對應的功能程式碼,儘快讓測試程式碼通過(綠)。
5) 對程式碼進行重構,並保證測試通過(重構)。
6) 重複以上步驟。
可以用下圖來表示上述過程。
03、TDD 的實踐過程
通常情況下,我們都習慣在需求分析完成之後,儘快地投入功能程式碼的編寫工作中,之後再去呼叫和測試。
而 TDD 則不同,它假設我們已經有了一個“測試使用者”了,它是功能程式碼的第一個使用者,儘管功能程式碼還不太完善。
當我們站在“測試使用者”的角度去寫測試程式碼的時候,我們要考慮的是,這個“測試使用者”該如何使用功能程式碼呢?是通過一個類直接呼叫方法呢(靜態方法),還是構建類的例項去呼叫方法呢(例項方法)?這個方法如何傳參呢?方法如何命名呢?方法有返回值嗎?
有了測試程式碼後,我們開始編寫功能程式碼,並且要以最快地速度讓測試由“紅”變為“綠”,可能此時的功能程式碼很不優雅,不過沒關係。
當測試通過以後,我們就可以放心大膽的對功能程式碼進行“重構”了——優化原來比較醜陋、臃腫、效能偏差的程式碼。
接下來,假設我們接到了一個開發需求:
汪汪隊要到小鎮冒險島進行表演,門票為 99 元,冒險島上唯一的一個程式設計師王二需要開發一款可以計算門票收入的小程式。
按照 TDD 的流程,王二需要先使用 Junit 編寫一個簡單的測試用例,測試預期是:銷售一張門票的收入是 99 元。
public class TicketTest {
private Ticket ticket;
@Before
public void setUp() throws Exception {
ticket = new Ticket();
}
@Test
public void test() {
BigDecimal total = new BigDecimal("99");
assertEquals(total, ticket.sale(1));
}
}
為了便於編譯能夠順利通過,王二需要一個簡單的 Ticket 類:
public class Ticket {
public BigDecimal sale(int count) {
return BigDecimal.ZERO;
}
}
測試用例執行結果如下圖所示,紅色表示測試沒有通過:預期結果是 99,實際結果是 0。
那接下來,王二需要快速讓測試通過,Ticket.sale()
方法修改後的結果如下:
public class Ticket {
public BigDecimal sale(int count) {
if (count == 1) {
return new BigDecimal("99");
}
return BigDecimal.ZERO;
}
}
再執行一下測試用例,結果如下圖所示,綠色表示測試通過了:預期結果是 99,實際結果是 99。
綠了,綠了,測試通過了,到了該重構功能程式碼的時候了。99 元是個魔法數字,至少應該宣告成常量,對吧?
public class Ticket {
private final static int PRICE = 99;
public BigDecimal sale(int count) {
if (count == 1) {
return new BigDecimal(PRICE);
}
return BigDecimal.ZERO;
}
}
重構完後再執行一下測試用例,確保測試通過的情況下,再增加幾個測試用例,比如說門票銷量為負數、零甚至一千的情況。
public class TicketTest {
private Ticket ticket;
@Before
public void setUp() throws Exception {
ticket = new Ticket();
}
@Test
public void testOne() {
BigDecimal total = new BigDecimal("99");
assertEquals(total, ticket.sale(1));
}
@Test(expected=IllegalArgumentException.class)
public void testNegative() {
ticket.sale(-1);
}
@Test
public void testZero() {
assertEquals(BigDecimal.ZERO, ticket.sale(0));
}
@Test
public void test1000() {
assertEquals(new BigDecimal(99000), ticket.sale(1000));
}
}
銷量為負數的時候,王二希望功能程式碼能夠丟擲異常;銷量為零的時候,功能程式碼的計算結果應該為零;銷量為一千的時候,計算結果應該為 99000。
重新執行一下測試用例,結果如下圖所示:
有兩個測試用例沒有通過,那麼王二需要繼續修改功能程式碼,調整如下:
public class Ticket {
private final static int PRICE = 99;
public BigDecimal sale(int count) {
if (count < 0) {
throw new IllegalArgumentException("銷量不能為負數");
}
if (count == 0) {
return BigDecimal.ZERO;
}
if (count == 1) {
return new BigDecimal(PRICE);
}
return new BigDecimal(PRICE * count);
}
}
再執行一下測試用例,發現都通過了。又到了重構的時候了,銷量為零、或者大於等於一的時候,程式碼可以合併,於是重構結果如下:
public class Ticket {
private final static int PRICE = 99;
public BigDecimal sale(int count) {
if (count < 0) {
throw new IllegalArgumentException("銷量不能為負數");
}
return new BigDecimal(PRICE * count);
}
}
重構結束後,再執行測試用例,確保重構後的程式碼依然可用。
04、最後
從上面的實踐過程可以得出如下結論:
TDD 想要做的就是讓我們對自己的程式碼充滿信心,因為我們可以通過測試程式碼來判斷這段程式碼是否正確無誤。
也就是說,TDD 流程比較關鍵的一環在於如何寫出有效的測試程式碼,這裡有 4 個原則可以參考:
1)測試過程應該儘量模擬正常使用的過程。
2)應該儘量做到分支覆蓋。
3)測試資料應該儘量包括真實資料,以及邊界資料。
4)測試語句和測試資料應該儘量簡單,容易理解。
注意,這 4 個原則不僅適用於 TDD,同樣適用於任何流程下的單元測試。
最後,我想說的是,不管 TDD 有沒有死,TDD 都不是銀彈,不可能適合所有的場景,但這不應該成為我們拒絕它的理由。