歡迎您光臨本站 註冊首頁

分析 JUnit 框架源代碼

←手機掃碼閱讀     火星人 @ 2014-03-12 , reply:0
  
本文細緻地描述了 JUnit 的代碼實現,在展示代碼流程 UML 圖的基礎上,詳細分析 JUnit 的內部實現代碼的功能與機制,並在涉及相關設計模式的地方結合代碼予以說明。另外,分析過程還涉及 Reflection 等 Java 語言的高級特徵。

概述

在測試驅動的開發理念深入人心的今天,JUnit 在測試開發領域的核心地位日漸穩定。不僅 Eclipse 將 JUnit 作為默認的 IDE 集成組件,而且基於 JUnit 的各種測試框架也在業內被廣泛應用,並獲得了一致好評。目前介紹 JUnit 書籍文章雖然較多,但大多數是針對 JUnit 的具體應用實踐,而對於 JUnit 本身的機制原理,只是停留在框架模塊的較淺層次。

本文內容完全描述 JUnit 的細緻代碼實現,在展示代碼流程 UML 圖的基礎上,詳細分析 JUnit 的內部實現代碼的功能與機制,並在涉及相關設計模式的地方結合代碼予以說明。另外,分析過程還涉及 Reflection 等 Java 語言的高級特徵。

本文的讀者應該對 JUnit 的基本原理及各種設計模式有所了解,主要是面向從事 Java 相關技術的設計、開發與測試的人員。對於 C++,C# 程序員也有很好的借鑒作用。





Junit 簡介

JUnit 的概念及用途

JUnit 是由 Erich Gamma 和 Kent Beck 編寫的一個開源的單元測試框架。它屬於白盒測試,只要將待測類繼承 TestCase 類,就可以利用 JUnit 的一系列機制進行便捷的自動測試了。

JUnit 的設計精簡,易學易用,但是功能卻非常強大,這歸因於它內部完善的代碼結構。 Erich Gamma 是著名的 GOF 之一,因此 JUnit 中深深滲透了擴展性優良的設計模式思想。 JUnit 提供的 API 既可以讓您寫出測試結果明確的可重用單元測試用例,也提供了單元測試用例成批運行的功能。在已經實現的框架中,用戶可以選擇三種方式來顯示測試結果,並且顯示的方式本身也是可擴展的。

JUnit 基本原理

一個 JUnit 測試包含以下元素:


表 1. 測試用例組成
開發代碼部分 測試代碼部分 測試工具部分
待測試類 A 通過擴展 TestCase 或者構造 TestSuit 方法
編寫測試類 B
一個測試運行器(TestRunner)R,可以選擇圖形界面或文本界面

操作步驟:

將 B 通過命令行方式或圖形界面選擇方式傳遞給 R,R 自動運行測試,並顯示結果。

JUnit 中的設計模式體現

設計模式(Design pattern)是一套被反覆使用的、為眾人所知的分類編目的代碼設計經驗總結。使用設計模式是為了可重用和擴展代碼,增加代碼的邏輯性和可靠性。設計模式的出現使代碼的編製真正工程化,成為軟體工程的基石。

GoF 的《設計模式》一書首次將設計模式提升到理論高度,並將之規範化。該書提出了 23 種基本設計模式,其後,在可復用面向對象軟體的發展過程中,新的設計模式亦不斷出現。

軟體框架通常定義了應用體系的整體結構類和對象的關係等等設計參數,以便於具體應用實現者能集中精力於應用本身的特定細節。因此,設計模式有助於對框架結構的理解,成熟的框架通常使用了多種設計模式,JUnit 就是其中的優秀代表。設計模式是 JUnit 代碼的精髓,沒有設計模式,JUnit 代碼無法達到在小代碼量下的高擴展性。總體上看,有三種設計模式在 JUnit 設計中得到充分體現,分別為 Composite 模式、Command 模式以及 Observer 模式。





一個簡單的 JUnit 程序實例

我們首先用一個完整實例來說明 JUnit 的使用。由於本文的分析對象是 JUnit 本身的實現代碼,因此測試類實例的簡化無妨。本部分引入《 JUnit in Action 》中一個 HelloWorld 級別的測試實例,下文的整個分析會以該例子為基點,剖析 JUnit 源代碼的內部流程。

待測試類如下:


圖 1. 待測試代碼

該類只有一個 add 方法,即求兩個浮點數之和返回。

下面介紹測試代碼部分,本文以 JUnit3.8 為實驗對象,JUnit4.0 架構類同。筆者對原書中的測試類做了一些修改,添加了一個必然失敗的測試方法 testFail,目的是為了演示測試失敗時的 JUnit 代碼流程。

完整的測試類代碼如下:


圖 2. 測試類代碼

TestCalculator 擴展了 JUnit 的 TestCase 類,其中 testAdd 方法就是對 Calculator.add 方法的測試,它會在測試開始後由 JUnit 框架從類中提取出來運行。在 testAdd 中,Calculator 類被實例化,並輸入測試參數 10 和 50,最後用 assertEquals 方法(基類 TestCase 提供)判斷測試結果與預期是否相等。無論測試符合預期或不符合都會在測試工具TestRunner 中體現出來。

實例運行結果:


圖 3. 實例運行結果

從運行結果中可見:testAdd 測試通過(未顯示),而 testFail 測試失敗。圖形界面結果如下:


圖 4. 測試圖形結果





JUnit 源代碼分析

JUnit 的完整生命周期分為 3 個階段:初始化階段、運行階段和結果捕捉階段。


圖 5. JUnit 的完整生命周期圖(查看大圖)

初始化階段(創建 Testcase 及 TestSuite)


圖 6. JUnit 的 main 函數代碼

初始化階段作一些重要的初始化工作,它的入口點在 junit.textui.TestRunner 的 main 方法。該方法首先創建一個 TestRunner 實例 aTestRunner。之後 main 函數中主體工作函數為 TestResult r = aTestRunner.start(args) 。

它的函數構造體代碼如下 :


圖 7. junit 的 start(String[]) 函數

我們可以看出,Junit 首先對命令行參數進行解析:參數“ -wait ”(等待模式,測試完畢用戶手動返回)、“ -c ”,“ -v ”(版本顯示)。 -m 參數用於測試單個方法。這是 JUnit 提供給用戶的一個非常輕便靈巧的測試功能,但是在一般情況下,用戶會像本文前述那樣在類名命令行參數,此時通過語句:

testCase = args[i];

將測試類的全限定名將傳給 String 變數 testcase 。

然後通過:

Test suite = getTest(testCase); 

將對 testCase 持有的全限定名進行解析,並構造 TestSuite 。


圖 8. getTest() 方法函數源代碼

TestSuite 的構造分兩種情況 ( 如上圖 ):

  • A:用戶在測試類中通過聲明 Suite() 方法自定義 TestSuite 。
  • B:JUnit 自動判斷並提取測試方法。

JUnit 提供給用戶兩種構造測試集合的方法,用戶既可以自行編碼定義結構化的 TestCase 集合,也可以讓 JUnit 框架自動創建測試集合,這種設計融合其它功能,讓測試的構建、運行、反饋三個過程完全無縫一體化。

情況 A:


圖 9. 自定義 TestSuite 流程圖

當 suite 方法在 test case 中定義時,JUnit 創建一個顯式的 test suite,它利用 Java 語言的 Reflection 機制找出名為 SUITE_METHODNAME 的方法,也即 suite 方法:

suiteMethod = testClass.getMethod(SUITE_METHODNAME, new Class[0]);

Reflection 是 Java 的高級特徵之一,藉助 Reflection 的 API 能直接在代碼中動態獲取到類的語言編程層面的信息,如類所包含的所有的成員名、成員屬性、方法名以及方法屬性,而且還可以通過得到的方法對象,直接調用該方法。 JUnit 源代碼頻繁使用了 Reflection 機制,不僅充分發揮了 Java 語言在系統編程要求下的超凡能力,也使 JUnit 能在用戶自行編寫的測試類中遊刃有餘地分析並提取各種屬性及代碼,而其它測試框架需要付出極大的複雜性才能得到等價功能。

若 JUnit 無法找到 siute 方法,則拋出異常,流程進入情況 B 代碼;若找到,則對用戶提供的 suite 方法進行外部特徵檢驗,判斷是否為類方法。最後,JUnit 自動調用該方法,構造用戶指定的 TestSuite:

test = (Test)suiteMethod.invoke(null, (Object[]) new Class[0]);

情況 B:


圖 10. 自動判斷並提取 TestSuite 流程圖

當 suite 方法未在 test case 中定義時,JUnit 自動分析創建一個 test suite 。代碼由 :

return new TestSuite(testClass); 

處進入 TestSuite(Class theclass) 方法為 TestSuite 類的構造方法,它能自動分析 theclass 所描述的類的內部有哪些方法需要測試,並加入到新構造的 TestSuite 中。代碼如下:


圖 11. TestSuite 函數代碼

TestSuite 採用了Composite 設計模式。在該模式下,可以將 TestSuite 比作一棵樹,樹中可以包含子樹(其它 TestSuite),也可以包含葉子 (TestCase),以此向下遞歸,直到底層全部落實到葉子為止。 JUnit 採用 Composite 模式維護測試集合的內部結構,使得所有分散的 TestCase 能夠統一集中到一個或若干個 TestSuite 中,同類的 TestCase 在樹中佔據同等的位置,便於統一運行處理。另外,採用這種結構使測試集合獲得了無限的擴充性,不需要重新構造測試集合,就能使新的 TestCase 不斷加入到集合中。

在 TestSuite 類的代碼中,可以找到:

private Vector fTests = new Vector(10);

此即為內部維護的“子樹或樹葉”的列表。

紅框內的代碼完成提取整個類繼承體系上的測試方法的提取。循環語句由 Class 類型的實例 theClass 開始,逐級向父類的繼承結構追溯,直到頂級 Object 類,並將沿途各級父類中所有合法的 testXXX() 方法都加入到 TestSuite 中。

合法 testXXX 的判斷工作由:

addTestMethod(methods[i], names, theClass)

完成,實際上該方法還把判斷成功的方法轉化為 TestCase 對象,並加入到 TestSuite 中。代碼如下圖 :


圖 12. addTestMethod 函數代碼

首先通過 String name= m.getName(); 利用 Refection API 獲得 Method 對象 m 的方法名,用於特徵判斷。然後通過方法

isTestMethod(Method m)

中的

return parameters.length == 0 && name.startsWith("test") && returnType.equals(Void.TYPE);

來判別方法名是不是以字元串“ test ”開始。

而代碼:

if (names.contains(name))       return;

用於在逐級追溯過程中,防止不同級別父類中的 testXXX() 方法重複加入 TestSuite 。

對於符合條件的 testXXX() 方法,addTestMethod 方法中用語句:

addTest(createTest(theClass, name));

將 testXXX 方法轉化為 TestCase,並加入到 TestSuite 。其中,addTest 方法接受 Test 介面類型的參數,其內部有 countTestCases 方法和 run 方法,該介面被 TestSuite 和 TestCase 同時實現。這是 Command 設計模式精神的體現,

Command 模式將調用操作的對象與如何實現該操作的對象解耦。在運行時,TestCase 或 TestSuite 被當作 Test 命令對象,可以像一般對象那樣進行操作和擴展,也可以在實現 Composite 模式時將多個命令複合成一個命令。另外,增加新的命令十分容易,隔離了現有類的影響,今後,也可以與備忘錄模式結合,實現 undo 等高級功能。

加入 TestSuite 的 TestCase 由 createTest(theClass, name) 方法創建,代碼如下:


圖 13. CreateTest 函數代碼(查看大圖)

TestSuite 和 TestCase 都有一個fName實例變數,是在其後的測試運行及結果返回階段中該 Test 的唯一標識,對 TestCase 來說,一般也是要測試的方法名。在 createTest 方法中,測試方法被轉化成一個 TestCase 實例,並通過:

((TestCase) test).setName(name); 

用該方法名標識 TestCase 。其中,test 對象也是通過 Refection 機制,通過 theClass 構建的:

test = constructor.newInstance(new Object[0]); 

注意:theClass 是圖 8 中 getTest 方法的 suiteClassName 字元串所構造的 Class 類實例,而後者其實是命令行參數傳入的帶測試類 Calculator,它繼承了 TestCase 方法。因此,theClass 完全具備轉化的條件。

至此整個流程的初始化完成。

測試驅動運行階段(運行所有 TestXXX 型的測試方法)

由圖 7 所示 , 我們可以知道初始化完畢,即 testsuit() 創建好后 , 便進入方法 :

doRun(suite, wait); 

代碼如下 :


圖 14. doRun 函數代碼

該方法為測試的驅動運行部分,結構如下:

  • 創建 TestResult 實例。
  • 將 junit.textui.TestRunner 的監聽器 fPrinter 加入到 result 的監聽器列表中。

其中,fPrinter 是 junit.textui.ResultPrinter 類的實例,該類提供了向控制台輸出測試結果的一系列功能介面,輸出的格式在類中定義。 ResultPrinter 類實現了 TestListener 介面,具體實現了 addError、addFailure、endTest 和 startTest 四個重要的方法,這種設計是 Observer 設計模式的體現,在 addListener 方法的代碼中:

public synchronized void addListener(TestListener listener) {      fListeners.addElement(listener);  }

將 ResultPrinter 對象加入到 TestResult 對象的監聽器列表中,因此實質上 TestResult 對象可以有多個監聽器顯示測試結果。第三部分分析中將會描述對監聽器的消息更新。

  • 計時開始。
  • run(result) 測試運行。
  • 計時結束。
  • 統一輸出,包括測試結果和所用時間。

其中最為重要的步驟為 run(result) 方法,代碼如下。


圖 15. run 函數代碼

Junit 通過 for (Enumeration e= tests(); e.hasMoreElements(); ){ …… } 對 TestSuite 中的整個“樹結構”遞歸遍歷運行其中的節點和葉子。此處 JUnit 代碼頗具說服力地說明了 Composite 模式的效力,run 介面方法的抽象具有重大意義,它實現了客戶代碼與複雜對象容器結構的解耦,讓對象容器自己來實現自身的複雜結構,從而使得客戶代碼就像處理簡單對象一樣來處理複雜的對象容器。每次循環得到的節點 test,都同 result 一起傳遞給 runTest 方法,進行下一步更深入的運行。


圖 16. junit.framework.TestResult.run 函數代碼

這裡變數 P 指向一個實現了 Protectable 介面的匿名類的實例,Protectable 介面只有一個 protect 待實現方法。而 junit.framework.TestResult.runProtected(Test, Protectable) 方法的定義為:

public void runProtected(final Test test, Protectable p) {      try {          p.protect();      }       catch (AssertionFailedError e) {          addFailure(test, e);      }      catch (ThreadDeath e) { // don't catch ThreadDeath by accident          throw e;      }      catch (Throwable e) {          addError(test, e);      }  }

可見 runProtected 方法實際上是調用了剛剛實現的 protect 方法,也就是調用了 test.runBare() 方法。另外,這裡的 startTest 和 endTest 方法也是 Observer 設計模式中的兩個重要的消息更新方法。

以下分析 junit.framework.TestCase.runBare() 方法:


圖 17. junit.framework.TestCase.runBare() 函數代碼

在該方法中,最終的測試會傳遞給一個 runTest 方法執行,注意此處的 runTest 方法是無參的,注意與之前形似的方法區別。該方法中也出現了經典的 setUp 方法和 tearDown 方法,追溯代碼可知它們的定義為空。用戶可以覆蓋兩者,進行一些 fixture 的自定義和搭建。 ( 注意:tearDown 放在了 finally{} 中,在測試異常拋出后仍會被執行到,因此它是被保證運行的。 )

主體工作還是在 junit.framework.TestCase.runTest() 方法中 , 代碼如下 :


圖 18. junit.framework.TestCase.runTest() 函數代碼

該方法最根本的原理是:利用在圖 13 中設定的 fName,藉助 Reflection 機制,從 TestCase 中提取測試方法:

runMethod = getClass().getMethod(fName, (Class[]) null); 

為每一個測試方法,創建一個方法對象 runMethod 並調用:

runMethod.invoke(this, (Object[]) new Class[0]); 

只有在這裡,用戶測試方法的代碼才開始被運行。

在測試方法運行時,眾多的 Assert 方法會根據測試的實際情況,拋出失敗異常或者錯誤。也是在“ runMethod.invoke(this, (Object[]) new Class[0]); ”這裡,這些異常或錯誤往上逐層拋出,或者被某一層次處理,或者處理后再次拋出,依次遞推,最終顯示給用戶。

流程圖如下 :


圖 19. JUnit 執行測試方法,並在測試結束后將失敗和錯誤信息通知所有 test listener

測試結果捕捉階段(返回 Fail 或 Error 並顯示)

通過以下代碼,我們可以看出失敗由第一個 catch 子句捕獲,並交由 addFailure 方法處理,而錯誤由第三個 catch 子句捕獲,並交由 addError 方法處理。


圖 20. 失敗處理函數代碼


圖 21. 失敗處理流程圖

JUnit 執行測試方法,並在測試結束后將失敗和錯誤信息通知給所有的 test listener 。其中 addFailure、addError、endTest、startTest 是 TestListener 介面的四大方法,而 TestListener 涉及到 Observer 設計模式。

我們嘗試看看 addFailure 方法的代碼:


圖 22. addFailure 方法的代碼

此處代碼將產生的失敗對象加入到了 fFailures,可聯繫 圖 2,此處的結果在程序退出時作為測試總體成功或失敗的判斷依據。而在 for 循環中,TestResult 對象循環遍歷觀察者(監聽器)列表,通過調用相應的更新方法,更新所有的觀察者信息,這部分代碼也是整個 Observer 設計模式架構的重要部分。

根據以上描述,JUnit 採用 Observer 設計模式使得 TestResult 與眾多測試結果監聽器通過介面 TestListenner 達到松耦合,使 JUnit 可以支持不同的使用方式。目標對象(TestResult)不必關心有多少對象對自身註冊,它只是根據列表通知所有觀察者。因此,TestResult 不用更改自身代碼,而輕易地支持了類似於 ResultPrinter 這種監聽器的無限擴充。目前,已有文本界面、圖形界面和 Eclipse 集成組件三種監聽器,用戶完全可以開發符合介面的更強大的監聽器。

出於安全考慮,cloneListeners() 使用克隆機製取出監聽器列表:

private synchronized Vector cloneListeners() {      return (Vector)fListeners.clone();  }

TestResult 的 addFailure 進一步調用 ResultPrinter 的 addFailure:


圖 23. ResultPrinter 的 addFailure 函數代碼

這裡並沒有將錯誤信息輸出,而只是輸出了錯誤類型:“ F “。錯誤信息由圖 14 中的:

fPrinter.print(result, runTime); 

統一輸出。

這些設計細節皆可以由 TestRunner 的實現者自己掌握。





總結

鑒於 JUnit 目前在測試領域的顯赫地位,以及 JUnit 實現代碼本身編寫的簡潔與藝術性,本文的詳盡分析無論對於測試開發的實踐運用、基於 JUnit 的高層框架的編寫、以及設計模式與 Java 語言高級特徵的學習都具有多面的重要意義。(責任編輯:A6)



[火星人 ] 分析 JUnit 框架源代碼已經有791次圍觀

http://coctec.com/docs/linux/show-post-68871.html