從 JUnit 深入理解 Java 注解與反射機制
參考資料:
- 編寫JUnit測試
- 詳解介紹JUnit單元測試框架(完整版)
- deepseek
- 封面來自 qwen-image
- 個人項目 github 項目地址
overview
- 本文會涉及:
- 什么是 JUnit
- JUnit 特性簡介
- JUnit 如何使用到了 Java 的反射機制和注解
- 自己實現一個極簡版的
MyJUnit
- 本文不深入討論:
- JUnit 測試用例具體的編寫方法與實踐建議
- Java 的反射機制是如何實現的
什么是 JUnit
- JUnit 是一種主流的 Java 單元測試框架, 在理解 JUnit 之前, 我們要先了解什么是"單元測試", 什么是"單元測試框架", 然后才可以理解什么是 JUnit
- 單元測試:
就是針對最小的功能單元編寫測試代碼. Java 程序的最小功能單元是方法, 所以, 對 Java 程序進行單元測試就是針對單個 Java 方法進行的測試 - 測試驅動開發:
TDD, Test Driven Develop, 即測試驅動開發, 也是我們常說的 測試先行. TDD 的優勢很多, 包括但不限于:- 可以促進開發者對需求進行初步檢驗
- 可以促進開發者對設計進行初步檢驗
- 可以促進開發者提前構思代碼, 有助于寫出高質量代碼
- JUnit:
JUnit 是一個開源的Java語言的單元測試框架, 專門針對 Java 語言設計, 使用最廣泛. JUnit 是事實上的單元測試的標準框架, 任何 Java 開發者都應當學習并使用 JUnit 編寫單元測試.
JUnit 特性簡介
- 注解驅動(Annotation-driven):
這是 JUnit4 和 5 的核心. 通過注解來配置測試行為, 使得代碼非常聲明式, 清晰易懂@Test
: 可以標記一個方法為測試方法@BeforeEach
(JUnit 5) /@Before
(JUnit 4):在每個測試方法之前執行。用于初始化公共資源(如創建對象、連接數據庫)。這體現了設置/拆除(Setup/Teardown) 模式。@AfterEach
(JUnit 5) /@After
(JUnit 4):在每個測試方法之后執行。用于清理資源(如關閉連接、刪除文件)。@BeforeAll
(JUnit 5) /@BeforeClass
(JUnit 4):在所有測試方法執行之前執行一次(方法必須是 static)。適用于昂貴且可共享的初始化,如啟動 Docker 容器。@AfterAll
(JUnit 5) /@AfterClass
(JUnit 4):在所有測試方法執行之后執行一次(方法必須是 static)。@Disabled
(JUnit 5) /@Ignore
(JUnit 4):忽略該測試方法,不執行。
- 斷言(Assertions):
是測試的"靈魂", 用于驗證代碼的行為是否符合預期. 斷言失敗意味著測試失敗assertEquals(expected, actual)
assertTrue(condition)
assertNull(object)
- 異常測試:
- JUnit 4:使用
@Test(expected = Exception.class)
- JUnit 5:使用更強大的
assertThrows()
- JUnit 4:使用
- 參數化測試
當寫下注解@Test
的時候, 實際上發生了什么?
- 我們下面的講解都會基于下面這一個簡單的例子, 從
@Test
這個注解開始逐步切入// 一個測試類 class TestClass{// 一個測試用例@Testvoid testAdd1(){assertEquals(4, 2 + 2);} }
注解
意味著什么? 注解在每個階段的作用?
注解的基本概念
注解實際上是給代碼貼的"標簽"或"元數據", 他們本身不包含業務邏輯, 但可以被其他程序讀取并采取相應行動.
注解的生命周期:從源碼到字節碼
接下來, 我們要進一步理解@Test
在 Java ‘編譯+運行’ 兩個階段發揮的作用
- Java 注解在兩個階段發揮作用:
- 編譯階段:注解信息被寫入字節碼文件
- 運行階段:通過反射機制讀取并處理注解
Java源碼→注解標記進字節碼→字節碼(.class文件)→通過反射機制識別注解并運行測試用例→執行測試用例Java源碼 \rightarrow^{注解標記進字節碼}\rightarrow 字節碼(.class文件) \rightarrow^{通過反射機制識別注解并運行測試用例}\rightarrow 執行測試用例 Java源碼→注解標記進字節碼→字節碼(.class文件)→通過反射機制識別注解并運行測試用例→執行測試用例
注解真的進入字節碼了嗎?
- 最簡單的辦法就是深入
.class
看一看,通過反編譯 .class 文件可以驗證注解確實被保留在字節碼中:
下面我們借用實現好的MyJUnit
小項目, 然后看看字節碼有沒有額外信息
# 編譯為字節碼 / 直接用IDE運行
javac *.java
# 觀察字節碼(.class)文件具體內容
javap *.class > classcontent.txt
下面是 TestClass.class
文件帶有注解的字節碼內容:
...
Constant pool:
...#16 = Utf8 Lcom/example/myjunit/annotations/MyTest;
...
{public com.example.myjunit.core.TestClass();descriptor: ()Vflags: ACC_PUBLICCode:stack=1, locals=1, args_size=10: aload_01: invokespecial #8 // Method java/lang/Object."<init>":()V4: returnLineNumberTable:line 6: 0LocalVariableTable:Start Length Slot Name Signature0 5 0 this Lcom/example/myjunit/core/TestClass;public void testAddRight();descriptor: ()Vflags: ACC_PUBLIC/* ######################## 這里就是注解信息 ########################### */RuntimeVisibleAnnotations:0: #16() // 對應到常量池 Lcom/example/myjunit/annotations/MyTest; 這就對應我們的 `@MyTest` 注解/* ######################## 這里就是注解信息 ########################### */Code:stack=2, locals=1, args_size=10: iconst_41: iconst_42: invokestatic #17 5: returnLineNumberTable:line 9: 0line 10: 5LocalVariableTable:Start Length Slot Name Signature0 6 0 this Lcom/example/myjunit/core/TestClass;public void testAddWrong();descriptor: ()Vflags: ACC_PUBLIC/* ######################## 這里就是注解信息 ########################### */RuntimeVisibleAnnotations:0: #16() // 對應到常量池 Lcom/example/myjunit/annotations/MyTest; 這就對應我們的 `@MyTest` 注解/* ######################## 這里就是注解信息 ########################### */Code:stack=2, locals=1, args_size=10: iconst_51: iconst_42: invokestatic #17 // Method com/example/myjunit/assertions/MyAssert.assertEquals:(II)V5: returnLineNumberTable:line 14: 0line 15: 5LocalVariableTable:Start Length Slot Name Signature0 6 0 this Lcom/example/myjunit/core/TestClass;
}
SourceFile: "TestClass.java"
給自己實現一個 MyJUnit
// 一個測試類
class TestClass{// 一個測試用例@Testvoid testAdd1(){assertEquals(4, 2 + 2);}
}
我們先嘗試打通流程, 更多的注解和斷言后續添加, 所以我們在最開始只需要考慮 一個注解@Test
和 一個函數assertEquals()
my-junit-framework/
└── src/└── main/└── java/└── com/└── example/└── myjunit/├── annotations/ # 注解定義│ └── MyTest.java├── core/ # 核心實現│ ├── MyJUnit.java # 運行函數│ └── TestRunner.java # 運行類└── assertions/ # 斷言工具└── MyAssert.java
-
注解的定義:
// my-junit-framework\src\main\java\com\example\myjunit\annotations\MyTest.java package com.example.myjunit.annotations;import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target;// @Retention 指定注解的生命周期,這里是 RUNTIME,表示注解會保留到運行時,可以通過反射讀取 @Retention(RetentionPolicy.RUNTIME) // @Target 指定注解可以應用的目標,這里是 METHOD,表示只能用于方法 @Target(ElementType.METHOD) public @interface MyTest {// 定義一個空注解,用于標記測試方法 }
-
斷言方法的定義:
// my-junit-framework\src\main\java\com\example\myjunit\assertions\MyAssert.java package com.example.myjunit.assertions;public class MyAssert {// 自定義斷言方法,用于比較兩個整數是否相等public static void assertEquals(int expected, int actual) {if (expected == actual) {return; // 如果相等,測試通過,直接返回} else {// 如果不相等,拋出 AssertionError,測試失敗throw new AssertionError("Assertion failed: expected [" + expected + "] but found [" + actual + "]");}} }
-
運行器
package com.example.myjunit.core; import com.example.myjunit.annotations.*; import com.example.myjunit.assertions.*; import java.lang.reflect.*; import java.util.*; import java.util.concurrent.*;public class MyTestRunner {private final Class<?> testClass; // 測試類的 Class 對象private final TestResult result = new TestResult(); // 測試結果(未實現)// 構造函數,接收一個測試類的 Class 對象public MyTestRunner(Class<?> testClass) {this.testClass = testClass;}// 運行測試方法public void runOnce() {// 獲取測試類中聲明的所有方法Method[] methods = testClass.getDeclaredMethods();List<Method> testMethods = new ArrayList<>();for (Method method : methods) {// 檢查方法是否被 @MyTest 注解標記if (method.isAnnotationPresent(MyTest.class)) {testMethods.add(method); // 將測試方法添加到列表}}// 遍歷所有測試方法并執行for (Method testMethod : testMethods) {try {// 創建測試類的實例Object testInstance = testClass.getDeclaredConstructor().newInstance();testMethod.setAccessible(true); // 確保方法可訪問testMethod.invoke(testInstance); // 調用測試方法System.out.println("Test " + testMethod.getName() + " passed.");} catch (Exception e) {// 捕獲異常并輸出失敗信息System.out.println("Test " + testMethod.getName() + " failed: " + e.getCause());} catch (AssertionError e) {// 捕獲斷言錯誤并輸出失敗信息System.out.println("Test " + testMethod.getName() + " failed: " + e.getMessage());}}return;} }
-
測試用例類
package com.example.myjunit.core; import com.example.myjunit.annotations.MyTest; import com.example.myjunit.assertions.MyAssert;public class TestClass {// 測試方法,驗證 2 + 2 是否等于 4@MyTestpublic void testAddRight() {MyAssert.assertEquals(4, 2 + 2); // 斷言通過} // 測試方法,驗證 2 + 2 是否等于 5@MyTestpublic void testAddWrong() {MyAssert.assertEquals(5, 2 + 2); // 斷言失敗} }
-
主函數
package com.example.myjunit.core;public class MyJUnit {public static void main(String[] args) {// 創建運行器實例,傳入測試類MyTestRunner runner = new MyTestRunner(TestClass.class);// 執行測試runner.runOnce();} }