JUnit原始碼分析 - 擴充套件 - 自定義RunListener
RunListener簡述
JUnit4中的RunListener類用來監聽測試執行的各個階段,由RunNotifier通知測試去執行。RunListener與RunNotifier之間的協作應用的是觀察者模式,RunListener類充當觀察者角色,RunNotifier充當通知者角色,有點類似於JDK中的事件監聽器MouseListener在滑鼠執行不同的操作時觸發相應方法中封裝的動作。RunListener監聽的動作包括如下測試階段:
- 所有測試開始前 :呼叫testRunStarted()
- 所有測試結束後 :呼叫testRunFinished()
- 測試套開始前 :呼叫testSuiteStarted()
- 測試套結束後 :呼叫testSuiteFinished()
- 原子測試方法開始前 :呼叫testStarted()
- 原子測試方法結束後 :呼叫testFinished()
- 原子測試被忽略 :呼叫testIgnored()
- 測試執行失敗或監聽器自身丟擲異常 :呼叫testFailure()
- 原子測試方法因呼叫Assume類中的方法失敗(非測試執行失敗) :呼叫testAssumptionFailure ()
RunListener封裝了測試執行過程處於這些階段時需要做出的響應,另外提供了@ThreadSafe用來表明其所註解的監聽器是執行緒安全的。
JUnit4自身已經提供瞭如下兩個RunListener子類供系統呼叫,我們還可以根據測試實際需要擴充套件自己的RunListener。
- TextListener :以列印文字的形式處理測試各階段的RunListener子類,列印操作由其類成員java.io.PrintStream執行
- SynchronizedRunListener :執行緒安全的RunListener子類,未使用@ThreadSafe修飾的監聽器由該子類封裝
Junit4在Result類中內建了一個私有內部類Listener用於實時統計測試執行資訊,該內部類繼承自RunListener但不對外提供系統呼叫。
RunListener原始碼分析
RunListener定義了9個空方法和1個註解,這裡空方法的主要作用是分類各種測試事件並定義相應的回撥介面,回撥介面中的引數用於將資料傳遞給上層模組。由於各類測試事件發生的時機不同,所以RunListener中分別使用了Description、Result和Failure類封裝回調介面需要傳遞給上層模組的資料。
- 關鍵程式碼
JUnit4.12版本增加了@ThreadSafe註解,JUnit4.13版本新增了testSuiteStarted()和testSuiteFinished()兩個測試事件。去掉英文註釋後的原始碼如下:
//org.junit.runner.notification public class RunListener { //所有測試開始前被呼叫 public void testRunStarted(Description description) throws Exception { } //所有測試結束後被呼叫 public void testRunFinished(Result result) throws Exception { } //測試套執行前被呼叫 public void testSuiteStarted(Description description) throws Exception { } //測試套執行後被呼叫 public void testSuiteFinished(Description description) throws Exception { } //單個原子級測試執行前被呼叫 public void testStarted(Description description) throws Exception { } //單個原子級測試執行後被呼叫 public void testFinished(Description description) throws Exception { } //單個原子級測試失敗時或監聽器丟擲異常時被呼叫,並且把測試失敗資訊寫入到Failure類物件中 public void testFailure(Failure failure) throws Exception { } //原子級測試方法因呼叫Assume類中的方法失敗(非測試執行失敗),並將呼叫失敗資訊寫入到Failure類物件中 public void testAssumptionFailure(Failure failure) { } //當執行到被@Ignore修飾的原子級測試方法時呼叫 public void testIgnored(Description description) throws Exception { } //被@ThreadSafe修飾的監聽器是執行緒安全的 @Documented @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ThreadSafe { } }
- 相關類解析
與RunListener相關的類有Description,Result和Failure,三者都實現了序列化介面,作為測試事件回撥介面的引數傳遞給上層模組。本篇只簡述這三個類的封裝及功能,詳細解析將在後續文件中展開。
Description(org.junit.runner)
Description類用來封裝待測試物件中的文字描述資訊。Description描述的物件可以是單個的測試方法,包含若干個測試方法的單個測試類,或者包含若干個測試類的測試套。
JUnit4使用組合模式來構造待測試物件集合,這些測試物件整體上可以看做一個測試樹,每個測試物件都由一個Description類封裝且包含如下資料:
private final Collection<Description> fChildren = new ConcurrentLinkedQueue<Description>(); //子節點 private final String fDisplayName; //顯示名稱 private final Serializable fUniqueId; //唯一識別符號 private final Annotation[] fAnnotations; //註解陣列 private volatile /* write-once */ Class<?> fTestClass; //待測試類
如果fChildren為空,則該Description為葉子結點;如果fChildren非空,則該Description為複合結點。
Result(org.junit.runner)
Result類用來封裝測試執行結果。每個Result物件都包括如下資料:
private final AtomicInteger count; //測試個數 private final AtomicInteger ignoreCount; //忽略的測試個數 private final AtomicInteger assumptionFailureCount; //因呼叫Assume類方法失敗的測試個數 private final CopyOnWriteArrayList<Failure> failures; //測試失敗個數 private final AtomicLong runTime; //執行時間 private final AtomicLong startTime; //開始時間
從Result物件中封裝的資料可以看出,count,runTime等變數型別為AtomicInteger或AtomicLong,採用的是原子操作,保證變數訪問時的執行緒安全。記錄failures使用的是CopyWriteArrayList(寫時複製)。
寫時複製本身是多執行緒環境中對共享物件執行修改操作時的優化方法,多執行緒執行時如果不修改共享物件則不需要使用此項優化,若某個執行緒需要修改共享物件,則寫時複製功能會首先將該共享物件複製一份,然後在新複製物件的地址空間上進行修改,其他的執行緒仍舊訪問之前舊的共享物件,當新複製物件修改完畢後,再將舊共享物件的指標指向新複製物件,此項優化適用於讀多寫少的場景,也屬於讀寫分離設計的應用。
Result類內部提供了一個私有內部類Listener負責實時統計測試執行資訊,該內部類繼承自RunListener,由@RunListener.ThreadSafe修飾,所以是執行緒安全的。
Result類中還封裝了一個靜態內部類SerializedForm 用於擴充套件序列化,可以根據專案需求將定製的作用域引入到SerializedForm中,從而控制序列化的內容和方式。
Faillure(org.junit.runner.notification)
Failure類用來封裝測試執行失敗時的資訊,JUnit中本身有Failure和Error的概念,在深入Failure之前需要先理清二者的區別。
- Failure:一般用在測試結果斷言的場景中,表明測試結果與預期結果不一致,也即測試過程中發現了Bug
- Error:一般是由程式碼異常引起的,是程式碼實現自身的錯誤,說明待測試程式本身不符合提測的標準或健壯性有問題
我們知道在測試執行結果Result類中有一個變數( private final CopyOnWriteArrayList<Failure> failures;//測試失敗個數 ),該變數代表的含義是如果測試過程中出現執行失敗的情況,則將該失敗資訊封裝為Failure物件並新增到failures列表中。
- 流程分析
因為RunListener的執行流程僅僅是Runner執行測試主流程的一部分,所以此處列出單個測試類執行的典型處理過程(不包括異常處理),並且把與RunListener直接相關的部分以綠色字型顯示,便於理解。單個測試類執行的主要過程如下:
- JUnit用例是通過JUnitCore類來執行的,該類可以通過命令列呼叫,也可以通過IDE呼叫
- JUnitCore的入口函式為main(),main()方法中呼叫runMain()方法,runMain方法執行如下操作
-JUnitCommandLineParseResult類進行 解析引數,並返回解析結果
- TextListener類封裝監聽器
- 呼叫addListener()新增監聽器
- 根據解析結果建立測試請求Request類物件,具體過程如下
- 根據待測試類資訊及預設的測試策略computer建立Request物件,具體過程如下
- 建立所有可能的RunnerBuilder類物件
- 根據computer物件代表的測試策略及所有可能的RunnerBuilder物件創建出相應的BlockJUnit4ClassRunner類物件
- 將Runner類物件封裝為Request類物件並返回
- 根據FilterFactories建立的Filter 過濾規則對返回的Request類物件進行過濾,並返回過濾後的Request
- 由該Request類物件獲取BlockJUnit4ClassRunner類物件,並將其作為run()方法的入參
- 執行JUnitCore類物件中的run()方法,具體過程如下
- 建立Result類物件
- 建立RunListener監聽器
- 新增該新建立的監聽器
- 啟動測試執行
- 呼叫runner.run()執行測試,具體過程如下
- 將 RunNotifier類物件封裝為EachTestNotifier類物件
- 由EachTestNotifier 類物件通知測試執行
- classBlock()函式將notifier轉換為Statement並對其進行一系列封裝處理,具體過程如下
- 呼叫childrenInvoker將notifier轉換為Statement,具體過程如下
- 呼叫runChildren()處理過濾出的所有FrameworkMethod類物件(如果Runner為Suite的話,此處應為被巢狀的Runner)
- 呼叫runChild()處理每一個 FrameworkMethod 類物件
- 呼叫runLeaf()先執行入參中的呼叫再執行statement的evaluate()方法,具體如下
- 呼叫methodBlock()將method轉換為Statement,具體如下
- 生成待測試的Test Class物件
- 呼叫methodInvoker()封裝為InvokeMethod類物件(statement)並返回
- 呼叫possiblyExpectingExceptions()對statement進一步封裝
- 呼叫withPotentialTimeout ()對statement 進一步 封裝
- 呼叫withBefores() 對statement 進一步 封裝
- 呼叫withAfters () 對statement 進一步 封裝
- 呼叫withRules () 對statement 進一步 封裝
- 呼叫withBeforeClasses()
- 結束測試執行,並將結果儲存在新建立的Result類物件中
- 移除監聽器
- 返回Result類物件
RunListener擴充套件示例
本次以簡單的Calculate類為例擴充套件RunListener,在所有測試方法執行前後、每個測試方法執行前後監控測試執行過程資訊。擴充套件RunListener功能的一般步驟如下:
- 自定義Listener繼承自RunListener
- 自定義Runner中新增該自定義Listener
- JUnit用例@RunWith中引入自定義Runner.class
在擴充套件RunListener前,先構造待測試類Calculate及相應的JUnit測試類CalculateTest。此處本身就是簡略示例,所以程式碼中涉及到的硬編碼資訊只是為了看起來更直觀易懂,而非處於易維護的考慮。
//待測試類 public class Calculate { public int add(int a, int b) { return Math.addExact(a, b); } public int subtract(int a, int b) { return Math.subtractExact(a, b); } public int multiple(int a, int b) { return Math.multiplyExact(a, b); } //實際專案中處於健壯性的考慮,Calculate類中的divide()方法實現中應該做相應的異常處理,此處僅僅是為了方便丟擲異常,所以直接return a / b. public int divide(int a, int b) { return a / b; } }
//待測試類的JUnit用例 public class CalculateTest { private Calculate cal; @BeforeClass public static void setUpBeforeClass() throws Exception { System.out.println("類測試開始"); } @AfterClass public static void tearDownAfterClass() throws Exception { System.out.println("類測試結束"); } @Before public void setUp() throws Exception { cal = new Calculate(); System.out.println("方法測試開始"); } @After public void tearDown() throws Exception { System.out.println("方法測試結束"); } @Test public void testAdd_Positive() { assertEquals("加法運算出錯", 6, cal.add(3, 3)); } @Test(expected=ArithmeticException.class) public void testAdd_Negetive() { assertEquals("請檢查運算結果是否溢位及測試平臺支援的基本資料型別取值範圍", 220000000, cal.add(1100000000, 1100000000)); } @Test public void testMinus_Positive() { assertEquals("減法運算出錯", 0, cal.subtract(3, 3)); } @Test(expected=ArithmeticException.class) public void testMinus_Negetive() { assertEquals("請檢查運算結果是否溢位及測試平臺支援的基本資料型別取值範圍", 220000000, cal.subtract(1100000000, -1100000000)); } @Test public void testMultiple_Positive() { assertEquals("乘法運算出錯", 9, cal.multiple(3, 3)); } @Test(expected=ArithmeticException.class) public void testMultiple_Negetive() { assertEquals("請檢查運算結果是否溢位及測試平臺支援的基本資料型別取值範圍", 220000000, cal.multiple(1100000000, 2)); } @Test public void testDivide_Poistive() { assertEquals("除法運算出錯", 3, cal.divide(9, 3)); } @Test(expected=ArithmeticException.class) public void testDivide_Negetive() { assertEquals("請檢查是否存在除0操作", 2, cal.divide(2, 0)); } @Test(timeout=500) public void testTimeout() throws InterruptedException { int sum = 0; for(int i=1; i<=100; i++) { Thread.sleep(1); sum = cal.add(i, sum); } } @Ignore public void testMix() { assertEquals("混合運算出錯", 30, cal.divide(cal.add(100, 200), cal.multiple(2, cal.subtract(10, 5)))); } }
從JUnit中列印的資訊可以看到,用例比較多時測試執行過程資訊容易混淆,除錯起來也很難瞬間定位到出錯的測試方法,如果我們能在測試執行過程資訊中新增相應的測試類名、測試方法名、測試失敗詳情等資訊,執行過程就比較一目瞭然了,除錯效率也會相應提升。基於以上考慮我們可以擴充套件自己的Listener加入這些特性,擴充套件後的Listener類和Runner類如下。
import java.util.Date; import org.junit.runner.Description; import org.junit.runner.Result; import org.junit.runner.notification.Failure; import org.junit.runner.notification.RunListener; //自定義Listener類,此處的JUnit用例為單一測試類,所以與Suite相關的測試事件不需要覆寫 public class MyListener extends RunListener { private long startTime; private long endTime; @Override public void testRunStarted(Description description) throws Exception { startTime = new Date().getTime(); System.out.println("Test Run Started!"); System.out.println("The Test Class is " + description.getClassName() + ". Number of Test Case is " + description.testCount()); System.out.println("==================================================================================="); } @Override public void testRunFinished(Result result) throws Exception { endTime = new Date().getTime(); System.out.println("Test Run Finished!"); System.out.println("Number of Test Case Executed is " + result.getRunCount()); System.out.println("Elipsed Time of this Test Run is " + (endTime - startTime) / 1000); System.out.println("==================================================================================="); } @Override public void testStarted(Description description) throws Exception { System.out.println("Test Method Named " + description.getMethodName() + " Started!"); } @Override public void testFinished(Description description) throws Exception { System.out.println("Test Method Named " + description.getMethodName() + " Ended!"); System.out.println("==================================================================================="); } @Override public void testFailure(Failure failure) throws Exception { System.out.println("Test Method Named " + failure.getDescription().getMethodName() + " Failed!"); System.out.println("Failure Cause is : " + failure.getException()); } @Override public void testAssumptionFailure(Failure failure) { System.out.println("Test Method Named " + failure.getDescription().getMethodName() + " Failed for Assumption!"); } @Override public void testIgnored(Description description) throws Exception { super.testIgnored(description); } }
import org.junit.internal.AssumptionViolatedException; import org.junit.internal.runners.model.EachTestNotifier; import org.junit.runner.notification.RunNotifier; import org.junit.runner.notification.StoppedByUserException; import org.junit.runners.BlockJUnit4ClassRunner; import org.junit.runners.model.InitializationError; import org.junit.runners.model.Statement; //擴充套件Runner,此處的JUnit為單一用例,所以繼承自預設Runner public class MyRunner extends BlockJUnit4ClassRunner { public MyRunner(Class<?> testClass) throws InitializationError { super(testClass); } @Override public void run(RunNotifier notifier) { //新增自定義Listener notifier.addListener(new MyListener()); EachTestNotifier testNotifier = new EachTestNotifier(notifier, getDescription()); notifier.fireTestRunStarted(getDescription()); try { Statement statement = classBlock(notifier); statement.evaluate(); } catch(AssumptionViolatedException av) { testNotifier.addFailedAssumption(av); } catch(StoppedByUserException sbue) { throw sbue; } catch(Throwable e) { testNotifier.addFailure(e); } } }
由於自定義的MyListener中已經擴充套件了測試執行時的監控資訊,所以原待測試類的JUnit用例修改如下:
import static org.junit.Assert.*; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.FixMethodOrder; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.MethodSorters; //修改後的JUnit用例,此處RunWith註解註解用引入自定義Runner @RunWith(MyRunner.class) @FixMethodOrder(MethodSorters.NAME_ASCENDING) public class CalculateTest { private static Calculate cal; @BeforeClass public static void setUpBeforeClass() throws Exception { cal = new Calculate(); } @AfterClass public static void tearDownAfterClass() throws Exception { cal = null; } @Test public void testAdd_Positive() { assertEquals("加法運算出錯", 6, cal.add(3, 3)); } @Test(expected=ArithmeticException.class) public void testAdd_Negetive() { assertEquals("請檢查運算結果是否溢位及測試平臺支援的基本資料型別取值範圍", 220000000, cal.add(1100000000, 1100000000)); } @Test public void testMinus_Positive() { assertEquals("減法運算出錯", 0, cal.subtract(3, 3)); } @Test(expected=ArithmeticException.class) public void testMinus_Negetive() { assertEquals("請檢查運算結果是否溢位及測試平臺支援的基本資料型別取值範圍", 220000000, cal.subtract(1100000000, -1100000000)); } @Test public void testMultiple_Positive() { assertEquals("乘法運算出錯", 9, cal.multiple(3, 3)); } @Test(expected=ArithmeticException.class) public void testMultiple_Negetive() { assertEquals("請檢查運算結果是否溢位及測試平臺支援的基本資料型別取值範圍", 220000000, cal.multiple(1100000000, 2)); } @Test public void testDivide_Poisitive() { assertEquals("除法運算出錯", 3, cal.divide(9, 3)); } @Test(expected=ArithmeticException.class) public void testDivide_Negetive() { assertEquals("請檢查是否存在除0操作", 2, cal.divide(2, 0)); } @Test(timeout=200) public void testTimeout() throws InterruptedException { int sum = 0; for(int i=1; i<=100; i++) { Thread.sleep(1); sum = cal.add(i, sum); } } @Ignore @Test public void testMix() { assertEquals("混合運算出錯", 30, cal.divide(cal.add(100, 200), cal.multiple(2, cal.subtract(10, 5)))); } }
以上JUnit用例執行輸出如下:
Test Run Started! The Test Class is com.junit.test.CalculateTest. Number of Test Case is 10 =================================================================================== Test Method Named testAdd_Negetive Started! Test Method Named testAdd_Negetive Ended! =================================================================================== Test Method Named testAdd_Positive Started! Test Method Named testAdd_Positive Ended! =================================================================================== Test Method Named testDivide_Negetive Started! Test Method Named testDivide_Negetive Ended! =================================================================================== Test Method Named testDivide_Positive Started! Test Method Named testDivide_Positive Ended! =================================================================================== Test Method Named testMinus_Negetive Started! Test Method Named testMinus_Negetive Ended! =================================================================================== Test Method Named testMinus_Positive Started! Test Method Named testMinus_Positive Ended! =================================================================================== Test Method Named testMultiple_Negetive Started! Test Method Named testMultiple_Negetive Ended! =================================================================================== Test Method Named testMultiple_Positive Started! Test Method Named testMultiple_Positive Ended! =================================================================================== Test Method Named testTimeout Started! Test Method Named testTimeout Failed! Failure Cause is : org.junit.runners.model.TestTimedOutException: test timed out after 200 milliseconds Test Method Named testTimeout Ended! =================================================================================== Test Run Finished! Number of Test Case Executed is 9 Elipsed Time of this Test Run is 0 ===================================================================================
RunListener總結
事實上自定義監控器的做法在JUnit或TestNG中都比較常用,TestNG本身也是參考JUnit實現的,而且比JUnit具有更多的實用特性。此處以JUnit為例進行原始碼解析及擴充套件,是考慮到JUnit框架程式碼量相對較少好下手,同時其設計也比較靈活,有不少精煉的實現可供參考。實際專案中可根據開發和測試平臺的實際需求決定採用JUnit還是TestNG做擴充套件,此處僅用作原始碼分析及模式總結。