測試覆蓋(率)到底有什么用?
引言
Martin Fowler(重構那本書的作者)曾經寫過一篇博客來討論這個問題,他指出:把測試覆蓋作為質量目標沒有任何意義,而我們應該把它作為一種發現未被測試覆蓋的代碼的手段。
Brian Marick(敏捷宣言最早的17個簽署人之一)也說過,作為一名程序員,我當然期望我的代碼有較高的測試覆蓋率。但是,當我的經理要求這樣的指標時,那就有別的目的了(績效考核?)。
我認為,高的測試覆蓋率應該是每個“認真”寫單元測試的程序員得到的必然結果,管理者把一個結果作為指標來衡量,本身就是沒有意義的。如果你把“萬能”的程序員逼急了,他就會從 “神秘的工具箱”中拿出一兩個“法寶”來,“高效”地達成指標。我就見過很多這樣的“法寶”,比如在單元測試中連一個“assert”也沒有,或者寫很多get和set方法的單元測試(寫起來簡單啊)來提高整體的覆蓋率等等。更何況,測試充分的代碼也有可能無法達到100%的覆蓋率,本文的后面就有這樣的例子。
那你大概會問:“那測試覆蓋到底有什么用呢?”。我的答案還是很簡單,“測試覆蓋是一種學習手段”。學習什么呢?學習為什么有些代碼沒有被覆蓋到,以及為什么有些代碼變了測試卻沒有失敗。理解“為什么”背后的原因,程序員就可以做相應的改善和提高,相比憑空想象單元測試的有效性和代碼的好壞,這會更加有效。
接下來,我會給大家介紹一些傳統的測試覆蓋方法和一種稱為“代碼變異測試”(Mutation Test)的方法。大家將會看到這些方法都可以產生什么樣的學習點,以及代碼變異測試相比傳統方法更有價值的地方。如果你是一名程序員(我不會區分你是開發人員還是測試人員,那對我來說都一樣),希望你看完這篇文章之后,可以找到一些提高測試和代碼質量的方法。如果你是一位管理者,不論你正在用還是想要用“測試覆蓋率”來做度量,希望你看完這篇文章之后,可以放棄這個想法,做點更有意義的事情(比如去寫點代碼)。
傳統的測試覆蓋方法
傳統的測試覆蓋方法常見的有以下幾種:
函數覆蓋(Function Coverage)
語句覆蓋(Statement Coverage)
決策覆蓋(Decision Coverage)
條件覆蓋(Condition Coverage)
還有一些其他覆蓋方法,如Modified Condition/Decision Coverage,就不在這里討論了。
函數覆蓋:顧名思義,就是指這個函數是否被測試代碼調用了。以下面的代碼為例,對函數foo要做到覆蓋,只要一個測試——如assertEquals(2, foo(2, 2))——就可以了。如果連函數覆蓋都達不到,那應該想想這個函數是否真的需要了。如果需要的話,那又為什么寫不了一個測試呢?
語句覆蓋:(也稱行覆蓋),指的是某一行代碼是否被測試覆蓋了。同樣的代碼要達到語句覆蓋也只需要一個測試就夠了,如assertEquals(2, foo(2, 2))。但是,如果把測試換成assertEquals(0, foo(2, -1)),那就無法達到所有行覆蓋的效果了。通常這種情況是由于一些分支語句導致的,因為相應的問題就是“那行代碼(以及它所對應的分支)需要嗎?”,或者“用什么測試可以覆蓋那行代碼所代表的分支呢?”。

決策覆蓋:指的是某一個邏輯分支是否被測試覆蓋了。如我上面所說,語句覆蓋通常和決策覆蓋有關系。還是以上面的代碼為例,要達到所有的決策覆蓋(即那個if語句為真和假的情況至少出現一次),我們需要至少兩個測試,如assertEquals(2, foo(2, 2))和assertEquals(0, foo(-1, 2))。如果有一個邏輯分支沒有被覆蓋(比如只有測試assertEquals(2, foo(2, 2))),那么我們應該問和上面“語句覆蓋”小節中相似的問題。
條件覆蓋:指的是分支中的每個條件(即與,或,非邏輯運算中的每一個條件判斷)是否被測試覆蓋了。之前的代碼要達到全部的條件覆蓋(也就是x>0和y>0這兩個條件為真和假的情況均至少出現一次)需要更多的測試,如assertEquals(2, foo(2, 2)),assertEquals(2, foo(2, -1))和assertEquals(2, foo(-1, -1))。如果有一個條件分支沒有被覆蓋(比如缺少測試assertEquals(2, foo(-1, -1))),那么大家應該想想“那個條件判斷是否還需要呢?”,或者“用什么測試可以覆蓋那個條件所對應的邏輯呢?”。
通過上面對幾種傳統的測試覆蓋方法的介紹,大家不難發現,這些方法的確可以幫我們找到一些顯而易見的代碼冗余或者測試遺漏的問題。不過,實踐證明,這些傳統的方法只能產生非常有限的“學習”代碼和測試中問題的機會。很多代碼和測試的問題即使在達到100%覆蓋的情況下也無法發現。然而,我接下來要介紹的“代碼變異測試”這種方法則,它可以很好的彌補傳統方法的缺點,產生更加有效的“學習”機會。
代碼變異測試(Mutation Test)
代碼變異測試是通過對代碼產生“變異”來幫助我們學習的。“變異”指的是修改一處代碼來改變代碼行為(當然保證語法的合理性)。簡單來說,代碼變異測試先試著對代碼產生這樣的變異,然后運行單元測試,并檢查是否有任何測試因為這個代碼變異而失敗。如果有測試失敗,那么說明這個變異被“消滅”了,這是我們期望看到的結果。如果沒有測試失敗,則說明這個變異“存活”了下來,這種情況下我們就需要去研究一下“為什么”了。
是不是感覺有點繞呢?讓我們換個角度來說明一下,可能就容易理解了。測試驅動開發相信大家一定都聽說過,它的一個重要觀點是,我們應該以最簡單的代碼來通過測試(剛好夠,Just Enough)。基于這個前提,那么幾乎所有的代碼修改(即“變異”)都應該會改變代碼的行為,從而導致測試失敗。這樣的話,如果有個變異沒有導致測試失敗,那要么是代碼有冗余,要么就是測試不足以發現這個變異。
另一方面,大家可以想一下對于自動化測試(包括單元測試)的期望是什么。我覺得一個很重要的期望就是,自動化測試可以防止“任何”錯誤的代碼修改,以減少代碼維護帶來的風險。錯誤的代碼修改實際上就是一個代碼變異,代碼變異測試可以幫我們找到一些無法被當前測試所防止的潛在錯誤。
舉例來說,我們給之前的那段被測代碼增加一行,sideEffect(z)。之前的那些可以讓傳統的測試覆蓋方法達到100%覆蓋率的測試,在新增這行代碼之后,依然會全部通過且覆蓋率不變。然而,如果我們再刪除那行新代碼sideEffect(z),結果有會怎樣呢?那些測試還是會全部通過,覆蓋率也還是100%。在這種情況下,原來那些測試可以說沒有任何意義。相對的,代碼變異測試則可以通過刪除那一行,再運行測試,就會發現沒有任何測試失敗。然后,我們就可以根據這個結果想到其實還需要一個測試來驗證sideEffect(z)這個行為(如果那行代碼不是多余的話)。
再舉一個例子,還是之前的代碼,不做任何修改。我們用assertEquals(2, foo(2, 2)),assertEquals(2, foo(2, -1))和assertEquals(2, foo(-1, -1))這三個測試達到了100%的條件覆蓋。然而,如果把y > 0的條件改成 y >= 0的話,這三個測試依然會通過。為什么會出現這樣的問題呢?那是因為之前的測試對輸入參數的選擇比較隨意,所以讓這個代碼變異存活了下來。可以看到,在條件覆蓋100%的情況下,代碼變異測試依然可以幫我們發現這種測試寫的不嚴謹的問題(假設y >= 0這個代碼變異是不合理的),從而使修改后的測試可以防止產生這樣的錯誤代碼。
通過上面兩個例子,相信大家已經發現代碼變異測試可以給我們提供大量的學習代碼合理性和測試有效性的機會。實際上,類似的代碼變異還有很多種。下面是常見變異的列表,更詳細的內容可以參考http://pitest.org/quickstart/mutators/。
條件邊界變異(Conditionals Boundary Mutator)
對關系運算(<, <=, >, >=)進行變異,上面第二例子就是這種變異
反向條件變異(Negate Conditionals Mutator)
對關系運算(==, !=, <, <=, >, >=)進行變異,例如把“==”變成“!=”
數學運算變異(Math Mutator)
對數學運算(+, -, *, /, %, &, |, ^, >>, <<, >>>)進行變異,例如把“+”變成“-”
增量運算變異(Increments Mutator)
對遞增或者遞減的運算(++, --)進行變異,例如把“++”變成“--”
負值翻轉變異(Invert Negatives Mutator)
對負數表示的變量進行變異,例如把“return -i”變成“return i”
內聯常量變異(Inline Constant Mutator)
對代碼中用到的常量數字進行變異,例如把“int i=42”變成“int i=43”
返回值變異(Return Values Mutator)
對代碼中的返回值進行變異,例如把“return 0”變成“return 1”或者把“return new Object();”變成“new Object(); return null;”
無返回值方法調用變異(Void Method Calls Mutator)
對代碼中的無返回值方法調用進行變異,也就是把那個方法調用刪除掉,上面的第一個例子就是這種變異。
有返回值方法調用變異(Non Void Method Calls Mutator)
對代碼中的有返回值函數調用進行變異,也就是接收返回值的變量賦值將被替換成為返回值類型的語言默認值,例如把“int i = getSomeIntValue()”變成“int i = 0”
構造函數調用變異(Constructor Calls Mutator)
對代碼中的構造函數調用進行變異,例如把“Object o = new Object()”變成“Object o == null”
測試驅動開發和代碼變異測試
測試驅動開發(TDD)是我推崇和實踐的寫代碼(做設計)方法。我在前面曾經提到,代碼變異測試的假設是“實現代碼是剛好夠通過測試的最簡單代碼”,而這也是TDD中的重要實踐之一。大家可能會問,如果做了TDD,代碼變異測試的結果又會如何呢?還會產生學習的機會嗎?答案是肯定的,一定會。
讓我們通過例子來看一下。我經常會做一些Kata來練習編程技巧,PokerHands(如上圖)就是其中之一(其實大體就是實現梭哈的五張比較規則http://codingdojo.org/cgi-bin/wiki.pl?KataPokerHands)。每次我把Kata做完之后,都會用運行一下代碼變異測試(sonar中有插件)。Java的代碼變異測試工具有個比較好的叫pitest。下面是我用這個工具跑出來的結果,代碼可以在這里找到https://github.com/JosephYao/Kata-PokerHands。
如大家所見,紅色那一行中有一個存活下來的代碼變異。而這個代碼變異是把“index < CARD_COUNT - 1”中的“<”換成“>”。看上去很不可思議吧,因為進行這樣的代碼變異意味著整個for循環都不會被執行了,應該不可能沒有一個測試失敗吧?
讓我們來看一下相關的單元測試。在下面這個測試中有三個assert,它們都是在驗證“一對”之間通過對子的點數來比較大小的情況。大家仔細觀察就可以發現,其實這三個assert中的牌如果作為High Card(就是比一對小一點的牌組)來比較的話,也都是成立的。這也就是那個代碼變異可以存活下來的原因,因為即使忽略了一對之間的比較,通過High Card比較出來的大小關系也是一樣的。我從中學到的是,只要把 assertPokerHandsLargerThan("2S 3H 5S 8C 8D","2S 3H 5S 7C 7D")改為 assertPokerHandsLargerThan("2S 3H 5S 8C 8D","2S 3H 9S 7C 7D")就可以清除這個代碼變異了。
從這個例子中可以看到,即使以TDD的方法來寫代碼,也是無法完全避免出現代碼變異存活下來的情況的(當然,存活變異的數量要非常明顯的少于不用TDD而寫出來的代碼)。做過TDD的人可能都有這樣的感覺,就是有時很難抑制自己寫出復雜代碼的沖動(也就是說代碼不是“剛好夠”的)。有時,即使實現代碼是最簡單的,也可能因為代碼過于直接,就會很“隨意”的寫出一個讓當前代碼失敗的測試。上面的例子就是這種情況,這樣不太“有效”的測試通常在TDD過程中很難意識到,從而給之后的代碼維護造成隱患。
除了上面那個有學習意義的代碼變異之外,其實工具還幫我找到了一個“沒意義”但存活下來的代碼變異。
這里存活下來的代碼變異是指把“index < CARD_COUNT - 2”中的“<”變成“<=”。之所以說這個代碼變異沒意義,是因為根據代碼上下文,在for循環中一定會在index等于CARD_COUNT - 2之前就找到那個三張的點數。因為工具無法理解上下文,所以產生了這個沒意義的代碼變異(也叫做Equivalent Mutation)。之所以舉這個例子,只是想提醒大家不要迷信代碼變異測試工具。對于他產生的結果一定去分析和學習,不然很容易走上考核指標的那條不歸路。
小結
總而言之,測試覆蓋這種方法是一種不錯的學習手段,可以幫助我們提高代碼和測試質量。代碼變異測試則比傳統的測試覆蓋方法可以更加有效的發現代碼和測試中潛在的問題,提供更多的學習機會。在這里,我要鄭重警告那些妄圖把代碼變異測試變成一種新的考核指標的管理者們,這樣做只會迫使程序員從他的神秘工具箱中找出新的法寶來對付你(比如,修改編譯器等等)。
代碼變異測試的概念其實早在30年前就被提出了。之所以到目前為止還沒有被業界廣泛接納,一個重要原因是由于需要對每個代碼變異反復運行測試。如果不是單元測試(運行速度慢),代碼變異測試工具執行時將消耗大量的時間。正因如此,單元測試可能是唯一符合代碼變異測試要求的一種測試了。如果你對代碼變異測試的歷史和發展過程感興趣的話,你可以參考這篇研究報告http://crestweb.cs.ucl.ac.uk/resources/mutation_testing_repository/TR-09-06.pdf。
posted on 2014-01-29 10:47 順其自然EVO 閱讀(427) 評論(1) 編輯 收藏 所屬分類: 測試學習專欄