背景介紹
最近參與了一個新產品的研發工作。新產品是采用模塊式開發方式,擁有眾多的功能模塊,每一個模塊 是一個獨立的 Java 工程。在產品中,為了保證各個模塊的功能,目前其都有相應的 JUnit 測試程序。隨著產品功能的逐 漸完善,我們發現,程序中光是 JUnit Test 測試文件,全部加起來已經有幾百個。由於這些文件分布在幾十個不同的工程 不同的子目錄結構中,目前並沒有很好的工具可以將所有的單元測試一次運行。而手工的運行這些單元測試是非常繁瑣的, 對程序員來說是浪費時間的;又或者可以用腳本完成運行所有測試文件的目的,但是由於我們采取的是敏捷開發的模式, JUnit Test 測試集合會不斷的持續增長,每增加一個 JUnit Test 文件,就需要立即修改腳本;一旦忘記修改,這個新加 的測試文件可能就無法被執行可見,用腳本來執行測試文件也並不是很好的手段,依然給我們的開發帶來額外工作。這裡我 們有了一個想法,做一個全局的單元測試程序,去自動的檢索工程集中所有的 JUnit Test 測試程序。這個全局單元測試程 序將基於 JUnit4 去運行。
核心機制:JUnit4 支持一次運行多個測試程序
首先我們要了解 JUnit4 支持多 個 Class 集合作為輸入,並且調用 org.junit.runner.Runner.run() 方法運行輸入的測試類集合。JUnit4 中已經定義了 一些默認的 Runner,可以分別處理不同的輸入類集合:比如 BlockJUnit4ClassRunner,就是默認處理帶有 @Test 的 JUnit4 測試類的運行器;Suite,可以處理兼容 JUnit3 的測試類的運行器,等等。
我們可以來看下面的 Runner 結構圖:
圖 1. JUnit Runner 結構圖
Runner 類定義了運行測試用例的接口, Suite 類繼承自 Runner 類,Suite 類支持 JUnit3 風格的測試類,可以用來執行多個測試用例。因此我們的想法是,自定 義一個繼承自 Suite 類的 Runner,就是上圖中得 AllClassRunner 類。這個 Runner 的輸入將是工程集中所有找到的 JUnit Test Class 集合,這樣就可以一次運行工程中不同工程不同目錄下的所有 JUnit Test 文件了。下面是我們的 AllClassRunner 類的代碼:
清單 1. AllClassRunner 類的代碼
public class AllJunitTestRunner extends Suite { public AllJunitTestRunner(Class<?> clazz, RunnerBuilder builder) throws InitializationError { // 調用父類 Suite 方法直接運行所有符合要求的 JUnit Test 對象 super(builder, clazz, loadAllTestClass(filterClassNameList (clazz, loadAllClassesName(clazz)))); } }
loadAllClassesName 方法將會找到工程集中所有的 Class 文件名的集合,filterClassNameList 將會對找到 的 Class 文件集合進行過濾,過濾條件是針對文件名稱。loadAllTestClass 則會找到所有符合條件的 JUnitTest Class 集合。
如何找到所有的需要的 JUnit Test 測試類集合
我們來看下面的流程圖:
圖 2. 找到需要的 JUnit Test Class 對象集合的流程圖
流程圖要素介紹:
找到工程集內所有的 .class 文件名
對找到的文件名集合根據過濾條件進行初步過濾
根據 .class 文件名轉換到相應的 Class 對象
判斷是否屬於 Abstract 類,如果不是的話,繼續判斷
判斷是否屬於 Inner 類,如果不是的話,繼續判斷
判斷是否屬於 JUnit3 或者 JUnit4 風格的類,如果是的話,加入結果集
經過上述步驟,將找到所有需要的 JUnit Test class 集合。將找到的 Class 集合放入到自定義 Runner 中,可以達到 一次運行工程集內所有測試程序的目的。
如何找出所有的 Class 文件名集合
首先根據默認的 Java classpath 屬性,找到當前運行的 JUnit Test 文件所在工程集中所有的文件及文件夾:
清單 2. 找到工程集中所 有 Jar 及 .Class 所在目錄集合
public static String getClasspath() { return System.getProperty(CONSTANT.DEFAULT_CLASSPATH_PROPERTY); }
這裡得到的將是一個路徑集合的 String 對象,包括文件目錄及 .jar 文件(jar 文件內也包含 Class 文件名 )。我們需要做的,是找到文件目錄下的 Class 文件,因此通過 split 這個路徑集合,剔除裡面的 .jar 文件,我們將得 到一個包含工程集中所有 Class 文件的目錄集合。
因為 Class 文件可能會嵌套的包含在我們找到的目錄集合中, 所以我們需要遞歸的去找到所有的 Class 文件,並將 Class 文件的 Class 名放到一個集合中,示例代碼如下:
清 單 3. 遞歸找到工程集中所有的 Class 對象集合
for (String path : Util.splitClassPath (Util.getClasspath())) { // 此處不處理 Jar 包文件,感興趣的讀者可以考慮自己添加對 Jar 包中 JUnit Test 文件的處理 if (!(path.toLowerCase().endsWith(CONSTANT.JAR_SUFFIX))) { Util.loadAllClassNames(path, path, classesFileNameList); } } public static void loadAllClassNames(String rootPath, String currentPath, List<String> classNameList) { File currentFile = new File(currentPath); if (currentFile.isFile()) { // 如果是文件的話,直接將路徑名轉換為文件名,並加入結果集 if (Util.isClassFile(currentFile.getName())) { classNameList.add( Util.replaceFileSeparator( Util.removeClassSuffix( // 只獲取文件的名字,並將路徑中的反斜槓”/”轉換為文件名中的”.” // 比如獲取文件名為”com.aa.bb.cc” Util.getFileNameWithoutRootPath(currentFile, rootPath)))); } } else { // 如果是文件夾的話,則取所有的子文件,遞歸處理所有取到的子文件 for (File file : currentFile.listFiles()) { if (file.isFile()) { if (Util.isClassFile(file.getName())) { classNameList.add(Util.replaceFileSeparator(Util.removeClassSuffix (Util.getFileNameWithoutRootPath(file, rootPath)))); } } else { loadAllClassNames(rootPath, file.getAbsolutePath(), classNameList); } } } }
上面我們得到的將是所有一個 List<String> 對象,包含所有的 .class 文件。
如何過濾 Class 文件名
可以自定義一系列的過濾條件,比如對於 package “com.aa.bb.cc”下的所有 JUnit Test 文件都不測試, 那麼可以寫如下的 filter:
清單 4. 對 Class 名字使用過濾條件的示例代碼
public static boolean classNameIsInArray(String className) { String filters = "com.aa.bb.cc1.*;com.aa.bb.cc2.*"; String[] filterList = filters.split(";"); if (filters == null || filterList.length < 1) { return false; } for (String pattern : filterList) { if (className.matches(pattern)) { return true; } } return false; }
如何過濾 Abstract 類
我們知道一個 JUnit Test 文件絕對不可能是 Abstract 類,因此可以把 Abstract 類從 Class 集合中首先過濾掉。示例代碼如下:
清單 5. 過濾 Abstract 類的示例代碼
public static boolean isAbstractClass(Class<?> clazz) { return (clazz.getModifiers()&Modifier.ABSTRACT) != 0; }
如何過濾 Inner 類
JUnit Test 類中一般來說不允許再定義一個子類,因此對於 Inner class 來說, 也是我們的剔除對象。示例代碼展示如何找到 Inner 類。
清單 6. 過濾 Inner 類的示例代碼
public static final String INNER_CLASS_CHAR = "$"; public static boolean isInnerClass(String className) { return className.contains(INNER_CLASS_CHAR); }
如何找到 JUnit3 文件
首先我們來看一個典型的基於 JUnit3 的單元測試程序:
清單 7. 典型 JUnit3 風格的測試代碼
import junit.framework.TestCase; import static org.junit.Assert.*; public class AddOperationTest extends TestCase{ public void setUp() throws Exception { } public void tearDown() throws Exception { } // 測試方法必須以 test 開頭 public void testAdd() { int x = 0; int y = 0; AddOperation instance = new AddOperation(); int expResult = 0; int result = instance.add(x, y); assertEquals(expResult, result); } }
我們可以看到上面的單元測試擁有如下特征:
1.繼承自 junit.framework.TestCase 類;
2.要 測試的方法以 test 開頭。
在這裡我們只需要知道一個 .class 文件是否是 JUnit3 的測試程序,因此第二條特征 暫時用不上,我們用是否繼承自 TestCase 類來作為判斷標准,代碼如下:
清單 8. 找出 JUnit3 風格的 JUnit Test 測試文件的示例代碼
public static boolean isJUnit3TestClass(Class<?> clazz) { // class.isAssignableFrom() 方法可以找到即使是父類的父類的繼承關系 // 因此認為如果輸入的子類繼承自 JUnit Test3.8 的 TestCase 類,則認為是 JUnit3 風格的 JUnit Test 類對象 return TestCase.class.isAssignableFrom(clazz); }
如果是繼承自 TestCase 這個類,則返回 true,代表是 JUnit3 的單元測試; 否則返回 false;.
如何 找到 JUnit4 文件
對於 JUnit4,我們知道它最大的特征是引入了 Annotation 機制,簡化了原來的單元測試的用法 。我們可以來看下面的例子:
清單 9. 典型 JUnit4 風格的測試代碼
import junit.framework.TestCase; import org.junit.After; import org.junit.Before; import org.junit.Test; import static org.junit.Assert.*; public class AddOperationTest extends TestCase{ @Before public void setUp() throws Exception { } @After public void tearDown() throws Exception { } @Test public void add() { int x = 0; int y = 0; AddOperation instance = new AddOperation(); int expResult = 0; int result = instance.add(x, y); assertEquals(expResult, result); } }
我們可以看到上面的 JUnit4 測試程序擁有如下特征:
1.繼承自 junit.framework.TestCase 類;
2.擁有至少一個有 @Test 的注釋的測試方法,且方法名稱任意
因此對於 JUnit4 的 Class 文件來說,我們 需要判斷的是它的方法內是否有 @Test Annotation,如果沒有的話,就不是一個有效的 JUnit4 測試文件。示例代碼如下 :
清單 10. 找出 JUnit4 風格的 JUnit Test 測試文件的示例代碼
public static boolean isJUnit4TestClass(Class<?> clazz) { try { for (Method method : clazz.getMethods()) { // 如果在 class 對象所有的方法中發現 @Test 注釋,則認為是 JUnit4 風格的 JUnit Test 對象 if (method.getAnnotation(Test.class) != null) { return true; } } } catch (NoClassDefFoundError ignore) { return false; } return false; }
使用各種過濾條件
前面我們已經得到了一個在本工程集內的所有 Class 文件的集合,現在我們可以使用 各種過濾條件對集合進行過濾,示例代碼如下:
清單 11. 聯合使用各種過濾條件找出需要的 JUnit Test 的示例代 碼
for (String className : classesFileNameList) { Class<?> classFromName = null; try { // 從 class 名字轉換為 class 對象 classFromName = Class.forName(className); } catch (ClassNotFoundException e) { // 如果轉換失敗,則跳過 continue; } // JUnit Test class 對象不可能是內部 class,所以跳過檢測所有的內部 class if (!Util.isInnerClass(className)) { Class<?> classFromName = Class.forName(className); if (classFromName.isLocalClass()|| classFromName.isAnonymousClass()) { // JUnit Test class 對象也不可能是 local 或者 Anonymous 對象,跳過 continue; } if (!Util.isAbstractClass(classFromName) && (Util.isJUnit4TestClass(classFromName)|| Util.isJUnit38TestClass(classFromName))) { toBeRanTestClassList.add(classFromName); } } }
這裡我們找到的 toBeRanTestClassList 集合就是我們期望測試的 JUnit Test Class 對象集合。
JUnit 自動執行所有測試
現在我們已經有了自己的 AllJUnitTestRunner,也找到了工程集內所有的 JUnit Test Class 集合。下面要做的就是如何運行找到的 Class 集合。具體代碼如下:
清單 12. 我們的期望值 - 最終的 JUnit Test 示例代碼
import org.junit.runner.RunWith; import junit.AllJunitTestRunner; @RunWith(AllJunitTestRunner.class) public class AllJunitTest { }
只需要一個空的 JUnitTest 測試文件,並將 AllJUnitTestRunner 作為這個測試文件的 Runner。那麼在運行這 個測試程序時,它將調用我們定義的 Runner 去自動運行所有我們期望的 JUnit Test 測試程序。是不是很方便呢?可以說 基本達到了我們預期的目的。
改進點
我們知道在 JUnit4 中支持自定義的 Annotation,因此對於我們的各 種過濾項,應該可以通過定義新的 Annotation 方式從而在最終的 JUnit Test 程序中任意定義,這樣比硬編碼在全局單元 測試程序模塊內更加方便使用。
遞歸查找整個工程集內所有的 class 文件名會對查找速度產生影響。本人覺得在遞 歸的同時應該可以再次對路徑名等進行過濾,避免在不必要的目錄內不停的查找,以提高性能。
以上是本人目前想 到的 2 個改進點,希望大家踴躍討論,提高程序的性能及易用性。
總結
本文主要利用了 Class 文件的各 種特征及 JUnit3、JUnit4 的特征,及 JUnit4 中可以一次執行多個 JUnit Test 文件的特性,達到一次執行全部工程集內 的所有測試文件的目的。目前,在實際項目執行中起到了非常大的作用。對於所有新增的功能點,新增的測試文件,在正式 上傳到服務器之前都只需要執行一個 JUnit Test 文件,就可以發現是否影響原有功能,增加了程序開發過程的自動性,節 約了大量程序開發糾錯的時間,提高了產品的質量。