从 JUnit 深入理解 Java 注解与反射机制
参考资料:
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
这个注解开始逐步切入java// 一个测试类 class TestClass{ // 一个测试用例 @Test void testAdd1(){ assertEquals(4, 2 + 2); } }
注解
意味着什么? 注解在每个阶段的作用?
注解的基本概念
注解实际上是给代码贴的"标签"或"元数据", 他们本身不包含业务逻辑, 但可以被其他程序读取并采取相应行动.
注解的生命周期:从源码到字节码
接下来, 我们要进一步理解@Test
在 Java '编译+运行' 两个阶段发挥的作用
- Java 注解在两个阶段发挥作用:
- 编译阶段:注解信息被写入字节码文件
- 运行阶段:通过反射机制读取并处理注解
J a v a 源码 → 注解标记进字节码 → 字节码 ( . c l a s s 文件 ) → 通过反射机制识别注解并运行测试用例 → 执行测试用例 Java源码 \rightarrow^{注解标记进字节码}\rightarrow 字节码(.class文件) \rightarrow^{通过反射机制识别注解并运行测试用例}\rightarrow 执行测试用例 Java源码→注解标记进字节码→字节码(.class文件)→通过反射机制识别注解并运行测试用例→执行测试用例
注解真的进入字节码了吗?
- 最简单的办法就是深入
.class
看一看,通过反编译 .class 文件可以验证注解确实被保留在字节码中:
下面我们借用实现好的MyJUnit
小项目, 然后看看字节码有没有额外信息
bash
# 编译为字节码 / 直接用IDE运行
javac *.java
# 观察字节码(.class)文件具体内容
javap *.class > classcontent.txt
下面是 TestClass.class
文件带有注解的字节码内容:
java
...
Constant pool:
...
#16 = Utf8 Lcom/example/myjunit/annotations/MyTest;
...
{
public com.example.myjunit.core.TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/myjunit/core/TestClass;
public void testAddRight();
descriptor: ()V
flags: ACC_PUBLIC
/* ######################## 这里就是注解信息 ########################### */
RuntimeVisibleAnnotations:
0: #16()
// 对应到常量池 Lcom/example/myjunit/annotations/MyTest; 这就对应我们的 `@MyTest` 注解
/* ######################## 这里就是注解信息 ########################### */
Code:
stack=2, locals=1, args_size=1
0: iconst_4
1: iconst_4
2: invokestatic #17
5: return
LineNumberTable:
line 9: 0
line 10: 5
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lcom/example/myjunit/core/TestClass;
public void testAddWrong();
descriptor: ()V
flags: ACC_PUBLIC
/* ######################## 这里就是注解信息 ########################### */
RuntimeVisibleAnnotations:
0: #16()
// 对应到常量池 Lcom/example/myjunit/annotations/MyTest; 这就对应我们的 `@MyTest` 注解
/* ######################## 这里就是注解信息 ########################### */
Code:
stack=2, locals=1, args_size=1
0: iconst_5
1: iconst_4
2: invokestatic #17 // Method com/example/myjunit/assertions/MyAssert.assertEquals:(II)V
5: return
LineNumberTable:
line 14: 0
line 15: 5
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lcom/example/myjunit/core/TestClass;
}
SourceFile: "TestClass.java"
给自己实现一个 MyJUnit
java
// 一个测试类
class TestClass{
// 一个测试用例
@Test
void testAdd1(){
assertEquals(4, 2 + 2);
}
}
我们先尝试打通流程, 更多的注解和断言后续添加, 所以我们在最开始只需要考虑 一个注解@Test
和 一个函数assertEquals()
text
my-junit-framework/
└── src/
└── main/
└── java/
└── com/
└── example/
└── myjunit/
├── annotations/ # 注解定义
│ └── MyTest.java
├── core/ # 核心实现
│ ├── MyJUnit.java # 运行函数
│ └── TestRunner.java # 运行类
└── assertions/ # 断言工具
└── MyAssert.java
-
注解的定义:
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 { // 定义一个空注解,用于标记测试方法 }
-
断言方法的定义:
java// 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 + "]"); } } }
-
运行器
javapackage 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; } }
-
测试用例类
javapackage com.example.myjunit.core; import com.example.myjunit.annotations.MyTest; import com.example.myjunit.assertions.MyAssert; public class TestClass { // 测试方法,验证 2 + 2 是否等于 4 @MyTest public void testAddRight() { MyAssert.assertEquals(4, 2 + 2); // 断言通过 } // 测试方法,验证 2 + 2 是否等于 5 @MyTest public void testAddWrong() { MyAssert.assertEquals(5, 2 + 2); // 断言失败 } }
-
主函数
javapackage com.example.myjunit.core; public class MyJUnit { public static void main(String[] args) { // 创建运行器实例,传入测试类 MyTestRunner runner = new MyTestRunner(TestClass.class); // 执行测试 runner.runOnce(); } }