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

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