讀古今文學網 > 編寫高質量代碼:改善Java程序的151個建議 > 建議145:不要完全依靠單元測試來發現問題 >

建議145:不要完全依靠單元測試來發現問題

單元測試的目的是保證各個獨立分割的程序單元的正確性,雖然它能夠發現程序中存在的問題(或缺陷,或錯誤),但是單元測試只是排查程序錯誤的一種方式,不能保證代碼中的所有錯誤都能被單元測試挖掘出來,原因有以下四點。

(1)單元測試不可能測試所有的場景(路徑)

單元測試必須測試的三種數據場景是:正常場景、邊界場景、異常場景。一般情況下,如果這三種測試場景都能出現預期的結果,則認為代碼正確,但問題是代碼是人類思維的直觀表達,要想完整地測試它就必須寫出比生產代碼多得多的測試代碼,例如有這樣一個類:


public class Foo{

//除法計算

public int pid(int a, int b){

return a/b;

}

}


就這一個簡單的除法計算,如果我們要進行完整的測試就必須建立三個不同的測試場景:正常數據場景,用來測試代碼的主邏輯;邊界數據場景,用來測試代碼(或數據)在邊界的情況下邏輯是否正確;異常數據場景,用來測試出現異常非故障時能否按照預期運行,測試類如下:


public class FooTest{

//構建測試對像

private Foo foo=new Foo();

//正常測試場景

@Test

public void testDividNormal(){

//斷言100除以10的結果為10

assertEquals(10,foo.pid(100,10));

}

//邊界測試場景

@Test

public void testDividBroader(){

//斷言最大值除以最小值結果為0

assertEquals(0,foo.pid(Integer.MAX_VALUE, Integer.MIN_VALUE));

//斷言最小值除以最大值結果為-1

assertEquals(-1,foo.pid(Integer.MIN_VALUE, Integer.MAX_VALUE));

}

//異常測試場景

@Test(expected=ArithmeticException.class)

public void testDividException(){

//斷言除數為0時拋出ArithmeticException

foo.pid(100,0);

//斷言不會執行到這裡

fail();

}

}


諸位可以看到這麼簡單的一個除法計算就需要如此多的測試代碼,如果在生產代碼中再加入就if、switch等判斷語句,那它所需要的測試場景就會更加複雜了。只要有一個判斷條件,就必須有兩個測試場景(條件為真的場景和條件為假的場景),這也是在項目中的測試覆蓋率不能達到100%的一個主要原因:單元測試的代碼量遠大於生產代碼。通常在項目中,單元測試覆蓋率很難達到60%,因為不能100%覆蓋,這就導致了代碼測試的不完整性,隱藏的缺陷也就必然存在了。

(2)代碼整合錯誤是不可避免的

單元測試只是保證了分割的獨立單元的正確性,它不能保證一個功能的完整性。單元測試首先會假設所有的依賴條件都滿足,但真實情況並不是這樣的,我們經常會發現雖然所有的單元測試都通過了,但在進行整合測試時仍然會產生大量的業務錯誤——很多情況下,此種錯誤是因為對代碼的職責不清晰而引起的,這屬於認知範疇,不能通過單元測試避免。

(3)部分代碼無法(或很難)測試

如果把如下代碼放置在一個多線程的環境下,思考一下該如何測試呢?代碼如下:


class Apple{

//蘋果顏色

private int color;

public int getColor(){

return color;

}

public void setColor(int color){

this.color=color;

}

}


這是一個簡單的JavaBean,也是我們項目中經常出現的,對於此類BO(Business Object),通常情況下是不會進行單元測試的,想必你也會想這不用測試吧,很簡單嘛,就一個getter/setter方法,出錯的可能性不大。但這只是我們一廂情願的想法,如果該Apple是在多線程環境下,你還認為不會出現線程不安全的情況嗎?事實上,因為沒有採用資源保護措施(synchronized或Lock),多個線程共同訪問該對像時就會出現不安全的情況。現在問題來了:為什麼在通常情況下不做此類對象的單元測試呢?

比如一個JEE應用,一般情況下都是多線程環境,但是我們很少對代碼進行多線程測試,原因很簡單,測試很複雜,很難進行全面的多線程測試。而且如果要保證在多線程下測試通過,就必須對代碼增加大量的修飾,這必然會導致代碼的可讀性和可維護性降低,這也是我們一般都拋棄多線程測試的原因。

在Spring中,默認情況下每個注入的對象都是Singleton的,也就是單例的,每個類在內存中只有一個對像實例,這也是偶爾出現數據資源不一致現象的元兇:在多線程環境下數據未進行資源保護,特別是在系統壓力較大、響應能力較低的情況下,數據資源出現不一致情況的可能性更大。

這只是一種單元測試很難覆蓋的情景,還有一種情景是根本不能實施單元測試,比如不確定性算法(Nondeterministic Algorithm),什麼叫不確定算法?像我們經常接觸的函數f(x),給定一個確定的x值,就有確定的結果f(x),在任何時候輸入x,都能獲得固定的f(x),這就是確定性算法,也是我們經常接觸的,但還有一種算法,比如要計算出明天通過某一個大橋的車輛數量,必須根據專家經驗、天氣、交通情況、是否是節慶日、是否有大型體育比賽、並行道路通行的情況等來進行計算,這些條件很多都是非確定的依據,所推導出的也是一個非確定結論的數據——明天通過大橋的車輛數量,想想看,這怎麼進行單元測試,不確定算法只能無限接近而不能達到,單元測試只能對確定算法進行假設,不能對不確定算法進行驗證。

(4)單元測試驗證的是編碼人員的假設

我們都知道單元測試是白箱測試,一般情況下測試代碼是編碼人員自行編寫的,我們可以這樣理解,編碼人員根據胸中的藍圖,迅速地實行了一個算法,然後通過斷言確定算法是否與預期相匹配。簡單地說,我們左手畫了一個圓,右手拿著一個圓規進行測試,檢驗這是否是一個標準的圓,但問題是是誰要求我們畫一個圓的呢?誰又能確定是一個直徑為2厘米的圓而不是2.1厘米的圓?——代碼的意圖只是反映了編碼者的理解,而單元測試只是驗證了這種假設。想想看,如果編碼者從一開始就誤解了需求意圖,此時的單元測試就充當了幫兇:驗證了一個錯誤的假設。

指出單元測試的缺陷,並不是為了說明單元測試不必要,相反,單元測試在項目中是必須的,而且是非常重要的,特別敏捷開發中提倡的TDD(Test-Driven Development)測試驅動開發:單元測試先行,而後才會編寫生產代碼,這可以大幅度地提升代碼質量,加快項目開發的進度。