用 Cobertura 測量測試覆蓋率
Cobertura 是一種開源工具,它通過檢測基本的程式碼,並觀察在測試包執行時執行了哪些程式碼和沒有執行哪些程式碼,來測量測試覆蓋率。除了找出未測試到的程式碼並發現 bug 外,Cobertura 還可以通過標記無用的、執行不到的程式碼來優化程式碼,還可以提供 API 實際操作的內部資訊。Elliotte Rusty Harold 將與您分享如何利用程式碼覆蓋率的最佳實踐來使用 Cobertura。
儘管測試先行程式設計(test-first programming)和單元測試已不能算是新概念,但測試驅動的開發仍然是過去 10 年中最重要的程式設計創新。最好的一些程式設計人員在過去半個世紀中一直在使用這些技術,不過,只是在最近幾年,這些技術才被廣泛地視為在時間及成本預算內開發健壯的無缺陷軟體的關鍵所在。但是,測試驅動的開發不能超過測試所能達到的程度。測試改進了程式碼質量,但這也只是針對實際測試到的那部分程式碼而言的。您需要有一個工具告訴您程式的哪些部分沒有測試到,這樣就可以針對這些部分編寫測試程式碼並找出更多 bug。
Mark Doliner 的 Cobertura (cobertura 在西班牙語是覆蓋的意思)是完成這項任務的一個免費 GPL 工具。Cobertura 通過用額外的語句記錄在執行測試包時,哪些行被測試到、哪些行沒有被測試到,通過這種方式來度量位元組碼,以便對測試進行監視。然後它生成一個 HTML 或者 XML 格式的報告,指出程式碼中的哪些包、哪些類、哪些方法和哪些行沒有測試到。可以針對這些特定的區域編寫更多的測試程式碼,以發現所有隱藏的 bug。
閱讀 Cobertura 輸出
我們首先檢視生成的 Cobertura 輸出。圖 1 顯示了對 Jaxen 測試包執行 Cobertura 生成的報告(請參閱 org.jaxen.expr.iter
包中幾乎是 100%)到極差(在
org.jaxen.dom.html
中完全沒有覆蓋)的覆蓋率結果。
圖 1. Jaxen 的包級別覆蓋率統計資料
Cobertura 通過被測試的行數和被測試的分支數來計算覆蓋率。第一次測試時,兩種測試方法之間的差別並不是很重要。Cobertura 還為類計算平均 McCabe 複雜度(請參閱 參考資料)。
可以深入挖掘 HTML 報告,瞭解特定包或者類的覆蓋率。圖 2 顯示了 org.jaxen.function
包的覆蓋率統計。在這個包中,覆蓋率的範圍從
SumFunction
IdFunction
類的僅為 5%。
圖 2. org.jaxen.function 包中的程式碼覆蓋率
進一步深入到單獨的類中,具體檢視哪一行程式碼沒有測試到。圖 3 顯示了 NameFunction
類中的部分覆蓋率。最左邊一欄顯示行號。後一欄顯示了執行測試時這一行被執行的次數。可以看出,第 112 行被執行了 100 次,第 114 行被執行了 28 次。用紅色突出顯示的那些行則根本沒有測試到。這個報告表明,雖然從總體上說該方法被測試到了,但實際上還有許多分支沒有測試到。
圖 3. NameFunction 類中的程式碼覆蓋率
Cobertura 是 jcoverage 的分支(請參閱 參考資料)。GPL 版本的 jcoverage 已經有一年沒有更新過了,並且有一些長期存在的 bug,Cobertura 修復了這些 bug。原來的那些 jcoverage 開發人員不再繼續開發開放原始碼,他們轉向開發 jcoverage 的商業版和 jcoverage+,jcoverage+ 是一個從同一程式碼基礎中發展出來的封閉原始碼產品。開放原始碼的奇妙之處在於:一個產品不會因為原開發人員決定讓他們的工作獲得相應的報酬而消亡。
確認遺漏的測試
利用 Cobertura 報告,可以找出程式碼中未測試的部分並針對它們編寫測試。例如,圖 3 顯示 Jaxen 需要進行一些測試,運用 name()
函式對文位元組點、註釋節點、處理指令節點、屬性節點和名稱空間節點進行測試。
如果有許多未覆蓋的程式碼,像 Cobertura 在這裡報告的那樣,那麼新增所有缺少的測試將會非常耗時,但也是值得的。不一定要一次完成它。您可以從被測試的最少的程式碼開始,比如那些所有沒有覆蓋的包。在測試所有的包之後,就可以對每一個顯示為沒有覆蓋的類編寫一些測試程式碼。對所有類進行專門測試後,還要為所有未覆蓋的方法編寫測試程式碼。在測試所有方法之後,就可以開始分析對未測試的語句進行測試的必要性。
(幾乎)不留下任何未測試的程式碼
是否有一些可以測試但不應測試的內容?這取決於您問的是誰。在 JUnit FAQ 中,J. B. Rainsberger 寫到“一般的看法是:如果 自身 不會出問題,那麼它會因為太簡單而不會出問題。第一個例子是
getX()
方法。假定 getX()
方法只提供某一例項變數的值。在這種情況下,除非編譯器或者直譯器出了問題,否則
getX()
是不會出問題的。因此,不用測試 getX()
,測試它不會帶來任何好處。對於 setX()
方法來說也是如此,不過,如果
setX()
方法確實要進行任何引數驗證,或者說確實有副作用,那麼還是有必要對其進行測試。”
理論上,對未覆蓋的程式碼編寫測試程式碼不一定就會發現 bug。但在實踐中,我從來沒有碰到沒有發現 bug 的情況。未測試的程式碼充滿了 bug。所做的測試越少,在程式碼中隱藏的、未發現的 bug 就會越多。
我不同意。我已經記不清在“簡單得不會出問題”的程式碼中發現的 bug 的數量了。確實,一些 getter 和 setter 很簡單,不可能出問題。但是我從來就沒有辦法區分哪些方法是真的簡單得不會出錯,哪些方法只是看上去如此。編寫覆蓋像 setter 和 getter 這樣簡單方法的測試程式碼並不難。為此所花的少量時間會因為在這些方法中發現未曾預料到的 bug 而得到補償。
一般來說,開始測量後,達到 90% 的測試覆蓋率是很容易的。將覆蓋率提高到 95% 或者更高就需要動一下腦筋。例如,可能需要裝載不同版本的支援庫,以測試沒有在所有版本的庫中出現的 bug。或者需要重新構建程式碼,以便測試通常執行不到的部分程式碼。可以對類進行擴充套件,讓它們的受保護方法變為公共方法,這樣就可以對這些方法進行測試。這些技巧看起來像是多此一舉,但是它們曾幫助我在一半的時間內發現更多的未發現的 bug。
並不總是可以得到完美的、100% 的程式碼覆蓋率。有時您會發現,不管對程式碼如何改造,仍然有一些行、方法、甚至是整個類是測試不到的。下面是您可能會遇到的挑戰的一些例子:
- 只在特定平臺上執行的程式碼。例如,在一個設計良好的 GUI 應用程式中,新增一個 Exit 選單項的程式碼可以在 Windows PC 上執行,但它不能在 Mac 機上執行。
- 捕獲不會發生的異常的
catch
語句,比如在從ByteArrayInputStream
進行讀取操作時丟擲的IOException
。 - 非公共類中的一些方法,它們永遠也不會被實際呼叫,只是為了滿足某個介面契約而必須實現。
- 處理虛擬機器 bug 的程式碼塊,比如說,不能識別 UTF-8 編碼。
考慮到上面這些以及類似的情況,我認為一些極限程式設計師自動刪除所有未測試程式碼的做法是不切實際的,並且可能具有一定的諷刺性。不能總是獲得絕對完美的測試覆蓋率並不意味著就不會有更好的覆蓋率。
然而,比執行不到的語句和方法更常見的是殘留程式碼,它不再有任何作用,並且從程式碼基中去掉這些程式碼也不會產生任何影響。有時可以通過使用反射來訪問私有成員這樣的怪招來測試未測試的程式碼。還可以為未測試的、包保護(package-protected)的程式碼來編寫測試程式碼,將測試類放到將要測試的類所在那個包中。但最好不要這樣做。所有不能通過釋出的(公共的和受保護的)介面訪問的程式碼都應刪除。執行不到的程式碼不應當成為程式碼基的一部分。程式碼基越小,它就越容易被理解和維護。
不要漏掉測量單元測試包和類本身。我不止一次注意到,某些個測試方法或者類沒有被測試包真正執行。通常這表明名稱規範中存在問題(比如將一個方法命名為
tesSomeReallyComplexCondition
,而不是將其命名為 testSomeReallyComplexCondition
),或者忘記將一個類新增到主
suite()
方法中。在其他情況下,未預期的條件導致跳過了測試方法中的程式碼。不管是什麼情況,都是雖然已經編寫了測試程式碼,但沒有真正執行它。JUnit 不會告訴您它沒有像您所想的那樣執行所有測試,但是 Cobertura 會告訴您。找出了未執行的測試後,改正它一般很容易。
執行 Cobertura
在瞭解了測量程式碼覆蓋率的好處後,讓我們再來討論一下如何用 Cobertura 測量程式碼覆蓋率的具體細節。Cobertura 被設計成為在 Ant 中執行。現在還沒有這方面的 IDE 外掛可用,不過一兩年內也許就會有了。
首先需要在 build.xml 檔案中新增一個任務定義。以下這個頂級 taskdef
元素將 cobertura.jar 檔案限定在當前工作目錄中:
<taskdef classpath="cobertura.jar" resource="tasks.properties" />
然後,需要一個 cobertura-instrument
任務,該任務將在已經編譯好的類檔案中新增日誌程式碼。todir
屬性指定將測量類放到什麼地方。fileset
子元素指定測量哪些 .class 檔案:
<target name="instrument"> <cobertura-instrument todir="target/instrumented-classes"> <fileset dir="target/classes"> <include name="**/*.class"/> </fileset> </cobertura-instrument> </target>
用通常執行測試包的同一種類型的 Ant 任務執行測試。惟一的區別在於:被測量的類必須在原始類出現在類路徑中之前出現在類路徑中,而且需要將 Cobertura JAR 檔案新增到類路徑中:
<target name="cover-test" depends="instrument"> <mkdir dir="${testreportdir}" /> <junit dir="./" failureproperty="test.failure" printSummary="yes" fork="true" haltonerror="true"> <!-- Normally you can create this task by copying your existing JUnit target, changing its name, and adding these next two lines. You may need to change the locations to point to wherever you've put the cobertura.jar file and the instrumented classes. --> <classpath location="cobertura.jar"/> <classpath location="target/instrumented-classes"/> <classpath> <fileset dir="${libdir}"> <include name="*.jar" /> </fileset> <pathelement path="${testclassesdir}" /> <pathelement path="${classesdir}" /> </classpath> <batchtest todir="${testreportdir}"> <fileset dir="src/java/test"> <include name="**/*Test.java" /> <include name="org/jaxen/javabean/*Test.java" /> </fileset> </batchtest> </junit> </target>>
Jaxen 專案使用 JUnit 作為其測試框架,但是 Cobertura 是不受框架影響的。它在 TestNG、Artima SuiteRunner、HTTPUni 或者在您自己在地下室開發的系統中一樣工作得很好。
最後,cobertura-report
任務生成本文開始部分看到的那個 HTML 檔案:
<target name="coverage-report" depends="cover-test"> <cobertura-report srcdir="src/java/main" destdir="cobertura"/> </target>
srcdir
屬性指定原始的 .java 原始碼在什麼地方。destdir
屬性指定 Cobertura 放置輸出 HTML 的那個目錄的名稱。
在自己的 Ant 編譯檔案中加入了類似的任務後,就可以通過鍵入以下命令來生成一個覆蓋報告:
% ant instrument % ant cover-test % ant coverage-report
當然,如果您願意的話,還可以改變目標任務的名稱,或者將這三項任務合併為一個目標任務。
結束語
Cobertura 是敏捷程式設計師工具箱中新增的一個重要工具。通過生成程式碼覆蓋率的具體數值,Cobertura 將單元測試從一種藝術轉變為一門科學。它可以尋找測試覆蓋中的空隙,直接找到 bug。測量程式碼覆蓋率使您可以獲得尋找並修復 bug 所需的資訊,從而開發出對每個人來說都更健壯的軟體。